mirror of
https://github.com/only-cli/oc.git
synced 2026-09-15 10:40:56 +02:00
test: cover http downgrades, bare TLDs, control characters, and jar caps
This commit is contained in:
+115
-1
@@ -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' });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user