tunnelfetch 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// RFC 6265 cookie jar, DOM-free.
|
|
2
|
+
//
|
|
3
|
+
// Two discipline points shape this file:
|
|
4
|
+
//
|
|
5
|
+
// 1. Rejection is silent BY SPECIFICATION. RFC 6265 says a UA that dislikes a Set-Cookie
|
|
6
|
+
// (domain mismatch, Secure over http, bare-TLD domain) ignores it and moves on — erroring
|
|
7
|
+
// would let any response header abort the fetch. Silent is not invisible, though: every
|
|
8
|
+
// ignored cookie increments `jar.rejected`, because "why is my session cookie missing"
|
|
9
|
+
// is otherwise undebuggable.
|
|
10
|
+
//
|
|
11
|
+
// 2. The clock is injected. On the target runtime Date.now() is frozen during synchronous
|
|
12
|
+
// execution and only advances across I/O, so expiry logic that calls Date.now() directly
|
|
13
|
+
// is untestable there and subtly wrong in production. Everything time-shaped goes through
|
|
14
|
+
// `this._now()`.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse a cookie date per RFC 6265 s5.1.1 — NOT Date.parse. Date.parse accepts formats the
|
|
18
|
+
* RFC rejects, rejects formats the RFC accepts (two-digit years, odd delimiters), and differs
|
|
19
|
+
* between engines; cookies from 1990s-era servers still use every shape the RFC grandfathers.
|
|
20
|
+
*
|
|
21
|
+
* The algorithm: split into tokens on "delimiters", then find — in any order, first match per
|
|
22
|
+
* category wins — a time (hh:mm:ss), a day (1-2 digits), a month (3-letter name), and a year
|
|
23
|
+
* (2-4 digits). Each token may carry trailing non-digit junk after the match.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} s
|
|
26
|
+
* @returns {number|null} epoch milliseconds UTC, or null if the string is not a cookie date.
|
|
27
|
+
*/
|
|
28
|
+
export function parseCookieDate(s) {
|
|
29
|
+
// Delimiter set per the RFC: %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E.
|
|
30
|
+
const tokens = s.split(/[\x09\x20-\x2f\x3b-\x40\x5b-\x60\x7b-\x7e]+/).filter((t) => t !== '');
|
|
31
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
32
|
+
let time = null;
|
|
33
|
+
let day = null;
|
|
34
|
+
let month = null;
|
|
35
|
+
let year = null;
|
|
36
|
+
for (const tok of tokens) {
|
|
37
|
+
// Order of checks mirrors the RFC: time, day, month, year; a token is consumed by the
|
|
38
|
+
// first category it matches AND that is still unset.
|
|
39
|
+
if (time === null) {
|
|
40
|
+
const m = /^(\d{1,2}):(\d{1,2}):(\d{1,2})(?:\D.*)?$/.exec(tok);
|
|
41
|
+
if (m) {
|
|
42
|
+
time = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (day === null) {
|
|
47
|
+
const m = /^(\d{1,2})(?:\D.*)?$/.exec(tok);
|
|
48
|
+
if (m) {
|
|
49
|
+
day = Number(m[1]);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (month === null) {
|
|
54
|
+
const idx = MONTHS.indexOf(tok.slice(0, 3).toLowerCase());
|
|
55
|
+
if (idx >= 0) {
|
|
56
|
+
month = idx;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (year === null) {
|
|
61
|
+
const m = /^(\d{2,4})(?:\D.*)?$/.exec(tok);
|
|
62
|
+
if (m) {
|
|
63
|
+
year = Number(m[1]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (time === null || day === null || month === null || year === null) return null;
|
|
69
|
+
// Two-digit pivot: 70-99 are 19xx, 00-69 are 20xx. Three-digit years are not a thing the
|
|
70
|
+
// RFC recognises; a "year" of 100-1600 falls to the <1601 floor below.
|
|
71
|
+
if (year >= 70 && year <= 99) year += 1900;
|
|
72
|
+
else if (year >= 0 && year <= 69) year += 2000;
|
|
73
|
+
const [hh, mm, ss] = time;
|
|
74
|
+
if (day < 1 || day > 31 || year < 1601 || hh > 23 || mm > 59 || ss > 59) return null;
|
|
75
|
+
const ms = Date.UTC(year, month, day, hh, mm, ss);
|
|
76
|
+
// Date.UTC rolls over out-of-range days (Feb 31 -> Mar 3); the RFC treats those as simply
|
|
77
|
+
// "some date", and rollover is what every UA ships, so we accept the rolled value.
|
|
78
|
+
return ms;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* RFC 6265 s5.1.4: the default path is the request path up to (not including) its last '/'.
|
|
83
|
+
* @param {string} requestPath
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
export function defaultPath(requestPath) {
|
|
87
|
+
if (!requestPath || requestPath[0] !== '/') return '/';
|
|
88
|
+
const cut = requestPath.lastIndexOf('/');
|
|
89
|
+
return cut === 0 ? '/' : requestPath.slice(0, cut);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RFC 6265 s5.1.4 path-match.
|
|
94
|
+
* @param {string} requestPath
|
|
95
|
+
* @param {string} cookiePath
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function pathMatches(requestPath, cookiePath) {
|
|
99
|
+
if (requestPath === cookiePath) return true;
|
|
100
|
+
if (!requestPath.startsWith(cookiePath)) return false;
|
|
101
|
+
// Prefix match counts only when the boundary is a '/', so /foo does not match /foobar.
|
|
102
|
+
return cookiePath.endsWith('/') || requestPath[cookiePath.length] === '/';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* RFC 6265 s5.1.3 domain-match: exact, or host ends with '.' + domain.
|
|
107
|
+
* @param {string} host
|
|
108
|
+
* @param {string} cookieDomain
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
export function domainMatches(host, cookieDomain) {
|
|
112
|
+
if (host === cookieDomain) return true;
|
|
113
|
+
return host.endsWith('.' + cookieDomain);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Very loose IPv4/IPv6 literal check — a suffix "domain match" on an IP makes no sense. */
|
|
117
|
+
const looksLikeIp = (host) =>
|
|
118
|
+
/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':') || /^\[.*\]$/.test(host);
|
|
119
|
+
|
|
120
|
+
let seqCounter = 0; // creation-order tiebreak; the injected clock may legally stand still
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A stored cookie, as entries() exposes it. Records are live jar state, not copies.
|
|
124
|
+
* @typedef {object} Cookie
|
|
125
|
+
* @property {string} name
|
|
126
|
+
* @property {string} value quotes already stripped
|
|
127
|
+
* @property {string} domain lowercased; the Domain attribute, or the request host
|
|
128
|
+
* @property {boolean} hostOnly true when no Domain attribute applied — exact-host match only
|
|
129
|
+
* @property {string} path
|
|
130
|
+
* @property {boolean} secure
|
|
131
|
+
* @property {boolean} httpOnly
|
|
132
|
+
* @property {string | null} sameSite lowercased attribute value, stored verbatim — servers send
|
|
133
|
+
* values outside strict/lax/none and this jar does not enforce SameSite anyway
|
|
134
|
+
* @property {number} expiry epoch ms; Infinity for a session cookie
|
|
135
|
+
* @property {number} creation epoch ms from the injected clock, kept across overwrites (s5.3)
|
|
136
|
+
* @property {number} seq creation-order tiebreak for the frozen-clock runtime
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @typedef {object} CookieJarOptions
|
|
141
|
+
* @property {() => number} [now] injectable clock returning epoch ms. On the target runtime
|
|
142
|
+
* Date.now() freezes for a whole execution slice, so expiry must be testable via this knob.
|
|
143
|
+
* @property {number} [maxCookies] global cap, default 3000
|
|
144
|
+
* @property {number} [maxPerDomain] per-domain cap, default 50
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
export class CookieJar {
|
|
148
|
+
/**
|
|
149
|
+
* @param {CookieJarOptions} [options]
|
|
150
|
+
* The caps exist because this jar lives inside a long-lived Worker isolate: an unbounded
|
|
151
|
+
* jar fed by a hostile or merely enthusiastic server is a slow memory leak, so overflow
|
|
152
|
+
* evicts the oldest cookies instead of growing.
|
|
153
|
+
*/
|
|
154
|
+
constructor({ now = () => Date.now(), maxCookies = 3000, maxPerDomain = 50 } = {}) {
|
|
155
|
+
this._now = now;
|
|
156
|
+
this._maxCookies = maxCookies;
|
|
157
|
+
this._maxPerDomain = maxPerDomain;
|
|
158
|
+
/** @type {Map<string, Cookie>} key "domain|path|name" -> cookie record */
|
|
159
|
+
this._cookies = new Map();
|
|
160
|
+
this._rejected = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Count of Set-Cookie values ignored per RFC rules — the observability hook. */
|
|
164
|
+
get rejected() {
|
|
165
|
+
return this._rejected;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get size() {
|
|
169
|
+
return this._cookies.size;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Ingest the Set-Cookie values of one response. Never throws on a bad cookie — rejection is
|
|
174
|
+
* silent per RFC 6265, counted in `rejected`.
|
|
175
|
+
* @param {string|URL} url the request URL the response belongs to
|
|
176
|
+
* @param {string[]} setCookieValues one array entry per Set-Cookie header
|
|
177
|
+
* @returns {void}
|
|
178
|
+
*/
|
|
179
|
+
setFromResponse(url, setCookieValues) {
|
|
180
|
+
const u = url instanceof URL ? url : new URL(url);
|
|
181
|
+
const host = u.hostname.toLowerCase();
|
|
182
|
+
const secure = u.protocol === 'https:';
|
|
183
|
+
for (const value of setCookieValues) {
|
|
184
|
+
if (!this._setOne(host, secure, u.pathname, value)) this._rejected++;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} host
|
|
190
|
+
* @param {boolean} requestSecure
|
|
191
|
+
* @param {string} requestPath
|
|
192
|
+
* @param {string} setCookie
|
|
193
|
+
* @returns {boolean} stored or deliberately deleted (true) vs ignored (false)
|
|
194
|
+
*/
|
|
195
|
+
_setOne(host, requestSecure, requestPath, setCookie) {
|
|
196
|
+
if (typeof setCookie !== 'string') return false;
|
|
197
|
+
const semi = setCookie.indexOf(';');
|
|
198
|
+
const pair = semi === -1 ? setCookie : setCookie.slice(0, semi);
|
|
199
|
+
// RFC 6265 s5.2: no '=' in the name-value pair means ignore the whole thing.
|
|
200
|
+
const eq = pair.indexOf('=');
|
|
201
|
+
if (eq === -1) return false;
|
|
202
|
+
const name = pair.slice(0, eq).trim();
|
|
203
|
+
let value = pair.slice(eq + 1).trim();
|
|
204
|
+
if (name === '') return false;
|
|
205
|
+
// Control characters in name or value are a smuggling vector, not a cookie.
|
|
206
|
+
if (/[\x00-\x1f\x7f]/.test(name) || /[\x00-\x1f\x7f]/.test(value)) return false;
|
|
207
|
+
if (/[\s;,]/.test(name)) return false;
|
|
208
|
+
// A quoted value is stored with its quotes stripped, matching what UAs send back.
|
|
209
|
+
if (value.length >= 2 && value[0] === '"' && value.at(-1) === '"') value = value.slice(1, -1);
|
|
210
|
+
|
|
211
|
+
// ---- attributes: names case-insensitive, LAST occurrence of each wins, unknown ignored.
|
|
212
|
+
let maxAge = null; // seconds, may be <= 0
|
|
213
|
+
let expires = null; // epoch ms
|
|
214
|
+
let domainAttr = null;
|
|
215
|
+
let pathAttr = null;
|
|
216
|
+
let secure = false;
|
|
217
|
+
let httpOnly = false;
|
|
218
|
+
let sameSite = null;
|
|
219
|
+
if (semi !== -1) {
|
|
220
|
+
for (const rawAttr of setCookie.slice(semi + 1).split(';')) {
|
|
221
|
+
const aeq = rawAttr.indexOf('=');
|
|
222
|
+
const aname = (aeq === -1 ? rawAttr : rawAttr.slice(0, aeq)).trim().toLowerCase();
|
|
223
|
+
const avalue = aeq === -1 ? '' : rawAttr.slice(aeq + 1).trim();
|
|
224
|
+
switch (aname) {
|
|
225
|
+
case 'expires': {
|
|
226
|
+
const t = parseCookieDate(avalue);
|
|
227
|
+
// An unparseable date means "ignore this attribute", never "reject the cookie":
|
|
228
|
+
// the RFC's error recovery is per-attribute.
|
|
229
|
+
if (t !== null) expires = t;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'max-age': {
|
|
233
|
+
// s5.2.2: first char must be a digit or '-', remainder digits; anything else
|
|
234
|
+
// means ignore the attribute (Max-Age=forever is not an error, it is noise).
|
|
235
|
+
if (/^-?\d+$/.test(avalue)) maxAge = Number(avalue);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'domain': {
|
|
239
|
+
if (avalue !== '') domainAttr = avalue.replace(/^\./, '').toLowerCase();
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'path': {
|
|
243
|
+
pathAttr = avalue;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'secure':
|
|
247
|
+
secure = true;
|
|
248
|
+
break;
|
|
249
|
+
case 'httponly':
|
|
250
|
+
httpOnly = true;
|
|
251
|
+
break;
|
|
252
|
+
case 'samesite':
|
|
253
|
+
sameSite = avalue.toLowerCase();
|
|
254
|
+
break;
|
|
255
|
+
default:
|
|
256
|
+
// Unknown attributes are explicitly ignored (s5.2 last paragraph).
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Secure cookies may only be SET over https, not just sent over it — otherwise an http
|
|
263
|
+
// man-in-the-middle can plant a cookie the application later trusts as Secure.
|
|
264
|
+
if (secure && !requestSecure) return false;
|
|
265
|
+
|
|
266
|
+
// ---- cookie name prefixes (RFC 6265bis s5.4; storage model s5.7 steps 20-21)
|
|
267
|
+
//
|
|
268
|
+
// A "__Secure-"/"__Host-" name is the server's claim that the cookie was set with specific
|
|
269
|
+
// attributes, and the receiving server trusts the NAME as proof of them. A Set-Cookie that
|
|
270
|
+
// breaks its own name's claim is therefore refused whole — storing it "repaired" would
|
|
271
|
+
// manufacture exactly the proof the server must not get.
|
|
272
|
+
//
|
|
273
|
+
// Matching is case-INSENSITIVE (s5.4 "UAs MUST match cookie name prefixes
|
|
274
|
+
// case-insensitively"): servers routinely compare names case-insensitively, so a
|
|
275
|
+
// "__SeCuRe-" lookalike must face the same rules as the honest spelling or it can
|
|
276
|
+
// impersonate the protected cookie. The regexes carry no 'u' flag on purpose: without it,
|
|
277
|
+
// /i folding can never map a non-ASCII character onto an ASCII one, making the match
|
|
278
|
+
// exactly ASCII-case-insensitive.
|
|
279
|
+
//
|
|
280
|
+
// The mimicry rule for nameless cookies (s5.7 step 22) needs no code: pairs with no '='
|
|
281
|
+
// or an empty name were refused above, so a value can never pose as a prefixed name.
|
|
282
|
+
if (/^__secure-/i.test(name)) {
|
|
283
|
+
// Step 20 requires only the Secure attribute here; being set over a secure channel is
|
|
284
|
+
// the check directly above. Together: https and Secure, or nothing.
|
|
285
|
+
if (!secure) return false;
|
|
286
|
+
} else if (/^__host-/i.test(name)) {
|
|
287
|
+
// Step 21: Secure, host-only, Path=/. Two clauses are deliberately stricter than the
|
|
288
|
+
// storage-model letter, matching the server-facing contract (s4.1.3.2: "a Secure
|
|
289
|
+
// attribute, a Path attribute with a value of /, and no Domain attribute"):
|
|
290
|
+
// - the Domain attribute must be ABSENT, not merely resolve to host-only (the no-dot
|
|
291
|
+
// and IP-literal branches below keep Domain=<host> host-only, but the name promised
|
|
292
|
+
// no Domain at all);
|
|
293
|
+
// - the Path attribute's value must literally be "/", not an invalid value that the
|
|
294
|
+
// default-path fallback would quietly repair to "/".
|
|
295
|
+
// Both refuse strictly more than s5.7 asks, never less — fail closed.
|
|
296
|
+
if (!secure || domainAttr !== null || pathAttr !== '/') return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- domain validation
|
|
300
|
+
let domain = host;
|
|
301
|
+
let hostOnly = true;
|
|
302
|
+
if (domainAttr !== null) {
|
|
303
|
+
// Public-suffix guard, minimal version: a domain with no dot is a bare TLD ("com",
|
|
304
|
+
// "org", "internal"), and accepting it would let one site set cookies for every site
|
|
305
|
+
// under that TLD. A real public-suffix list (which would also catch "co.uk") is out of
|
|
306
|
+
// scope here and deliberately NOT approximated further — a half-faked PSL is worse than
|
|
307
|
+
// a documented gap, because it changes behaviour silently as the fake grows. The one
|
|
308
|
+
// legitimate no-dot case, Domain=localhost on localhost, is exact-equal to the host and
|
|
309
|
+
// handled by treating it as host-only, which is what the PSL algorithm prescribes.
|
|
310
|
+
if (!domainAttr.includes('.')) {
|
|
311
|
+
if (domainAttr !== host) return false;
|
|
312
|
+
// fall through with hostOnly = true, domain = host
|
|
313
|
+
} else if (looksLikeIp(host)) {
|
|
314
|
+
// A Domain attribute on an IP-literal request only makes sense as an exact match.
|
|
315
|
+
if (domainAttr !== host) return false;
|
|
316
|
+
} else {
|
|
317
|
+
if (!domainMatches(host, domainAttr)) return false;
|
|
318
|
+
domain = domainAttr;
|
|
319
|
+
hostOnly = false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const path = pathAttr && pathAttr[0] === '/' ? pathAttr : defaultPath(requestPath);
|
|
324
|
+
|
|
325
|
+
// ---- expiry: Max-Age wins over Expires when both are present (s5.3 step 3).
|
|
326
|
+
const now = this._now();
|
|
327
|
+
let expiry = Infinity; // session cookie: lives as long as the jar
|
|
328
|
+
if (maxAge !== null) {
|
|
329
|
+
// Max-Age <= 0 means "expire immediately", i.e. delete any existing cookie.
|
|
330
|
+
expiry = maxAge <= 0 ? -Infinity : now + maxAge * 1000;
|
|
331
|
+
} else if (expires !== null) {
|
|
332
|
+
expiry = expires;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const key = `${domain}|${path}|${name}`;
|
|
336
|
+
const existing = this._cookies.get(key);
|
|
337
|
+
if (expiry <= now) {
|
|
338
|
+
// Setting an already-expired cookie is the standard deletion idiom.
|
|
339
|
+
this._cookies.delete(key);
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
const record = {
|
|
343
|
+
name,
|
|
344
|
+
value,
|
|
345
|
+
domain,
|
|
346
|
+
hostOnly,
|
|
347
|
+
path,
|
|
348
|
+
secure,
|
|
349
|
+
httpOnly,
|
|
350
|
+
sameSite,
|
|
351
|
+
expiry,
|
|
352
|
+
// Overwriting keeps the ORIGINAL creation time (s5.3 step 11.3) so the s5.4 sort order
|
|
353
|
+
// is stable across refreshes of the same cookie.
|
|
354
|
+
creation: existing ? existing.creation : now,
|
|
355
|
+
seq: existing ? existing.seq : seqCounter++,
|
|
356
|
+
};
|
|
357
|
+
// Map insertion order would put an overwrite at the end; delete-then-set keeps eviction
|
|
358
|
+
// scanning simple while `seq` preserves true creation order.
|
|
359
|
+
this._cookies.delete(key);
|
|
360
|
+
this._cookies.set(key, record);
|
|
361
|
+
this._enforceCaps(domain);
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Evict expired cookies first, then the oldest by creation, per-domain then globally.
|
|
367
|
+
* @param {string} domain
|
|
368
|
+
*/
|
|
369
|
+
_enforceCaps(domain) {
|
|
370
|
+
const now = this._now();
|
|
371
|
+
for (const [k, c] of this._cookies) if (c.expiry <= now) this._cookies.delete(k);
|
|
372
|
+
|
|
373
|
+
const inDomain = [...this._cookies.values()].filter((c) => c.domain === domain);
|
|
374
|
+
if (inDomain.length > this._maxPerDomain) {
|
|
375
|
+
inDomain.sort((a, b) => a.creation - b.creation || a.seq - b.seq);
|
|
376
|
+
for (const c of inDomain.slice(0, inDomain.length - this._maxPerDomain)) {
|
|
377
|
+
this._cookies.delete(`${c.domain}|${c.path}|${c.name}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (this._cookies.size > this._maxCookies) {
|
|
381
|
+
const all = [...this._cookies.values()].sort(
|
|
382
|
+
(a, b) => a.creation - b.creation || a.seq - b.seq,
|
|
383
|
+
);
|
|
384
|
+
for (const c of all.slice(0, all.length - this._maxCookies)) {
|
|
385
|
+
this._cookies.delete(`${c.domain}|${c.path}|${c.name}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The Cookie header value for a request, or null if no cookie matches.
|
|
392
|
+
* @param {string|URL} url
|
|
393
|
+
* @returns {string | null}
|
|
394
|
+
*/
|
|
395
|
+
headerFor(url) {
|
|
396
|
+
const u = url instanceof URL ? url : new URL(url);
|
|
397
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
398
|
+
const host = u.hostname.toLowerCase();
|
|
399
|
+
const requestPath = u.pathname || '/';
|
|
400
|
+
const secureChannel = u.protocol === 'https:';
|
|
401
|
+
const now = this._now();
|
|
402
|
+
const matched = [];
|
|
403
|
+
for (const [key, c] of this._cookies) {
|
|
404
|
+
if (c.expiry <= now) {
|
|
405
|
+
this._cookies.delete(key); // lazy expiry sweep; the injected clock decides "now"
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (c.hostOnly ? host !== c.domain : !domainMatches(host, c.domain)) continue;
|
|
409
|
+
if (!pathMatches(requestPath, c.path)) continue;
|
|
410
|
+
if (c.secure && !secureChannel) continue;
|
|
411
|
+
matched.push(c);
|
|
412
|
+
}
|
|
413
|
+
if (matched.length === 0) return null;
|
|
414
|
+
// s5.4: longer paths first, then earlier creation first. The seq tiebreak matters because
|
|
415
|
+
// the frozen-clock runtime hands out identical creation timestamps within one task.
|
|
416
|
+
matched.sort(
|
|
417
|
+
(a, b) => b.path.length - a.path.length || a.creation - b.creation || a.seq - b.seq,
|
|
418
|
+
);
|
|
419
|
+
return matched.map((c) => `${c.name}=${c.value}`).join('; ');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Everything currently stored, for tests and debugging. Records are live; do not mutate.
|
|
424
|
+
* @returns {Cookie[]}
|
|
425
|
+
*/
|
|
426
|
+
entries() {
|
|
427
|
+
return [...this._cookies.values()];
|
|
428
|
+
}
|
|
429
|
+
}
|