diff --git a/packages/happy-dom/src/cookie/Cookie.ts b/packages/happy-dom/src/cookie/Cookie.ts new file mode 100644 index 000000000..ddcf02813 --- /dev/null +++ b/packages/happy-dom/src/cookie/Cookie.ts @@ -0,0 +1,122 @@ +const CookiePairRegex = /([^=]+)(?:=([\s\S]*))?/; + + +export default class Cookie { + private pairs: { [key: string]: string } = {}; + // + public key: string = ''; + public value: string = ''; + public size: number = 0; + // Optional + public domain: string = ''; + public path: string = ''; + public expriesOrMaxAge: Date = null; + public httpOnly: boolean = false; + public secure: boolean = false; + public sameSite: string = ''; + + + constructor(cookie: string) { + let match: RegExpExecArray | null; + + const parts = cookie.split(";").filter(Boolean); + + // Part[0] is the key-value pair. + match = new RegExp(CookiePairRegex).exec(parts[0]); + if (!match) { + throw new Error(`Invalid cookie: ${cookie}`); + } + this.key = match[1].trim(); + this.value = match[2]; + // set key is empty if match[2] is undefined. + if (!match[2]) { + this.value = this.key; + this.key = ''; + } + this.pairs[this.key] = this.value; + this.size = this.key.length + this.value.length; + // Attribute. + for (const part of parts.slice(1)) { + match = new RegExp(CookiePairRegex).exec(part); + if (!match) { + throw new Error(`Invalid cookie: ${part}`); + } + const key = match[1].trim(); + const value = match[2]; + + switch (key.toLowerCase()) { + case "expires": + this.expriesOrMaxAge = new Date(value); + break; + case "max-age": + this.expriesOrMaxAge = new Date(parseInt(value, 10) * 1000 + Date.now()); + break; + case "domain": + this.domain = value; + break; + case "path": + this.path = value.startsWith("/") ? value : `/${value}`; + break; + case "httponly": + this.httpOnly = true; + break; + case "secure": + this.secure = true; + break; + case "samesite": + this.sameSite = value; + break; + default: + continue; // skip. + } + // skip unknown key-value pair. + if (['expires', 'max-age', 'domain', 'path', 'httponly', 'secure', 'samesite'].indexOf(key.toLowerCase()) === -1) { + continue; + } + this.pairs[key] = value; + } + } + + public rawString(): string { + return Object.keys(this.pairs).map(key => { + if (key) { + return `${key}=${this.pairs[key]}`; + } + return this.pairs[key]; + }).join("; "); + } + + public cookieString(): string { + if (this.key) { + return `${this.key}=${this.value}`; + } + return this.value; + } + + + public isExpired(): boolean { + // If the expries/maxage is set, then determine whether it is expired. + if (this.expriesOrMaxAge && this.expriesOrMaxAge.getTime() < Date.now()) { + return true; + } + // If the expries/maxage is not set, it's a session-level cookie that will expire when the browser is closed. + // (it's never expired in happy-dom) + return false; + } + + public isHttpOnly(): boolean { + return this.httpOnly; + } + + public isSecure(): boolean { + return this.secure; + } + + public static parse(cookieString: string): Cookie { + return new Cookie(cookieString); + } + + public static stringify(cookie: Cookie): string { + return cookie.toString(); + } +} \ No newline at end of file diff --git a/packages/happy-dom/src/cookie/CookieJar.ts b/packages/happy-dom/src/cookie/CookieJar.ts new file mode 100644 index 000000000..ce3a9930f --- /dev/null +++ b/packages/happy-dom/src/cookie/CookieJar.ts @@ -0,0 +1,61 @@ +import Location from "src/location/Location"; +import Cookie from "./Cookie"; + + +export default class CookieJar { + private cookies: Cookie[] = []; + + + private validateCookie(cookie: Cookie): boolean { + if (cookie.key.toLocaleUpperCase().startsWith('__secure-') && !cookie.isSecure()) return false; + if (cookie.key.toLocaleUpperCase().startsWith('__host-') && (!cookie.isSecure() || cookie.path !== '/' || cookie.domain)) return false; + return true; + } + + public setCookiesString(cookieString: string): void { + if (!cookieString) return; + const newCookie = new Cookie(cookieString); + if (!this.validateCookie(newCookie)) { + return; + } + this.cookies.filter(cookie => cookie.key === newCookie.key).forEach(cookie => { + this.cookies.splice(this.cookies.indexOf(cookie), 1); + }); + this.cookies.push(newCookie); + } + + // return the cookie string. + // skip httponly when use document.cookie. + public getCookiesString(url: Location, fromDocument: boolean): string { + const cookies = this.cookies.filter(cookie => { + // skip when use document.cookie and the cookie is httponly. + if (fromDocument && cookie.isHttpOnly()) return false; + if (cookie.isExpired()) return false; + if (cookie.isSecure() && url.protocol !== "https:") return false; + if (cookie.domain && !url.hostname.endsWith(cookie.domain)) return false; + if (cookie.path && !url.pathname.startsWith(cookie.path)) return false; + // TODO: check SameSite. + /* + switch (cookie.getSameSite()) { + case "Strict": + // means that the browser sends the cookie only for same-site requests, that is, requests originating from the same site that set the cookie. If a request originates from a different domain or scheme (even with the same domain), no cookies with the SameSite=Strict attribute are sent. + if (url.hostname !== cookie.getDomain()) return false; + break; + case "Lax": + // means that the cookie is not sent on cross-site requests, such as on requests to load images or frames, but is sent when a user is navigating to the origin site from an external site (for example, when following a link). This is the default behavior if the SameSite attribute is not specified. + if (url.hostname !== cookie.getDomain()) return false; + break; + + case "None": + // means that the browser sends the cookie for same-site requests, and for cross-site requests with a CORS header. This is the default value if the SameSite attribute is not specified. + break; + default: + break; + + } + */ + return true; + }); + return cookies.map(cookie => cookie.cookieString()).join("; "); + } +} \ No newline at end of file diff --git a/packages/happy-dom/src/cookie/CookieUtility.ts b/packages/happy-dom/src/cookie/CookieUtility.ts deleted file mode 100644 index f4fae29b3..000000000 --- a/packages/happy-dom/src/cookie/CookieUtility.ts +++ /dev/null @@ -1,87 +0,0 @@ -import Location from '../location/Location'; - -/** - * Cookie utility. - */ -export default class CookieUtility { - /** - * Returns a cookie string. - * - * @param location Location. - * @param cookies Current cookie string. - * @param newCookie New cookie string. - * @returns Generated cookie string. - */ - public static getCookieString(location: Location, cookies: string, newCookie): string { - const newCookieParts = newCookie.split(';'); - const [newCookieName, newCookieValue] = newCookieParts.shift().trim().split('='); - let isExpired = false; - - for (const part of newCookieParts) { - const [key, value] = part.trim().split('='); - - switch (key.toLowerCase()) { - case 'expires': - const expires = new Date(value).getTime(); - const now = Date.now(); - if (expires < now) { - isExpired = true; - break; - } - break; - case 'domain': - const hostnameParts = location.hostname.split('.'); - if (hostnameParts.length > 2) { - hostnameParts.shift(); - } - const currentDomain = hostnameParts.join('.'); - if (!value.endsWith(currentDomain)) { - return cookies; - } - break; - case 'path': - const pathname = location.pathname; - const currentPath = pathname.startsWith('/') ? pathname.replace('/', '') : pathname; - const path = value.startsWith('/') ? value.replace('/', '') : value; - if (path && !currentPath.startsWith(path)) { - return cookies; - } - break; - case 'max-age': - if (parseInt(value) <= 0) { - return cookies; - } - break; - } - } - - const newCookies = []; - - if (cookies) { - for (const cookie of cookies.split(';')) { - const [name, value] = cookie.trim().split('='); - if ( - (name && name !== newCookieName) || - (!value && newCookieValue) || - (value && !newCookieValue) - ) { - if (value) { - newCookies.push(`${name}=${value}`); - } else { - newCookies.push(name); - } - } - } - } - - if (!isExpired) { - if (newCookieValue) { - newCookies.push(`${newCookieName}=${newCookieValue}`); - } else { - newCookies.push(newCookieName); - } - } - - return newCookies.join('; '); - } -} diff --git a/packages/happy-dom/src/nodes/document/Document.ts b/packages/happy-dom/src/nodes/document/Document.ts index c57005391..eecce14c5 100644 --- a/packages/happy-dom/src/nodes/document/Document.ts +++ b/packages/happy-dom/src/nodes/document/Document.ts @@ -19,7 +19,7 @@ import QuerySelector from '../../query-selector/QuerySelector'; import IDocument from './IDocument'; import CSSStyleSheet from '../../css/CSSStyleSheet'; import DOMException from '../../exception/DOMException'; -import CookieUtility from '../../cookie/CookieUtility'; +import CookieJar from '../../cookie/CookieJar'; import IElement from '../element/IElement'; import IHTMLScriptElement from '../html-script-element/IHTMLScriptElement'; import IHTMLElement from '../html-element/IHTMLElement'; @@ -66,7 +66,7 @@ export default class Document extends Node implements IDocument { protected _isFirstWrite = true; protected _isFirstWriteAfterOpen = false; - private _cookie = ''; + private _cookie = new CookieJar(); private _selection: Selection = null; // Events @@ -258,7 +258,7 @@ export default class Document extends Node implements IDocument { * @returns Cookie. */ public get cookie(): string { - return this._cookie; + return this._cookie.getCookiesString(this.defaultView.location, true); } /** @@ -267,7 +267,7 @@ export default class Document extends Node implements IDocument { * @param cookie Cookie string. */ public set cookie(cookie: string) { - this._cookie = CookieUtility.getCookieString(this.defaultView.location, this._cookie, cookie); + this._cookie.setCookiesString(cookie); } /**