Skip to content

Commit e5b73eb

Browse files
committed
Migrate to our own raw-content cookie parser
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.
1 parent 3dd492b commit e5b73eb

File tree

5 files changed

+228
-46
lines changed

5 files changed

+228
-46
lines changed

package-lock.json

Lines changed: 0 additions & 28 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@
7373
"@types/remarkable": "^1.7.3",
7474
"@types/semver": "^7.3.1",
7575
"@types/serialize-error": "^2.1.0",
76-
"@types/set-cookie-parser": "0.0.3",
7776
"@types/styled-components": "^5.1.34",
7877
"@types/traverse": "^0.6.32",
7978
"@types/ua-parser-js": "^0.7.33",
@@ -133,7 +132,6 @@
133132
"semver": "^7.5.2",
134133
"serialize-error": "^3.0.0",
135134
"serializr": "^1.5.4",
136-
"set-cookie-parser": "^2.3.5",
137135
"styled-components": "^5.0.0",
138136
"styled-reset": "^1.1.2",
139137
"swagger2openapi": "^5.3.2",

src/components/view/http/set-cookie-header-description.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import * as React from 'react';
22

3-
import { parse as parseCookie, Cookie } from 'set-cookie-parser';
43
import {
54
isFuture,
65
addSeconds,
76
format as formatDate,
87
distanceInWordsToNow
98
} from 'date-fns';
109

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

1313
function getExpiryExplanation(date: Date) {
@@ -26,17 +26,15 @@ function getExpiryExplanation(date: Date) {
2626
}
2727

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

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

3434
return <>{
3535
// In 99% of cases there is only one cookie here, but we can play it safe.
36-
cookies.map((
37-
cookie: Cookie & { sameSite?: 'Strict' | 'Lax' | 'None' }
38-
) => {
39-
if (cookie.sameSite?.toLowerCase() === 'none' && !cookie.secure) {
36+
cookies.map((cookie: Cookie) => {
37+
if (cookie.samesite?.toLowerCase() === 'none' && !cookie.secure) {
4038
return <Content key={cookie.name}>
4139
<p>
4240
This attempts to set cookie '<code>{cookie.name}</code>' to
@@ -75,29 +73,29 @@ export const CookieHeaderDescription = (p: { value: string, requestUrl: URL }) =
7573
</p>
7674
<p>
7775
The cookie is {
78-
cookie.httpOnly ?
76+
cookie.httponly ?
7977
'not accessible from client-side scripts' :
8078
'accessible from client-side scripts running on matching pages'
8179
}
82-
{ (cookie.sameSite === undefined || cookie.sameSite.toLowerCase() === 'lax')
80+
{ (cookie.samesite === undefined || cookie.samesite.toLowerCase() === 'lax')
8381
// Lax is default for modern browsers (e.g. Chrome 80+)
8482
? <>
8583
. Matching requests triggered from other origins will {
86-
cookie.httpOnly ? 'however' : 'also'
84+
cookie.httponly ? 'however' : 'also'
8785
} include this cookie, if they are top-level navigations (not subresources).
8886
</>
89-
: cookie.sameSite.toLowerCase() === 'strict' && cookie.httpOnly
87+
: cookie.samesite.toLowerCase() === 'strict' && cookie.httponly
9088
? <>
9189
, or sent in requests triggered from other origins.
9290
</>
93-
: cookie.sameSite.toLowerCase() === 'strict' && !cookie.httpOnly
91+
: cookie.samesite.toLowerCase() === 'strict' && !cookie.httponly
9492
? <>
9593
, but will not be sent in requests triggered from other origins.
9694
</>
97-
: cookie.sameSite.toLowerCase() === 'none' && cookie.secure
95+
: cookie.samesite.toLowerCase() === 'none' && cookie.secure
9896
? <>
9997
. Matching requests triggered from other origins will {
100-
cookie.httpOnly ? 'however' : 'also'
98+
cookie.httponly ? 'however' : 'also'
10199
} include this cookie.
102100
</>
103101
: <>
@@ -108,12 +106,12 @@ export const CookieHeaderDescription = (p: { value: string, requestUrl: URL }) =
108106

109107
<p>
110108
The cookie {
111-
cookie.maxAge ? <>
112-
{ getExpiryExplanation(addSeconds(new Date(), cookie.maxAge)) }
109+
cookie['max-age'] ? <>
110+
{ getExpiryExplanation(addSeconds(new Date(), parseInt(cookie['max-age'], 10))) }
113111
{ cookie.expires && ` ('max-age' overrides 'expires')` }
114112
</> :
115113
cookie.expires ?
116-
getExpiryExplanation(cookie.expires)
114+
getExpiryExplanation(new Date(cookie.expires))
117115
: 'expires at the end of the current session'
118116
}.
119117
</p>

src/model/http/cookies.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Partially taken from https://github.com/jshttp/cookie under MIT license,
2+
// heavily rewritten with setCookie support added.
3+
4+
export interface Cookie {
5+
name: string;
6+
value: string;
7+
path?: string;
8+
httponly?: boolean;
9+
secure?: boolean;
10+
samesite?: string;
11+
domain?: string;
12+
expires?: string;
13+
'max-age'?: string;
14+
[key: string]: string | boolean | undefined;
15+
}
16+
17+
export function parseSetCookieHeader(
18+
headers: string | string[]
19+
) {
20+
if (!Array.isArray(headers)) {
21+
headers = [headers];
22+
}
23+
24+
const cookies: Array<Cookie> = [];
25+
26+
for (const header of headers) {
27+
const [cookieKV, ...parts] = header.split(";");
28+
29+
const [name, value] = (cookieKV?.split("=") ?? []);
30+
if (!name || value === undefined) continue;
31+
32+
const cookie: Cookie = {
33+
name,
34+
value
35+
};
36+
37+
for (const part of parts) {
38+
let [key, val] = part.split("=");
39+
key = key.trim().toLowerCase();
40+
val = val?.trim() ?? true;
41+
cookie[key] = val;
42+
}
43+
44+
cookies.push(cookie);
45+
}
46+
47+
return cookies;
48+
}
49+
50+
export function parseCookieHeader(
51+
str: string
52+
): Array<Cookie> {
53+
const cookies: Array<Cookie> = [];
54+
const len = str.length;
55+
// RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='.
56+
if (len < 2) return cookies;
57+
58+
let index = 0;
59+
60+
do {
61+
const eqIdx = str.indexOf("=", index);
62+
if (eqIdx === -1) break; // No more cookie pairs.
63+
64+
const colonIdx = str.indexOf(";", index);
65+
const endIdx = colonIdx === -1 ? len : colonIdx;
66+
67+
if (eqIdx > endIdx) {
68+
// backtrack on prior semicolon
69+
index = str.lastIndexOf(";", eqIdx - 1) + 1;
70+
continue;
71+
}
72+
73+
const nameStartIdx = startIndex(str, index, eqIdx);
74+
const nameEndIdx = endIndex(str, eqIdx, nameStartIdx);
75+
const name = str.slice(nameStartIdx, nameEndIdx);
76+
77+
const valStartIdx = startIndex(str, eqIdx + 1, endIdx);
78+
const valEndIdx = endIndex(str, endIdx, valStartIdx);
79+
const value = decode(str.slice(valStartIdx, valEndIdx));
80+
81+
cookies.push({ name: name, value });
82+
83+
index = endIdx + 1;
84+
} while (index < len);
85+
86+
return cookies;
87+
}
88+
89+
function startIndex(str: string, index: number, max: number) {
90+
do {
91+
const code = str.charCodeAt(index);
92+
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index;
93+
} while (++index < max);
94+
return max;
95+
}
96+
97+
function endIndex(str: string, index: number, min: number) {
98+
while (index > min) {
99+
const code = str.charCodeAt(--index);
100+
if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index + 1;
101+
}
102+
return min;
103+
}
104+
105+
function decode(str: string): string {
106+
if (str.indexOf("%") === -1) return str;
107+
108+
try {
109+
return decodeURIComponent(str);
110+
} catch (e) {
111+
return str;
112+
}
113+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { expect } from 'chai';
2+
3+
import {
4+
parseCookieHeader,
5+
parseSetCookieHeader
6+
} from '../../../../src/model/http/cookies';
7+
8+
describe('Cookie parsing', () => {
9+
describe('parseCookieHeader', () => {
10+
it('should parse a simple cookie', () => {
11+
const result = parseCookieHeader('name=value');
12+
expect(result).to.deep.equal([{ name: 'name', value: 'value' }]);
13+
});
14+
15+
it('should parse multiple cookies', () => {
16+
const result = parseCookieHeader('name1=value1; name2=value2');
17+
expect(result).to.deep.equal([
18+
{ name: 'name1', value: 'value1' },
19+
{ name: 'name2', value: 'value2' }
20+
]);
21+
});
22+
23+
it('should handle URL encoded values', () => {
24+
const result = parseCookieHeader('name=hello%20world');
25+
expect(result).to.deep.equal([{ name: 'name', value: 'hello world' }]);
26+
});
27+
28+
it('should return empty array for invalid input', () => {
29+
expect(parseCookieHeader('')).to.deep.equal([]);
30+
expect(parseCookieHeader('invalid')).to.deep.equal([]);
31+
});
32+
});
33+
34+
describe('parseSetCookieHeader', () => {
35+
it('should parse a simple Set-Cookie header', () => {
36+
const result = parseSetCookieHeader('name=value');
37+
expect(result).to.deep.equal([{ name: 'name', value: 'value' }]);
38+
});
39+
40+
it('should parse Set-Cookie with attributes', () => {
41+
const result = parseSetCookieHeader(
42+
'name=value; Path=/; Domain=example.com; Expires=Wed, 21 Oct 2015 07:28:00 GMT'
43+
);
44+
expect(result).to.deep.equal([{
45+
name: 'name',
46+
value: 'value',
47+
path: '/',
48+
domain: 'example.com',
49+
expires: 'Wed, 21 Oct 2015 07:28:00 GMT'
50+
}]);
51+
});
52+
53+
it('should parse boolean flags', () => {
54+
const result = parseSetCookieHeader('name=value; httponly; Secure');
55+
expect(result).to.deep.equal([{
56+
name: 'name',
57+
value: 'value',
58+
httponly: true,
59+
secure: true
60+
}]);
61+
});
62+
63+
it('should parse multiple Set-Cookie headers', () => {
64+
const result = parseSetCookieHeader([
65+
'name1=value1; Path=/',
66+
'name2=value2;httponly;UnknownOther=hello'
67+
]);
68+
expect(result).to.deep.equal([
69+
{ name: 'name1', value: 'value1', path: '/' },
70+
{ name: 'name2', value: 'value2', httponly: true, unknownother: 'hello' }
71+
]);
72+
});
73+
74+
it('should handle case-insensitive attribute names', () => {
75+
const result = parseSetCookieHeader([
76+
'name=value; PATH=/test; httponly; DOMAIN=example.com; SecURE',
77+
'other=value; samesite=Strict; MAX-AGE=3600'
78+
]);
79+
80+
expect(result).to.deep.equal([{
81+
name: 'name',
82+
value: 'value',
83+
path: '/test', // Standardized casing
84+
httponly: true, // Standardized casing
85+
domain: 'example.com',
86+
secure: true
87+
}, {
88+
name: 'other',
89+
value: 'value',
90+
samesite: 'Strict',
91+
'max-age': '3600'
92+
}]);
93+
});
94+
95+
it('should handle empty/invalid headers', () => {
96+
expect(parseSetCookieHeader('')).to.deep.equal([]);
97+
expect(parseSetCookieHeader([])).to.deep.equal([]);
98+
expect(parseSetCookieHeader([';', 'invalid'])).to.deep.equal([]);
99+
});
100+
});
101+
});

0 commit comments

Comments
 (0)