merge main: authenticated sessions via per-session cookie jars

This commit is contained in:
only-cli
2026-08-24 21:25:44 -04:00
15 changed files with 1604 additions and 30 deletions
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { readFileSync } from 'node:fs';
const { distill, toMarkdown } = await import('../src/distill.js');
const { authFailure } = await import('../src/auth.js');
const { fetchPage } = await import('../src/fetch.js');
const loginHtml = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8');
const navWithLoginLink = `<html><head><title>News</title></head><body>
<nav><a href="/login">Log in</a></nav>
<article>${'<p>Real story content here.</p>'.repeat(20)}</article>
</body></html>`;
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'];
function listen(server) {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
function withoutProxyEnv(run) {
const prev = Object.fromEntries(PROXY_ENV_KEYS.map((k) => [k, process.env[k]]));
for (const k of PROXY_ENV_KEYS) delete process.env[k];
return run().finally(() => {
for (const k of PROXY_ENV_KEYS) {
if (prev[k] === undefined) delete process.env[k];
else process.env[k] = prev[k];
}
});
}
test('authFailure detects a login page with password input and supporting signals', () => {
const page = distill(loginHtml, 'https://example.com/login');
assert.match(authFailure(page, 'https://example.com/login'), /requires login/);
});
test('authFailure reports expired session when auth was sent', () => {
const page = distill(loginHtml, 'https://example.com/login');
assert.match(
authFailure(page, 'https://example.com/login', { hadAuth: true }),
/session expired or cookies are no longer valid/,
);
});
test('authFailure ignores nav login links without a password field', () => {
const page = distill(navWithLoginLink, 'https://example.com/news');
assert.equal(authFailure(page, 'https://example.com/news'), null);
});
test('authFailure ignores a password field without login context', () => {
const html = `<html><head><title>Account settings</title></head><body>
<p>Change your password below.</p>
<input type="password" name="new">
<button>Save</button>
</body></html>`;
const page = distill(html, 'https://example.com/settings');
assert.equal(authFailure(page, 'https://example.com/settings'), null);
});
test('fetch through a proxy detects a login page end to end (offline)', async () => {
await withoutProxyEnv(async () => {
const proxy = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(loginHtml);
});
const port = await listen(proxy);
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
try {
const { html, url } = await fetchPage('http://1.1.1.1/login');
const page = distill(html, url);
assert.match(authFailure(page, url), /requires login/);
assert.match(authFailure(page, url, { hadAuth: true }), /session expired or cookies are no longer valid/);
} finally {
proxy.close();
}
});
});
test('auth failure gates raw output before markdown is emitted', async () => {
await withoutProxyEnv(async () => {
const proxy = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(loginHtml);
});
const port = await listen(proxy);
process.env.HTTP_PROXY = `http://127.0.0.1:${port}`;
try {
const { html, url } = await fetchPage('http://1.1.1.1/login');
const page = distill(html, url);
assert.ok(authFailure(page, url));
assert.match(toMarkdown(html, url), /Sign in/);
} finally {
proxy.close();
}
});
});
+277
View File
@@ -0,0 +1,277 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
const OC_HOME = mkdtempSync(join(tmpdir(), 'oc-cli-auth-'));
process.env.OC_HOME = OC_HOME;
const bin = new URL('../src/cli.js', import.meta.url).pathname;
const loginHtml = readFileSync(new URL('./pages/login.html', import.meta.url), 'utf8');
const dashHtml = `<html><head><title>Dashboard</title></head><body>
<h1>Welcome back</h1>
${'<p>Secret project notes for the signed-in user.</p>'.repeat(20)}
</body></html>`;
const PROXY_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy'];
function childEnv(envExtra = {}) {
const env = { ...process.env, OC_HOME, ...envExtra };
for (const k of PROXY_ENV_KEYS) {
if (!(k in envExtra)) delete env[k];
}
return env;
}
// Sync run for cases that never touch the network (login, logout, expired jar).
function oc(args, envExtra = {}) {
return spawnSync(process.execPath, [bin, ...args], { encoding: 'utf8', env: childEnv(envExtra) });
}
// Async run for cases that fetch through an in-process mock proxy: spawnSync
// would block the event loop the proxy server runs on and deadlock the test.
function ocAsync(args, envExtra = {}) {
return new Promise((resolve) => {
const child = spawn(process.execPath, [bin, ...args], { env: childEnv(envExtra) });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
child.stderr.on('data', (d) => { stderr += d; });
child.on('close', (status) => resolve({ status, stdout, stderr }));
});
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
});
}
test('login saves a sidecar jar and logout removes it', () => {
let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'work']);
assert.equal(r.status, 0, r.stderr);
const jarPath = join(OC_HOME, 'sessions', 'work.cookies.json');
const saved = JSON.parse(readFileSync(jarPath, 'utf8'));
assert.equal(saved.cookies[0].value, 'abc');
r = oc(['logout', 'work']);
assert.equal(r.status, 0, r.stderr);
assert.throws(() => readFileSync(jarPath), /ENOENT/);
});
test('open with an expired jar reports session expired and clears it', () => {
const sessionsDir = join(OC_HOME, 'sessions');
mkdirSync(sessionsDir, { recursive: true });
const jarPath = join(sessionsDir, 'expired.cookies.json');
writeFileSync(jarPath, JSON.stringify({
expiresAt: new Date(Date.now() - 1000).toISOString(),
cookies: [{ name: 'sid', value: 'old', domain: 'example.com', path: '/' }],
}));
const r = oc(['open', 'example.com', '--session', 'expired']);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /session expired or cookies are no longer valid/);
assert.equal(r.stdout.trim(), '');
assert.throws(() => readFileSync(jarPath), /ENOENT/);
});
test('login requires --domain', () => {
const r = oc(['login', '--cookie', 'sid=abc']);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /--domain is required/);
});
test('a session name that is a path is refused before any file is written', () => {
for (const bad of ['../../.ssh/id_rsa', '/tmp/leak', 'a/b']) {
const r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', bad]);
assert.notEqual(r.status, 0, `expected failure for ${bad}`);
assert.match(r.stderr, /invalid session name/);
}
});
test('open sends the jar cookies and renders authenticated content', async () => {
const proxy = http.createServer((req, res) => {
const cookie = req.headers.cookie || '';
res.writeHead(200, { 'content-type': 'text/html' });
res.end(cookie.includes('sid=secret') ? dashHtml : loginHtml);
});
const port = await listen(proxy);
const proxyUrl = `http://127.0.0.1:${port}`;
try {
// --allow-http because this mock speaks plain http; without it the cookie
// is withheld, which is the case the next test covers.
let r = oc(['login', '--cookie', 'sid=secret', '--domain', '1.1.1.1', '--session', 'authed', '--allow-http']);
assert.equal(r.status, 0, r.stderr);
r = await ocAsync(['open', 'http://1.1.1.1/dashboard', '--session', 'authed'], { HTTP_PROXY: proxyUrl });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /Welcome back/);
assert.doesNotMatch(r.stderr, /requires login|session expired/);
} finally {
proxy.close();
}
});
test('a seeded cookie is not sent over plain http unless the user asked for it', async () => {
const proxy = http.createServer((req, res) => {
const cookie = req.headers.cookie || '';
res.writeHead(200, { 'content-type': 'text/html' });
res.end(cookie.includes('sid=secret') ? dashHtml : loginHtml);
});
const port = await listen(proxy);
try {
let r = oc(['login', '--cookie', 'sid=secret', '--domain', '1.1.1.1', '--session', 'httponly']);
assert.equal(r.status, 0, r.stderr);
const saved = JSON.parse(readFileSync(join(OC_HOME, 'sessions', 'httponly.cookies.json'), 'utf8'));
assert.equal(saved.cookies[0].secure, true);
r = await ocAsync(['open', 'http://1.1.1.1/dashboard', '--session', 'httponly'], {
HTTP_PROXY: `http://127.0.0.1:${port}`,
});
// The page came back a login form because the credential stayed home, and
// the warning names the flag that would have sent it.
assert.match(r.stderr, /https-only cookies.*--allow-http/s);
assert.doesNotMatch(r.stdout, /Welcome back/);
} finally {
proxy.close();
}
});
test('an https page that redirects to http does not carry the cookie down with it', async () => {
const seen = [];
const proxy = http.createServer((req, res) => {
seen.push(req.headers.cookie || '');
if (req.url.endsWith('/start')) {
res.writeHead(302, { location: 'http://1.1.1.1/landed' });
res.end();
return;
}
res.writeHead(200, { 'content-type': 'text/html' });
res.end(dashHtml);
});
const port = await listen(proxy);
try {
// Seeded over http so the first hop is reachable through the mock proxy,
// then pinned secure by hand: the jar is what an https login leaves behind.
let r = oc(['login', '--cookie', 'sid=secret', '--domain', '1.1.1.1', '--session', 'hop', '--allow-http']);
assert.equal(r.status, 0, r.stderr);
const jarPath = join(OC_HOME, 'sessions', 'hop.cookies.json');
const jar = JSON.parse(readFileSync(jarPath, 'utf8'));
jar.cookies[0].secure = true;
writeFileSync(jarPath, JSON.stringify(jar));
r = await ocAsync(['open', 'http://1.1.1.1/start', '--session', 'hop'], {
HTTP_PROXY: `http://127.0.0.1:${port}`,
});
assert.equal(r.status, 0, r.stderr);
assert.ok(seen.length >= 2, `expected a redirect hop, saw ${seen.length} requests`);
for (const cookie of seen) assert.doesNotMatch(cookie, /sid=secret/);
} finally {
proxy.close();
}
});
test('--cookie - reads the header from stdin instead of argv', () => {
const r = spawnSync(process.execPath, [bin, 'login', '--cookie', '-', '--domain', 'example.com', '--session', 'piped'], {
encoding: 'utf8',
env: childEnv(),
input: 'Cookie: sid=from-stdin; auth=xyz\n',
});
assert.equal(r.status, 0, r.stderr);
const saved = JSON.parse(readFileSync(join(OC_HOME, 'sessions', 'piped.cookies.json'), 'utf8'));
assert.deepEqual(saved.cookies.map((c) => `${c.name}=${c.value}`), ['sid=from-stdin', 'auth=xyz']);
});
test('--cookie - with nothing piped in says what to pipe', () => {
const r = spawnSync(process.execPath, [bin, 'login', '--cookie', '-', '--domain', 'example.com'], {
encoding: 'utf8',
env: childEnv(),
input: ' \n',
});
assert.notEqual(r.status, 0);
assert.match(r.stderr, /nothing on stdin/);
});
test('login refuses a bare TLD and a cookie carrying a control character', () => {
let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'com', '--session', 'tld']);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /bare name/);
assert.ok(!existsSync(join(OC_HOME, 'sessions', 'tld.cookies.json')));
r = oc(['login', '--cookie', 'sid=a\r\nX-Injected: 1', '--domain', 'example.com', '--session', 'crlf']);
assert.notEqual(r.status, 0);
assert.match(r.stderr, /invalid value for cookie 'sid'/);
assert.ok(!existsSync(join(OC_HOME, 'sessions', 'crlf.cookies.json')));
});
test('logout drops the saved page along with the cookies', () => {
let r = oc(['login', '--cookie', 'sid=abc', '--domain', 'example.com', '--session', 'clean']);
assert.equal(r.status, 0, r.stderr);
const jarPath = join(OC_HOME, 'sessions', 'clean.cookies.json');
const pagePath = join(OC_HOME, 'sessions', 'clean.json');
writeFileSync(pagePath, JSON.stringify({
url: 'https://example.com/dashboard',
title: 'Dashboard',
savedAt: new Date().toISOString(),
blocks: [{ type: 'text', text: 'Secret project notes for the signed-in user.' }],
cursor: null,
history: [],
}));
r = oc(['logout', 'clean']);
assert.equal(r.status, 0, r.stderr);
assert.ok(!existsSync(jarPath));
assert.ok(!existsSync(pagePath));
});
test('open without cookies detects a login page and fails loud', async () => {
const proxy = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(loginHtml);
});
const port = await listen(proxy);
try {
const r = await ocAsync(['open', 'http://1.1.1.1/login', '--session', 'anon'], {
HTTP_PROXY: `http://127.0.0.1:${port}`,
});
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /requires login/);
assert.equal(r.stdout.trim(), '');
} finally {
proxy.close();
}
});
test('json auth failure does not overwrite saved page state', async () => {
const sessionsDir = join(OC_HOME, 'sessions');
mkdirSync(sessionsDir, { recursive: true });
const sessionPath = join(sessionsDir, 'keep.json');
writeFileSync(sessionPath, JSON.stringify({
url: 'http://1.1.1.1/dashboard',
title: 'Dashboard',
savedAt: new Date().toISOString(),
blocks: [{ type: 'heading', text: 'Welcome back', n: 1, level: 1 }],
cursor: null,
history: ['http://1.1.1.1/dashboard'],
}));
const proxy = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/html' });
res.end(loginHtml);
});
const port = await listen(proxy);
try {
const r = await ocAsync(['open', 'http://1.1.1.1/login', '--json', '--session', 'keep'], {
HTTP_PROXY: `http://127.0.0.1:${port}`,
});
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /requires login/);
const saved = JSON.parse(readFileSync(sessionPath, 'utf8'));
assert.equal(saved.title, 'Dashboard');
assert.ok(existsSync(sessionPath));
} finally {
proxy.close();
}
});
+220
View File
@@ -0,0 +1,220 @@
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));
});
+3 -1
View File
@@ -549,8 +549,10 @@ test('a link-list page counts as content even with no prose on it', () => {
test('every fixture page reads as content, none as a failed render', () => {
// Feeds, a JSON API, and a YouTube watch page are all thin by design, which
// is exactly where this check must not cry wolf.
// is exactly where this check must not cry wolf. login.html is an auth-gate
// fixture, not a page that should distill as content.
for (const name of readdirSync(PAGES)) {
if (name === 'login.html') continue;
const raw = readFileSync(PAGES + name, 'utf8');
const page = distill(raw, `https://api.example.test/2.3/search/advanced?site=fixture&f=${name}`);
assert.equal(contentFailure(contentTokens(page), estimateTokens(raw)), null, name);
+159
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import http from 'node:http';
import https from 'node:https';
import net from 'node:net';
import tls from 'node:tls';
const { fetchPage, followRedirects, resolveProxy, proxyGet } = await import('../src/fetch.js');
@@ -498,6 +499,90 @@ test('proxyGet returns the origin body through an HTTPS CONNECT tunnel', async (
}
});
// A separate cert whose SAN covers the IPv6 loopback ::1, so the tunneled
// TLS handshake to an IPv6 literal can be validated offline.
const LOCAL_CERT_V6 = `-----BEGIN CERTIFICATE-----
MIIBsjCCAVigAwIBAgIUYhesMP2mQCQ6S4SrcGJshDK0g9owCgYIKoZIzj0EAwIw
FzEVMBMGA1UEAwwMb2MtaXB2Ni10ZXN0MB4XDTI2MDgyNDE4NTM1MVoXDTM2MDgy
MTE4NTM1MVowFzEVMBMGA1UEAwwMb2MtaXB2Ni10ZXN0MFkwEwYHKoZIzj0CAQYI
KoZIzj0DAQcDQgAE26JWljo6HQCqheYsEL/xViNZpq+6NPKBlEjlvXf/WtJa2mAl
qELRtfWYJeRS+0ogeMNjYXTYME2WKHL3il88cqOBgTB/MB0GA1UdDgQWBBSx4h35
MCnlyDhcx7ATZiWyJ/IYEzAfBgNVHSMEGDAWgBSx4h35MCnlyDhcx7ATZiWyJ/IY
EzAPBgNVHRMBAf8EBTADAQH/MCwGA1UdEQQlMCOHEAAAAAAAAAAAAAAAAAAAAAGH
BH8AAAGCCWxvY2FsaG9zdDAKBggqhkjOPQQDAgNIADBFAiAFJGgQcNAAXI5HWj02
NYBPF1nTo3BfOoT/PY5pUsSjuAIhAP9oPp1R2+ckC9sXTOL8n1vw2qVElGDLuvES
Hi24p3Qi
-----END CERTIFICATE-----`;
const LOCAL_KEY_V6 = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgjZ74TmqrsdfKIelm
KQFHEBF+5zD8lk8lDLuPgvz2dNGhRANCAATbolaWOjodAKqF5iwQv/FWI1mmr7o0
8oGUSOW9d/9a0lraYCWoQtG19Zgl5FL7SiB4w2NhdNgwTZYocveKXzxy
-----END PRIVATE KEY-----`;
test('an IPv6 literal target tunnels through a proxy with its brackets stripped', async () => {
// URL.hostname keeps the brackets ("[::1]"); before the fix they reached
// tls.connect as a DNS name and the handshake never happened, so what this
// test is really about is the host oc hands to the identity check.
//
// It asserts that host directly rather than letting the handshake stand in
// for it: node 24.19 stopped matching IPv6 addresses in a certificate's SAN
// (IPv4 still matches), so the default check now rejects an ::1 origin on
// grounds that have nothing to do with oc, and 24.8 accepts it. Chain
// verification against `ca` stays on; only the hostname step is ours.
const origin = https.createServer({ cert: LOCAL_CERT_V6, key: LOCAL_KEY_V6 }, (req, res) => {
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html><title>v6 tunnel</title></html>');
});
await new Promise((r) => origin.listen(0, '::1', r));
const originPort = origin.address().port;
const proxy = http.createServer();
proxy.on('connect', (req, socket) => {
const host = req.url.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
const port = Number(req.url.slice(req.url.lastIndexOf(':') + 1));
const dest = net.connect(port, host, () => {
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
dest.pipe(socket);
socket.pipe(dest);
});
dest.on('error', () => socket.destroy());
});
const proxyPort = await listen(proxy);
const realConnect = tls.connect;
let identity = null;
let servername = 'unset';
tls.connect = (opts, onSecure) => {
servername = opts.servername;
return realConnect({
...opts,
checkServerIdentity: (host) => {
identity = host;
return undefined;
},
}, onSecure);
};
try {
const res = await proxyGet(
`https://[::1]:${originPort}/page`,
`http://127.0.0.1:${proxyPort}`,
{ 'user-agent': 'oc-test' },
{ ca: LOCAL_CERT_V6 },
);
// The bare address, and no SNI: an IP literal is not a server name.
assert.equal(identity, '::1');
assert.equal(servername, undefined);
assert.equal(res.status, 200);
assert.equal(await res.text(), '<html><title>v6 tunnel</title></html>');
} finally {
tls.connect = realConnect;
origin.close();
proxy.close();
}
});
test('followRedirects still blocks a private hop when the transport is a proxy', async () => {
// The page 302s to loopback. The proxy is also loopback, which is allowed;
// the hop is not. Blocked before the second request goes out.
@@ -531,6 +616,80 @@ test('proxyGet refuses a non-HTTP proxy scheme', () => {
);
});
test('followRedirects sends jar cookies on every hop and stores Set-Cookie', () => withoutProxyEnv(async () => {
const { jarFromCookieHeader, createJarHandle } = await import('../src/cookies.js');
const seed = jarFromCookieHeader('sid=abc', 'public.example');
const jar = createJarHandle('test', seed);
const seen = [];
const get = (url) => {
seen.push({ url, cookie: jar.cookieHeaderFor(url) });
const setCookie = url.includes('/two')
? ['fresh=1; Path=/; Domain=public.example']
: [];
return Promise.resolve({
status: url.includes('/two') ? 200 : 302,
headers: {
get(name) {
if (name === 'location' && !url.includes('/two')) return 'https://public.example/two';
if (name === 'set-cookie') return setCookie.join(', ');
return null;
},
getSetCookie() { return setCookie; },
},
});
};
const { getSetCookieHeaders } = await import('../src/cookies.js');
await followRedirects(get, 'https://public.example/one', {
onResponse: (url, res) => jar.storeFromResponse(url, getSetCookieHeaders(res)),
});
assert.equal(seen.length, 2);
assert.equal(seen[0].cookie, 'sid=abc');
assert.equal(seen[1].cookie, 'sid=abc');
assert.ok(jar.toJSON().cookies.some((c) => c.name === 'fresh'));
}));
test('proxyGet forwards a cookie header from the jar', async () => {
const seen = [];
const proxy = http.createServer((req, res) => {
seen.push({ cookie: req.headers.cookie });
res.writeHead(200, { 'content-type': 'text/html' });
res.end('<html><title>ok</title></html>');
});
const port = await listen(proxy);
try {
await proxyGet('http://example.test/page', `http://127.0.0.1:${port}`, { cookie: 'a=1; b=2' });
assert.equal(seen[0].cookie, 'a=1; b=2');
} finally {
proxy.close();
}
});
test('a proxied response exposes each Set-Cookie intact, even with a comma in Expires', async () => {
const { getSetCookieHeaders } = await import('../src/cookies.js');
const proxy = http.createServer((req, res) => {
// Two separate Set-Cookie headers, one carrying a comma inside Expires:
// joining them into one string would make them impossible to split back.
res.writeHead(200, {
'content-type': 'text/html',
'set-cookie': [
'sid=abc; Path=/; Expires=Wed, 21 Oct 2026 07:28:00 GMT',
'theme=dark; Path=/',
],
});
res.end('<html><title>ok</title></html>');
});
const port = await listen(proxy);
try {
const res = await proxyGet('http://example.test/page', `http://127.0.0.1:${port}`);
const headers = getSetCookieHeaders(res);
assert.equal(headers.length, 2);
assert.match(headers[0], /^sid=abc;/);
assert.match(headers[1], /^theme=dark;/);
} finally {
proxy.close();
}
});
test('a body over the cap is refused, from the header or from the bytes', async () => {
const { assertBodySize, readBody, MAX_BODY } = await import('../src/fetch.js');
+7
View File
@@ -0,0 +1,7 @@
<html><head><title>Sign in</title></head><body>
<form action="/login">
<input type="email" name="email" placeholder="Email">
<input type="password" name="password">
<button type="submit">Sign in</button>
</form>
</body></html>