Skip to content

Commit

Permalink
Migrate to our own raw-content cookie parser
Browse files Browse the repository at this point in the history
Useful to give us cookie + set-cookie parsing with the same format, and
avoid various automatic format handling (Date etc) that
set-cookie-parser does unhelpfully.
  • Loading branch information
pimterry committed Dec 5, 2024
1 parent 3dd492b commit e5b73eb
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 46 deletions.
28 changes: 0 additions & 28 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@
"@types/remarkable": "^1.7.3",
"@types/semver": "^7.3.1",
"@types/serialize-error": "^2.1.0",
"@types/set-cookie-parser": "0.0.3",
"@types/styled-components": "^5.1.34",
"@types/traverse": "^0.6.32",
"@types/ua-parser-js": "^0.7.33",
Expand Down Expand Up @@ -133,7 +132,6 @@
"semver": "^7.5.2",
"serialize-error": "^3.0.0",
"serializr": "^1.5.4",
"set-cookie-parser": "^2.3.5",
"styled-components": "^5.0.0",
"styled-reset": "^1.1.2",
"swagger2openapi": "^5.3.2",
Expand Down
30 changes: 14 additions & 16 deletions src/components/view/http/set-cookie-header-description.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import * as React from 'react';

import { parse as parseCookie, Cookie } from 'set-cookie-parser';
import {
isFuture,
addSeconds,
format as formatDate,
distanceInWordsToNow
} from 'date-fns';

import { Cookie, parseSetCookieHeader } from '../../../model/http/cookies'
import { Content } from '../../common/text-content';

function getExpiryExplanation(date: Date) {
Expand All @@ -26,17 +26,15 @@ function getExpiryExplanation(date: Date) {
}

export const CookieHeaderDescription = (p: { value: string, requestUrl: URL }) => {
const cookies = parseCookie(p.value);
const cookies = parseSetCookieHeader(p.value);

// The effective path at which cookies will be set by default.
const requestPath = p.requestUrl.pathname.replace(/\/[^\/]*$/, '') || '/';

return <>{
// In 99% of cases there is only one cookie here, but we can play it safe.
cookies.map((
cookie: Cookie & { sameSite?: 'Strict' | 'Lax' | 'None' }
) => {
if (cookie.sameSite?.toLowerCase() === 'none' && !cookie.secure) {
cookies.map((cookie: Cookie) => {
if (cookie.samesite?.toLowerCase() === 'none' && !cookie.secure) {
return <Content key={cookie.name}>
<p>
This attempts to set cookie '<code>{cookie.name}</code>' to
Expand Down Expand Up @@ -75,29 +73,29 @@ export const CookieHeaderDescription = (p: { value: string, requestUrl: URL }) =
</p>
<p>
The cookie is {
cookie.httpOnly ?
cookie.httponly ?
'not accessible from client-side scripts' :
'accessible from client-side scripts running on matching pages'
}
{ (cookie.sameSite === undefined || cookie.sameSite.toLowerCase() === 'lax')
{ (cookie.samesite === undefined || cookie.samesite.toLowerCase() === 'lax')
// Lax is default for modern browsers (e.g. Chrome 80+)
? <>
. Matching requests triggered from other origins will {
cookie.httpOnly ? 'however' : 'also'
cookie.httponly ? 'however' : 'also'
} include this cookie, if they are top-level navigations (not subresources).
</>
: cookie.sameSite.toLowerCase() === 'strict' && cookie.httpOnly
: cookie.samesite.toLowerCase() === 'strict' && cookie.httponly
? <>
, or sent in requests triggered from other origins.
</>
: cookie.sameSite.toLowerCase() === 'strict' && !cookie.httpOnly
: cookie.samesite.toLowerCase() === 'strict' && !cookie.httponly
? <>
, but will not be sent in requests triggered from other origins.
</>
: cookie.sameSite.toLowerCase() === 'none' && cookie.secure
: cookie.samesite.toLowerCase() === 'none' && cookie.secure
? <>
. Matching requests triggered from other origins will {
cookie.httpOnly ? 'however' : 'also'
cookie.httponly ? 'however' : 'also'
} include this cookie.
</>
: <>
Expand All @@ -108,12 +106,12 @@ export const CookieHeaderDescription = (p: { value: string, requestUrl: URL }) =

<p>
The cookie {
cookie.maxAge ? <>
{ getExpiryExplanation(addSeconds(new Date(), cookie.maxAge)) }
cookie['max-age'] ? <>
{ getExpiryExplanation(addSeconds(new Date(), parseInt(cookie['max-age'], 10))) }
{ cookie.expires && ` ('max-age' overrides 'expires')` }
</> :
cookie.expires ?
getExpiryExplanation(cookie.expires)
getExpiryExplanation(new Date(cookie.expires))
: 'expires at the end of the current session'
}.
</p>
Expand Down
113 changes: 113 additions & 0 deletions src/model/http/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Partially taken from https://github.com/jshttp/cookie under MIT license,
// heavily rewritten with setCookie support added.

export interface Cookie {
name: string;
value: string;
path?: string;
httponly?: boolean;
secure?: boolean;
samesite?: string;
domain?: string;
expires?: string;
'max-age'?: string;
[key: string]: string | boolean | undefined;
}

export function parseSetCookieHeader(
headers: string | string[]
) {
if (!Array.isArray(headers)) {
headers = [headers];
}

const cookies: Array<Cookie> = [];

for (const header of headers) {
const [cookieKV, ...parts] = header.split(";");

const [name, value] = (cookieKV?.split("=") ?? []);
if (!name || value === undefined) continue;

const cookie: Cookie = {
name,
value
};

for (const part of parts) {
let [key, val] = part.split("=");
key = key.trim().toLowerCase();
val = val?.trim() ?? true;
cookie[key] = val;
}

cookies.push(cookie);
}

return cookies;
}

export function parseCookieHeader(
str: string
): Array<Cookie> {
const cookies: Array<Cookie> = [];
const len = str.length;
// RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='.
if (len < 2) return cookies;

let index = 0;

do {
const eqIdx = str.indexOf("=", index);
if (eqIdx === -1) break; // No more cookie pairs.

const colonIdx = str.indexOf(";", index);
const endIdx = colonIdx === -1 ? len : colonIdx;

if (eqIdx > endIdx) {
// backtrack on prior semicolon
index = str.lastIndexOf(";", eqIdx - 1) + 1;
continue;
}

const nameStartIdx = startIndex(str, index, eqIdx);
const nameEndIdx = endIndex(str, eqIdx, nameStartIdx);
const name = str.slice(nameStartIdx, nameEndIdx);

const valStartIdx = startIndex(str, eqIdx + 1, endIdx);
const valEndIdx = endIndex(str, endIdx, valStartIdx);
const value = decode(str.slice(valStartIdx, valEndIdx));

cookies.push({ name: name, value });

index = endIdx + 1;
} while (index < len);

return cookies;
}

function startIndex(str: string, index: number, max: number) {
do {
const code = str.charCodeAt(index);
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index;
} while (++index < max);
return max;
}

function endIndex(str: string, index: number, min: number) {
while (index > min) {
const code = str.charCodeAt(--index);
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index + 1;
}
return min;
}

function decode(str: string): string {
if (str.indexOf("%") === -1) return str;

try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
}
101 changes: 101 additions & 0 deletions test/unit/model/http/cookies.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { expect } from 'chai';

import {
parseCookieHeader,
parseSetCookieHeader
} from '../../../../src/model/http/cookies';

describe('Cookie parsing', () => {
describe('parseCookieHeader', () => {
it('should parse a simple cookie', () => {
const result = parseCookieHeader('name=value');
expect(result).to.deep.equal([{ name: 'name', value: 'value' }]);
});

it('should parse multiple cookies', () => {
const result = parseCookieHeader('name1=value1; name2=value2');
expect(result).to.deep.equal([
{ name: 'name1', value: 'value1' },
{ name: 'name2', value: 'value2' }
]);
});

it('should handle URL encoded values', () => {
const result = parseCookieHeader('name=hello%20world');
expect(result).to.deep.equal([{ name: 'name', value: 'hello world' }]);
});

it('should return empty array for invalid input', () => {
expect(parseCookieHeader('')).to.deep.equal([]);
expect(parseCookieHeader('invalid')).to.deep.equal([]);
});
});

describe('parseSetCookieHeader', () => {
it('should parse a simple Set-Cookie header', () => {
const result = parseSetCookieHeader('name=value');
expect(result).to.deep.equal([{ name: 'name', value: 'value' }]);
});

it('should parse Set-Cookie with attributes', () => {
const result = parseSetCookieHeader(
'name=value; Path=/; Domain=example.com; Expires=Wed, 21 Oct 2015 07:28:00 GMT'
);
expect(result).to.deep.equal([{
name: 'name',
value: 'value',
path: '/',
domain: 'example.com',
expires: 'Wed, 21 Oct 2015 07:28:00 GMT'
}]);
});

it('should parse boolean flags', () => {
const result = parseSetCookieHeader('name=value; httponly; Secure');
expect(result).to.deep.equal([{
name: 'name',
value: 'value',
httponly: true,
secure: true
}]);
});

it('should parse multiple Set-Cookie headers', () => {
const result = parseSetCookieHeader([
'name1=value1; Path=/',
'name2=value2;httponly;UnknownOther=hello'
]);
expect(result).to.deep.equal([
{ name: 'name1', value: 'value1', path: '/' },
{ name: 'name2', value: 'value2', httponly: true, unknownother: 'hello' }
]);
});

it('should handle case-insensitive attribute names', () => {
const result = parseSetCookieHeader([
'name=value; PATH=/test; httponly; DOMAIN=example.com; SecURE',
'other=value; samesite=Strict; MAX-AGE=3600'
]);

expect(result).to.deep.equal([{
name: 'name',
value: 'value',
path: '/test', // Standardized casing
httponly: true, // Standardized casing
domain: 'example.com',
secure: true
}, {
name: 'other',
value: 'value',
samesite: 'Strict',
'max-age': '3600'
}]);
});

it('should handle empty/invalid headers', () => {
expect(parseSetCookieHeader('')).to.deep.equal([]);
expect(parseSetCookieHeader([])).to.deep.equal([]);
expect(parseSetCookieHeader([';', 'invalid'])).to.deep.equal([]);
});
});
});

0 comments on commit e5b73eb

Please sign in to comment.