import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, readFileSync, writeFileSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; process.env.OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cookie-test-')); const { jarFromCookieHeader, parseExpires, cookieHeaderFor, parseSetCookie, storeFromResponse, saveCookieJar, loadCookieJar, clearCookieJar, purgeExpiredJars, isSessionExpired, cookieJarPath, normalizeDomain, withheldForScheme, MAX_COOKIES, MAX_COOKIE_BYTES, JAR_EXPIRED, _resetPurgeGuard, } = await import('../src/cookies.js'); test('parseExpires accepts common durations', () => { assert.equal(parseExpires('1h'), 3_600_000); assert.equal(parseExpires('30m'), 1_800_000); assert.equal(parseExpires('2d'), 172_800_000); }); test('jarFromCookieHeader parses a Cookie header for a domain', () => { const jar = jarFromCookieHeader('session=abc; auth=xyz', 'Example.COM'); assert.equal(jar.cookies.length, 2); assert.equal(jar.cookies[0].name, 'session'); assert.equal(jar.cookies[0].value, 'abc'); assert.equal(jar.cookies[0].domain, 'example.com'); assert.ok(Date.parse(jar.expiresAt) > Date.now()); }); test('a seeded cookie is https-only by default and travels over http only on request', () => { const secure = jarFromCookieHeader('session=abc', 'example.com'); assert.equal(secure.cookies[0].secure, true); // The credential came out of an https browser session, so plain http never // sees it unless the user says the site is http-only. assert.equal(cookieHeaderFor(secure, 'http://example.com/'), undefined); assert.equal(cookieHeaderFor(secure, 'https://example.com/'), 'session=abc'); assert.ok(withheldForScheme(secure, 'http://example.com/')); assert.ok(!withheldForScheme(secure, 'https://example.com/')); const opted = jarFromCookieHeader('session=abc', 'example.com', { allowHttp: true }); assert.equal(opted.cookies[0].secure, undefined); assert.equal(cookieHeaderFor(opted, 'http://example.com/'), 'session=abc'); assert.ok(!withheldForScheme(opted, 'http://example.com/')); }); test('a cookie learned over https is pinned secure even without the attribute', () => { const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [] }; const next = storeFromResponse(jar, 'https://example.com/', ['sid=x; Path=/']); assert.equal(next.cookies[0].secure, true); // Which is what keeps it off the wire when a later hop drops to http. assert.equal(cookieHeaderFor(next, 'http://example.com/'), undefined); // A cookie a site set over http was never secret to begin with; it is left alone. const plain = storeFromResponse(jar, 'http://example.com/', ['sid=x; Path=/']); assert.equal(plain.cookies[0].secure, undefined); }); test('normalizeDomain refuses a bare TLD but keeps IPs and localhost', () => { assert.equal(normalizeDomain('.Example.COM.'), 'example.com'); assert.equal(normalizeDomain('1.1.1.1'), '1.1.1.1'); assert.equal(normalizeDomain('localhost'), 'localhost'); // A suffix match on a bare TLD would hand the cookie to every host under it. for (const bad of ['com', 'co', 'localdomain']) { assert.throws(() => normalizeDomain(bad), /bare name/, `expected '${bad}' to be refused`); } for (const bad of ['', '.', '/', 'example.com:8443', 'http://example.com', 'example.com/x', 'ex ample.com', '-x.com']) { assert.throws(() => normalizeDomain(bad), /must be a hostname/, `expected '${bad}' to be refused`); } }); test('a jar seeded with a bare TLD never reaches every host under it', () => { assert.throws(() => jarFromCookieHeader('sid=secret', 'com'), /bare name/); }); test('jarFromCookieHeader rejects control characters in a name or value', () => { assert.throws(() => jarFromCookieHeader('sid=a\r\nX-Injected: 1', 'example.com'), /invalid value for cookie 'sid'/); assert.throws(() => jarFromCookieHeader('sid=a\u0000b', 'example.com'), /invalid value for cookie/); assert.throws(() => jarFromCookieHeader('sid=caf\u00e9', 'example.com'), /invalid value for cookie/); assert.throws(() => jarFromCookieHeader('bad name=x', 'example.com'), /invalid cookie name/); assert.throws(() => jarFromCookieHeader('sid=x'.padEnd(MAX_COOKIE_BYTES + 8, 'y'), 'example.com'), /over the .* limit/); // A CR in the error message would let the rejected value rewrite the line. assert.throws(() => jarFromCookieHeader('sid=a\rb', 'example.com'), (err) => !/[\r\n]/.test(err.message)); // Values a browser really hands over - base64 padding, commas, quotes - still pass. const ok = jarFromCookieHeader('sid="a,b+c/d=="; _ga=GA1.2.3', 'example.com'); assert.equal(ok.cookies.length, 2); }); test('a hostile response cannot grow the jar past its cap', () => { const jar = jarFromCookieHeader('sid=secret', 'example.com'); const headers = Array.from({ length: MAX_COOKIES * 3 }, (_, i) => `junk${i}=x; Path=/`); const next = storeFromResponse(jar, 'https://example.com/', headers); assert.equal(next.cookies.length, MAX_COOKIES); // The seeded login is what survives; the overflow is what is refused. assert.ok(next.cookies.some((c) => c.name === 'sid' && c.value === 'secret')); // A full jar still takes an update to a cookie it already holds. const rotated = storeFromResponse(next, 'https://example.com/', ['junk0=rotated; Path=/']); assert.equal(rotated.cookies.length, MAX_COOKIES); assert.equal(rotated.cookies.find((c) => c.name === 'junk0').value, 'rotated'); }); test('a response cannot smuggle a control character into the next request', () => { const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [] }; const next = storeFromResponse(jar, 'https://example.com/', ['sid=a\r\nX-Injected: 1; Path=/']); assert.equal(next.cookies.length, 0); assert.equal(parseSetCookie('sid=a\r\nb', 'https://example.com/'), null); }); test('cookieHeaderFor matches domain and path', () => { const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [ { name: 'a', value: '1', domain: 'example.com', path: '/' }, { name: 'b', value: '2', domain: 'other.com', path: '/' }, { name: 'c', value: '3', domain: 'example.com', path: '/app', secure: true }, ], }; assert.equal(cookieHeaderFor(jar, 'https://example.com/app/home'), 'a=1; c=3'); assert.equal(cookieHeaderFor(jar, 'http://example.com/app/home'), 'a=1'); assert.equal(cookieHeaderFor(jar, 'https://other.com/'), 'b=2'); assert.equal(cookieHeaderFor(jar, 'https://example.com/other'), 'a=1'); }); test('parseSetCookie reads attributes and pins the cookie host-only', () => { const c = parseSetCookie('sid=val; Path=/app; Domain=.example.com; Secure; HttpOnly; Max-Age=3600', 'https://www.example.com/login'); assert.equal(c.name, 'sid'); assert.equal(c.value, 'val'); // Domain is ignored: the cookie is scoped to the host that set it, not the // wider domain the response asked for. assert.equal(c.domain, 'www.example.com'); assert.equal(c.path, '/app'); assert.ok(c.secure); assert.ok(c.httpOnly); assert.ok(c.expires); }); test('a response cannot widen a cookie to a public suffix and reach other sites', () => { const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [] }; // A page fetched under the jar tries to plant a '.com'-scoped cookie. const next = storeFromResponse(jar, 'https://evil.example/', ['sid=x; Domain=.com; Path=/']); assert.equal(next.cookies[0].domain, 'evil.example'); // It is never sent to an unrelated site that merely shares the suffix. assert.equal(cookieHeaderFor(next, 'https://bank.com/'), undefined); assert.equal(cookieHeaderFor(next, 'https://evil.example/'), 'sid=x'); }); test('storeFromResponse replaces cookies with the same name and domain', () => { const jar = { expiresAt: new Date(Date.now() + 3_600_000).toISOString(), cookies: [{ name: 'sid', value: 'old', domain: 'example.com', path: '/' }], }; const next = storeFromResponse(jar, 'https://example.com/', ['sid=new; Path=/; Domain=example.com']); assert.equal(next.cookies.length, 1); assert.equal(next.cookies[0].value, 'new'); }); test('session ceiling caps per-cookie expiry from Set-Cookie', () => { const ceiling = new Date(Date.now() + 3_600_000).toISOString(); const jar = { expiresAt: ceiling, cookies: [] }; const next = storeFromResponse(jar, 'https://example.com/', [ 'sid=x; Max-Age=86400; Domain=example.com; Path=/', ]); assert.equal(next.cookies[0].expires, ceiling); }); test('saveCookieJar writes with mode 0600 and loadCookieJar reads back', () => { clearCookieJar('work'); const jar = jarFromCookieHeader('token=secret', 'example.com', { expiresMs: 3_600_000 }); saveCookieJar('work', jar); const mode = statSync(cookieJarPath('work')).mode & 0o777; assert.equal(mode, 0o600); const loaded = loadCookieJar('work'); assert.equal(loaded.cookies[0].value, 'secret'); }); test('loadCookieJar returns JAR_EXPIRED and clears an expired jar', () => { clearCookieJar('expired'); saveCookieJar('expired', { expiresAt: new Date(Date.now() - 1000).toISOString(), cookies: [{ name: 'a', value: 'b', domain: 'example.com', path: '/' }], }); _resetPurgeGuard(); assert.equal(loadCookieJar('expired'), JAR_EXPIRED); assert.throws(() => readFileSync(cookieJarPath('expired')), /ENOENT/); }); test('purgeExpiredJars removes stale sidecar files', () => { clearCookieJar('old'); clearCookieJar('fresh'); writeFileSync(cookieJarPath('old'), JSON.stringify({ expiresAt: new Date(Date.now() - 1000).toISOString(), cookies: [{ name: 'a', value: 'b', domain: 'example.com', path: '/' }], })); saveCookieJar('fresh', jarFromCookieHeader('x=1', 'example.com')); _resetPurgeGuard(); purgeExpiredJars(); assert.throws(() => readFileSync(cookieJarPath('old')), /ENOENT/); assert.ok(loadCookieJar('fresh')); }); test('isSessionExpired respects the session ceiling', () => { const jar = { expiresAt: new Date(Date.now() + 1000).toISOString(), cookies: [] }; assert.ok(!isSessionExpired(jar)); jar.expiresAt = new Date(Date.now() - 1000).toISOString(); assert.ok(isSessionExpired(jar)); });