135 lines
5.7 KiB
JavaScript
135 lines
5.7 KiB
JavaScript
import url from "url";
|
|
import {paramError, CookieParseError} from "./errors.mjs";
|
|
|
|
const validateHostname = (cookieHostname, requestHostname, subdomains) => {
|
|
cookieHostname = cookieHostname.toLowerCase();
|
|
requestHostname = requestHostname.toLowerCase();
|
|
if(requestHostname === cookieHostname || (subdomains && requestHostname.endsWith("." + cookieHostname)))
|
|
return true;
|
|
return false;
|
|
};
|
|
|
|
const validatePath = (cookiePath, requestPath) => {
|
|
cookiePath = decodeURIComponent(cookiePath).toLowerCase();
|
|
requestPath = decodeURIComponent(requestPath).toLowerCase();
|
|
if(cookiePath.endsWith("/"))
|
|
cookiePath = cookiePath.slice(0, -1);
|
|
if(requestPath.endsWith("/"))
|
|
requestPath = requestPath.slice(0, -1);
|
|
return (requestPath + "/").startsWith(cookiePath + "/");
|
|
};
|
|
|
|
const splitN = (str, sep, n) => {
|
|
const splitted = str.split(sep);
|
|
if(n < splitted.length - 1) {
|
|
splitted[n] = splitted.slice(n).join(sep);
|
|
splitted.splice(n + 1);
|
|
}
|
|
return splitted;
|
|
};
|
|
|
|
export default class Cookie {
|
|
constructor(str, requestURL) {
|
|
if(typeof str !== "string")
|
|
throw paramError("First", "str", "new Cookie()", "string");
|
|
if(typeof requestURL !== "string")
|
|
throw paramError("Second", "requestURL", "new Cookie()", "string");
|
|
|
|
// check if url is valid
|
|
new url.URL(requestURL);
|
|
|
|
const splitted = str.split("; ");
|
|
[this.name, this.value] = splitN(splitted[0], "=", 1);
|
|
if(!this.name)
|
|
throw new CookieParseError("Invalid cookie name \"" + this.name + "\"!");
|
|
if(this.value.startsWith("\"") && this.value.endsWith("\""))
|
|
this.value = this.value.slice(1, -1);
|
|
|
|
const parsedURL = url.parse(requestURL);
|
|
|
|
for(let i = 1; i < splitted.length; i++) {
|
|
let [k, v] = splitN(splitted[i], "=", 1);
|
|
k = k.toLowerCase();
|
|
if(v) {
|
|
if(k === "expires") {
|
|
if(this.expiry) // max-age has precedence over expires
|
|
continue;
|
|
if(!/^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2}[ -](?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ -]\d{2,4} \d{2}:\d{2}:\d{2} GMT$/.test(v)
|
|
|| (this.expiry = new Date(v)).toString() === "Invalid Date"
|
|
|| this.expiry.getTime() < 0)
|
|
throw new CookieParseError("Invalid value for Expires \"" + v + "\"!");
|
|
}
|
|
else if(k === "max-age") {
|
|
const seconds = ~~+v;
|
|
if(seconds.toString() !== v)
|
|
throw new CookieParseError("Invalid value for Max-Age \"" + v + "\"!");
|
|
this.expiry = new Date();
|
|
this.expiry.setSeconds(this.expiry.getSeconds() + seconds);
|
|
}
|
|
else if(k === "domain") {
|
|
if(v.startsWith("."))
|
|
v = v.substring(1);
|
|
if(!validateHostname(parsedURL.hostname, v, true))
|
|
throw new CookieParseError("Invalid value for Domain \"" + v + "\": cookie was received from \"" + parsedURL.hostname + "\"!");
|
|
this.domain = v;
|
|
this.subdomains = true;
|
|
}
|
|
else if(k === "path")
|
|
this.path = v;
|
|
else if(k === "samesite") // only relevant for cross site requests, so not for us
|
|
continue;
|
|
else
|
|
throw new CookieParseError("Invalid key \"" + k + "\" with value \"" + v + "\" specified!");
|
|
}
|
|
else {
|
|
if(k === "secure")
|
|
this.secure = true;
|
|
else if(k === "httponly") // only relevant for browsers
|
|
continue;
|
|
else
|
|
throw new CookieParseError("Invalid key \"" + k + "\" specified!");
|
|
}
|
|
}
|
|
|
|
if(this.name.toLowerCase().startsWith("__secure-") && (!this.secure || parsedURL.protocol !== "https:"))
|
|
throw new CookieParseError("Cookie has \"__Secure-\" prefix but \"Secure\" isn't set or the cookie is not set via https!");
|
|
if(this.name.toLowerCase().startsWith("__host-") && (!this.secure || parsedURL.protocol !== "https:" || this.domain || this.path !== "/"))
|
|
throw new CookieParseError("Cookie has \"__Host-\" prefix but \"Secure\" isn't set, the cookie is not set via https, \"Domain\" is set or \"Path\" is not equal to \"/\"!");
|
|
|
|
// assign defaults
|
|
if(!this.domain) {
|
|
this.domain = parsedURL.hostname;
|
|
this.subdomains = false;
|
|
}
|
|
if(!this.path)
|
|
this.path = "/";
|
|
if(!this.secure)
|
|
this.secure = false;
|
|
if(!this.expiry)
|
|
this.expiry = null;
|
|
}
|
|
static fromObject(obj) {
|
|
let c = Object.assign(Object.create(this.prototype), obj);
|
|
if(typeof c.expiry === "string")
|
|
c.expiry = new Date(c.expiry);
|
|
return c;
|
|
}
|
|
serialize() {
|
|
return this.name + "=" + this.value;
|
|
}
|
|
hasExpired(sessionEnded) {
|
|
return sessionEnded && this.expiry === null || this.expiry && this.expiry < new Date();
|
|
}
|
|
isValidForRequest(requestURL) {
|
|
if(this.hasExpired(false))
|
|
return false;
|
|
const parsedURL = url.parse(requestURL);
|
|
if(parsedURL.protocol !== "http:" && parsedURL.protocol !== "https:"
|
|
|| this.secure && parsedURL.protocol !== "https:"
|
|
|| !validateHostname(this.domain, parsedURL.hostname, this.subdomains)
|
|
|| !validatePath(this.path, parsedURL.pathname))
|
|
return false;
|
|
return true;
|
|
}
|
|
};
|