mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
merge main: authenticated sessions via per-session cookie jars
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user