diff --git a/tests/cli-auth.test.js b/tests/cli-auth.test.js index abb14aa..e0600ed 100644 --- a/tests/cli-auth.test.js +++ b/tests/cli-auth.test.js @@ -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' }); diff --git a/tests/cookies.test.js b/tests/cookies.test.js index 3680240..1e2fd46 100644 --- a/tests/cookies.test.js +++ b/tests/cookies.test.js @@ -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(),