test: cover http downgrades, bare TLDs, control characters, and jar caps

This commit is contained in:
RonCodes88
2026-08-25 09:38:34 +09:00
parent fd3e3f7681
commit 218afd7f83
2 changed files with 197 additions and 1 deletions
+115 -1
View File
@@ -100,7 +100,9 @@ test('open sends the jar cookies and renders authenticated content', async () =>
const port = await listen(proxy);
const proxyUrl = `http://127.0.0.1:${port}`;
try {
let r = oc(['login', '--cookie', 'sid=secret', '--domain', '1.1.1.1', '--session', 'authed']);
// --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 });
@@ -112,6 +114,118 @@ test('open sends the jar cookies and renders authenticated content', async () =>
}
});
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' });
+82
View File
@@ -18,6 +18,10 @@ const {
purgeExpiredJars,
isSessionExpired,
cookieJarPath,
normalizeDomain,
withheldForScheme,
MAX_COOKIES,
MAX_COOKIE_BYTES,
JAR_EXPIRED,
_resetPurgeGuard,
} = await import('../src/cookies.js');
@@ -37,6 +41,84 @@ test('jarFromCookieHeader parses a Cookie header for a domain', () => {
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(),