Skip to content

Commit

Permalink
Merge pull request #851 from capricorn86/task/836-wrong-patch-677-cookie
Browse files Browse the repository at this point in the history
Task/836 wrong patch 677 cookie
  • Loading branch information
capricorn86 committed Apr 11, 2023
2 parents bee0cfe + 79fe6c5 commit c70d674
Show file tree
Hide file tree
Showing 9 changed files with 252 additions and 158 deletions.
149 changes: 58 additions & 91 deletions packages/happy-dom/src/cookie/Cookie.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,54 @@
const CookiePairRegex = /([^=]+)(?:=([\s\S]*))?/;
import DOMException from '../exception/DOMException';
import DOMExceptionNameEnum from '../exception/DOMExceptionNameEnum';
import CookieSameSiteEnum from './CookieSameSiteEnum';
import { URL } from 'url';

/**
* Cookie.
*/
export default class Cookie {
private pairs: { [key: string]: string } = {};
//
// Required
public key = '';
public value = '';
public size = 0;
public value: string | null = null;
public originURL: URL;

// Optional
public domain = '';
public path = '';
public expriesOrMaxAge: Date = null;
public expires: Date | null = null;
public httpOnly = false;
public secure = false;
public sameSite = '';
public sameSite: CookieSameSiteEnum = CookieSameSiteEnum.lax;

/**
* Constructor.
*
* @param originURL Origin URL.
* @param cookie Cookie.
*/
constructor(cookie: string) {
let match: RegExpExecArray | null;
constructor(originURL, cookie: string) {
const parts = cookie.split(';');
const [key, value] = parts.shift().split('=');

const parts = cookie.split(';').filter(Boolean);
this.originURL = originURL;
this.key = key.trim();
this.value = value !== undefined ? value : null;

// 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] && parts[0][this.key.length] !== '=') {
this.value = this.key;
this.key = '';
if (!this.key) {
throw new DOMException(`Invalid cookie: ${cookie}.`, DOMExceptionNameEnum.syntaxError);
}
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()) {
for (const part of parts) {
const keyAndValue = part.split('=');
const key = keyAndValue[0].trim().toLowerCase();
const value = keyAndValue[1];

switch (key) {
case 'expires':
this.expriesOrMaxAge = new Date(value);
this.expires = new Date(value);
break;
case 'max-age':
this.expriesOrMaxAge = new Date(parseInt(value, 10) * 1000 + Date.now());
this.expires = new Date(parseInt(value, 10) * 1000 + Date.now());
break;
case 'domain':
this.domain = value;
Expand All @@ -70,53 +63,42 @@ export default class Cookie {
this.secure = true;
break;
case 'samesite':
this.sameSite = value;
switch (value.toLowerCase()) {
case 'strict':
this.sameSite = CookieSameSiteEnum.strict;
break;
case 'lax':
this.sameSite = CookieSameSiteEnum.lax;
break;
case 'none':
this.sameSite = CookieSameSiteEnum.none;
}
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;
}
}

/**
* Returns a raw string of the cookie.
*/
public rawString(): string {
return Object.keys(this.pairs)
.map((key) => {
if (key) {
return `${key}=${this.pairs[key]}`;
}
return this.pairs[key];
})
.join('; ');
}

/**
* Returns cookie string.
*
* @returns Cookie string.
*/
public cookieString(): string {
if (this.key) {
public toString(): string {
if (this.value !== null) {
return `${this.key}=${this.value}`;
}
return this.value;

return this.key;
}

/**
* Returns "true" if expired.
*
* @returns "true" if expired.
*/
public isExpired(): boolean {
// If the expries/maxage is set, then determine whether it is expired.
if (this.expriesOrMaxAge && this.expriesOrMaxAge.getTime() < Date.now()) {
if (this.expires && this.expires.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.
Expand All @@ -125,34 +107,19 @@ export default class Cookie {
}

/**
* Validate cookie.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#cookie_prefixes
* @returns "true" if valid.
*/
public isHttpOnly(): boolean {
return this.httpOnly;
}

/**
*
*/
public isSecure(): boolean {
return this.secure;
}

/**
* Parse a cookie string.
*
* @param cookieString
*/
public static parse(cookieString: string): Cookie {
return new Cookie(cookieString);
}

/**
* Stringify a Cookie object.
*
* @param cookie
*/
public static stringify(cookie: Cookie): string {
return cookie.toString();
public validate(): boolean {
const lowerKey = this.key.toLowerCase();
if (lowerKey.startsWith('__secure-') && !this.secure) {
return false;
}
if (lowerKey.startsWith('__host-') && (!this.secure || this.path !== '/' || this.domain)) {
return false;
}
return true;
}
}
105 changes: 52 additions & 53 deletions packages/happy-dom/src/cookie/CookieJar.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Location from 'src/location/Location';
import Cookie from './Cookie';
import CookieSameSiteEnum from './CookieSameSiteEnum';
import { URL } from 'url';

/**
* CookieJar.
Expand All @@ -11,72 +12,70 @@ export default class CookieJar {
private cookies: Cookie[] = [];

/**
* Validate cookie.
* Adds cookie string.
*
* @param cookie
* @param originURL Origin URL.
* @param cookieString Cookie string.
*/
private validateCookie(cookie: Cookie): boolean {
if (cookie.key.toLowerCase().startsWith('__secure-') && !cookie.isSecure()) {
return false;
}
if (
cookie.key.toLowerCase().startsWith('__host-') &&
(!cookie.isSecure() || cookie.path !== '/' || cookie.domain)
) {
return false;
}
return true;
}

/**
* Set cookie.
*
* @param cookieString
*/
public setCookiesString(cookieString: string): void {
public addCookieString(originURL: URL, cookieString: string): void {
if (!cookieString) {
return;
}
const newCookie = new Cookie(cookieString);
if (!this.validateCookie(newCookie)) {

const newCookie = new Cookie(originURL, cookieString);

if (!newCookie.validate()) {
return;
}
this.cookies
.filter((cookie) => cookie.key === newCookie.key)
.forEach((cookie) => {
this.cookies.splice(this.cookies.indexOf(cookie), 1);
});
this.cookies.push(newCookie);

for (let i = 0, max = this.cookies.length; i < max; i++) {
if (
this.cookies[i].key === newCookie.key &&
this.cookies[i].originURL.hostname === newCookie.originURL.hostname &&
// Cookies with or without values are treated differently in the browser.
// Therefore, the cookie should only be replaced if either both has a value or if both has no value.
// The cookie value is null if it has no value set.
// This is a bit unlogical, so it would be nice with a link to the spec here.
typeof this.cookies[i].value === typeof newCookie.value
) {
this.cookies.splice(i, 1);
break;
}
}

if (!newCookie.isExpired()) {
this.cookies.push(newCookie);
}
}

/**
* Get cookie.
* Get cookie string.
*
* @param location Location.
* @param targetURL Target URL.
* @param fromDocument If true, the caller is a document.
* @returns Cookie string.
*/
public getCookiesString(location: 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() && location.protocol !== 'https:') {
return false;
}
if (cookie.domain && !location.hostname.endsWith(cookie.domain)) {
return false;
}
if (cookie.path && !location.pathname.startsWith(cookie.path)) {
return false;
public getCookieString(targetURL: URL, fromDocument: boolean): string {
let cookieString = '';

for (const cookie of this.cookies) {
if (
(!fromDocument || !cookie.httpOnly) &&
!cookie.isExpired() &&
(!cookie.secure || targetURL.protocol === 'https:') &&
(!cookie.domain || targetURL.hostname.endsWith(cookie.domain)) &&
(!cookie.path || targetURL.pathname.startsWith(cookie.path)) &&
// @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
((cookie.sameSite === CookieSameSiteEnum.none && cookie.secure) ||
cookie.originURL.hostname === targetURL.hostname)
) {
if (cookieString) {
cookieString += '; ';
}
cookieString += cookie.toString();
}
// TODO: Check same site behaviour.
return true;
});
return cookies.map((cookie) => cookie.cookieString()).join('; ');
}

return cookieString;
}
}
6 changes: 6 additions & 0 deletions packages/happy-dom/src/cookie/CookieSameSiteEnum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
enum CookieSameSiteEnum {
strict = 'Strict',
lax = 'Lax',
none = 'None'
}
export default CookieSameSiteEnum;
14 changes: 6 additions & 8 deletions packages/happy-dom/src/fetch/Fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Socket } from 'net';
import Stream from 'stream';
import DataURIParser from './data-uri/DataURIParser';
import FetchCORSUtility from './utilities/FetchCORSUtility';
import CookieJar from '../cookie/CookieJar';

const SUPPORTED_SCHEMAS = ['data:', 'http:', 'https:'];
const REDIRECT_STATUS_CODES = [301, 302, 303, 307, 308];
Expand Down Expand Up @@ -557,7 +558,10 @@ export default class Fetch {
this.request.credentials === 'include' ||
(this.request.credentials === 'same-origin' && !isCORS)
) {
const cookie = document.defaultView.document.cookie;
const cookie = document.defaultView.document._cookie.getCookieString(
this.ownerDocument.defaultView.location,
false
);
if (cookie) {
headers.set('Cookie', cookie);
}
Expand Down Expand Up @@ -614,13 +618,7 @@ export default class Fetch {
// Handles setting cookie headers to the document.
// "set-cookie" and "set-cookie2" are not allowed in response headers according to spec.
if (lowerKey === 'set-cookie' || lowerKey === 'set-cookie2') {
const isCORS = FetchCORSUtility.isCORS(this.ownerDocument.location, this.request._url);
if (
this.request.credentials === 'include' ||
(this.request.credentials === 'same-origin' && !isCORS)
) {
this.ownerDocument.cookie = header;
}
(<CookieJar>this.ownerDocument['_cookie']).addCookieString(this.request._url, header);
} else {
headers.append(key, header);
}
Expand Down
4 changes: 2 additions & 2 deletions packages/happy-dom/src/nodes/document/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export default class Document extends Node implements IDocument {
* @returns Cookie.
*/
public get cookie(): string {
return this._cookie.getCookiesString(this.defaultView.location, true);
return this._cookie.getCookieString(this.defaultView.location, true);
}

/**
Expand All @@ -299,7 +299,7 @@ export default class Document extends Node implements IDocument {
* @param cookie Cookie string.
*/
public set cookie(cookie: string) {
this._cookie.setCookiesString(cookie);
this._cookie.addCookieString(this.defaultView.location, cookie);
}

/**
Expand Down

0 comments on commit c70d674

Please sign in to comment.