mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
fix: cookie picker auth token leak (CVE — CVSS 7.8)
GET /cookie-picker served HTML that inlined the master bearer token without authentication. Any local process could extract it and use it to call /command, executing arbitrary JS in the browser context. Fix: Jupyter-style one-time code exchange. The picker URL now includes a one-time code that is consumed via 302 redirect, setting an HttpOnly session cookie. The master AUTH_TOKEN never appears in HTML. The session cookie is isolated from the scoped token system (not valid for /command). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,20 +4,59 @@
|
||||
* Handles all /cookie-picker/* routes. Imports from cookie-import-browser.ts
|
||||
* (decryption) and cookie-picker-ui.ts (HTML generation).
|
||||
*
|
||||
* Routes (no auth — localhost-only, accepted risk):
|
||||
* GET /cookie-picker → serves the picker HTML page
|
||||
* GET /cookie-picker/browsers → list installed browsers
|
||||
* GET /cookie-picker/domains → list domains + counts for a browser
|
||||
* POST /cookie-picker/import → decrypt + import cookies to Playwright
|
||||
* POST /cookie-picker/remove → clear cookies for domains
|
||||
* GET /cookie-picker/imported → currently imported domains + counts
|
||||
* Auth model (post-CVE fix):
|
||||
* GET /cookie-picker → requires one-time code (?code=) or session cookie
|
||||
* GET /cookie-picker/browsers → requires Bearer token or session cookie
|
||||
* GET /cookie-picker/domains → requires Bearer token or session cookie
|
||||
* POST /cookie-picker/import → requires Bearer token or session cookie
|
||||
* POST /cookie-picker/remove → requires Bearer token or session cookie
|
||||
* GET /cookie-picker/imported → requires Bearer token or session cookie
|
||||
*
|
||||
* The session cookie (gstack_picker) is isolated from the scoped token system.
|
||||
* It is NOT valid for /command. This prevents session cookie extraction from
|
||||
* re-enabling the auth token leak vulnerability.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, listProfiles, listDomains, importCookies, CookieImportError, type PlaywrightCookie } from './cookie-import-browser';
|
||||
import { getCookiePickerHTML } from './cookie-picker-ui';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────
|
||||
// ─── Auth State ─────────────────────────────────────────────────
|
||||
// One-time codes for the cookie picker UI (code → expiry timestamp).
|
||||
// Codes are generated by generatePickerCode() and consumed on first use.
|
||||
const pendingCodes = new Map<string, number>();
|
||||
const CODE_TTL_MS = 30_000; // 30 seconds
|
||||
|
||||
// Session cookies for authenticated picker access (session → expiry timestamp).
|
||||
// Sessions are created after a valid code exchange and last 1 hour.
|
||||
const validSessions = new Map<string, number>();
|
||||
const SESSION_TTL_MS = 3_600_000; // 1 hour
|
||||
|
||||
/** Generate a one-time code for opening the cookie picker UI. */
|
||||
export function generatePickerCode(): string {
|
||||
const code = crypto.randomUUID();
|
||||
pendingCodes.set(code, Date.now() + CODE_TTL_MS);
|
||||
return code;
|
||||
}
|
||||
|
||||
/** Extract session ID from the gstack_picker cookie. */
|
||||
function getSessionFromCookie(req: Request): string | null {
|
||||
const cookie = req.headers.get('cookie');
|
||||
if (!cookie) return null;
|
||||
const match = cookie.match(/gstack_picker=([^;]+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/** Check if a session cookie value is valid and not expired. */
|
||||
function isValidSession(session: string): boolean {
|
||||
const expiry = validSessions.get(session);
|
||||
if (!expiry) return false;
|
||||
if (Date.now() > expiry) { validSessions.delete(session); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Domain State ───────────────────────────────────────────────
|
||||
// Tracks which domains were imported via the picker.
|
||||
// /imported only returns cookies for domains in this Set.
|
||||
// /remove clears from this Set.
|
||||
@@ -71,19 +110,56 @@ export async function handleCookiePickerRoute(
|
||||
}
|
||||
|
||||
try {
|
||||
// GET /cookie-picker — serve the picker UI
|
||||
// GET /cookie-picker — serve the picker UI (requires code or session cookie)
|
||||
if (pathname === '/cookie-picker' && req.method === 'GET') {
|
||||
const html = getCookiePickerHTML(port, authToken);
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
const code = url.searchParams.get('code');
|
||||
|
||||
// Code exchange: validate one-time code, set session cookie, redirect
|
||||
if (code) {
|
||||
const expiry = pendingCodes.get(code);
|
||||
if (!expiry || Date.now() > expiry) {
|
||||
pendingCodes.delete(code);
|
||||
return new Response('Invalid or expired code. Re-run cookie-import-browser.', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
pendingCodes.delete(code); // one-time use
|
||||
const session = crypto.randomUUID();
|
||||
validSessions.set(session, Date.now() + SESSION_TTL_MS);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': '/cookie-picker',
|
||||
'Set-Cookie': `gstack_picker=${session}; HttpOnly; SameSite=Strict; Path=/cookie-picker; Max-Age=3600`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Session cookie: serve HTML (no auth token inlined)
|
||||
const session = getSessionFromCookie(req);
|
||||
if (session && isValidSession(session)) {
|
||||
const html = getCookiePickerHTML(port);
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
// No code, no session: reject
|
||||
return new Response('Access denied. Open the cookie picker from gstack.', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Auth gate: all data/action routes below require Bearer token ───
|
||||
// Auth is mandatory — if authToken is undefined, reject all requests
|
||||
// ─── Auth gate: all data/action routes below require Bearer token or session cookie ───
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (!authToken || !authHeader || authHeader !== `Bearer ${authToken}`) {
|
||||
const sessionId = getSessionFromCookie(req);
|
||||
const hasBearer = !!authToken && !!authHeader && authHeader === `Bearer ${authToken}`;
|
||||
const hasSession = sessionId !== null && isValidSession(sessionId);
|
||||
if (!hasBearer && !hasSession) {
|
||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* No cookie values exposed anywhere.
|
||||
*/
|
||||
|
||||
export function getCookiePickerHTML(serverPort: number, authToken?: string): string {
|
||||
export function getCookiePickerHTML(serverPort: number): string {
|
||||
const baseUrl = `http://127.0.0.1:${serverPort}`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
<script>
|
||||
(function() {
|
||||
const BASE = '${baseUrl}';
|
||||
const AUTH_TOKEN = '${authToken || ''}';
|
||||
let activeBrowser = null;
|
||||
let activeProfile = 'Default';
|
||||
let allProfiles = [];
|
||||
@@ -384,9 +383,7 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str
|
||||
|
||||
// ─── API ────────────────────────────────
|
||||
async function api(path, opts) {
|
||||
const headers = { ...(opts?.headers || {}) };
|
||||
if (AUTH_TOKEN) headers['Authorization'] = 'Bearer ' + AUTH_TOKEN;
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, headers });
|
||||
const res = await fetch(BASE + '/cookie-picker' + path, { ...opts, credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || 'Request failed');
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { findInstalledBrowsers, importCookies, listSupportedBrowserNames } from './cookie-import-browser';
|
||||
import { generatePickerCode } from './cookie-picker-routes';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
@@ -530,14 +531,15 @@ export async function handleWriteCommand(
|
||||
throw new Error(`No Chromium browsers found. Supported: ${listSupportedBrowserNames().join(', ')}`);
|
||||
}
|
||||
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker`;
|
||||
const code = generatePickerCode();
|
||||
const pickerUrl = `http://127.0.0.1:${port}/cookie-picker?code=${code}`;
|
||||
try {
|
||||
Bun.spawn(['open', pickerUrl], { stdout: 'ignore', stderr: 'ignore' });
|
||||
} catch {
|
||||
// open may fail silently — URL is in the message below
|
||||
}
|
||||
|
||||
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
return `Cookie picker opened at http://127.0.0.1:${port}/cookie-picker\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* Tests for cookie-picker route handler
|
||||
*
|
||||
* Tests the HTTP glue layer directly with mock BrowserManager objects.
|
||||
* Verifies that all routes return valid JSON (not HTML) with correct CORS headers.
|
||||
* Verifies auth (one-time code exchange, session cookies, Bearer tokens),
|
||||
* CORS headers, and JSON response formats.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { handleCookiePickerRoute } from '../src/cookie-picker-routes';
|
||||
import { handleCookiePickerRoute, generatePickerCode } from '../src/cookie-picker-routes';
|
||||
|
||||
// ─── Mock BrowserManager ──────────────────────────────────────
|
||||
|
||||
@@ -31,15 +32,28 @@ function makeUrl(path: string, port = 9470): URL {
|
||||
return new URL(`http://127.0.0.1:${port}${path}`);
|
||||
}
|
||||
|
||||
function makeReq(method: string, body?: any): Request {
|
||||
const opts: RequestInit = { method };
|
||||
function makeReq(method: string, body?: any, headers?: Record<string, string>): Request {
|
||||
const opts: RequestInit = { method, headers: { ...headers } };
|
||||
if (body) {
|
||||
opts.body = JSON.stringify(body);
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
(opts.headers as any)['Content-Type'] = 'application/json';
|
||||
}
|
||||
return new Request('http://127.0.0.1:9470', opts);
|
||||
}
|
||||
|
||||
/** Helper: exchange a one-time code and return the session cookie value. */
|
||||
async function getSessionCookie(bm: any, authToken: string): Promise<string> {
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res = await handleCookiePickerRoute(url, req, bm, authToken);
|
||||
expect(res.status).toBe(302);
|
||||
const setCookie = res.headers.get('Set-Cookie') || '';
|
||||
const match = setCookie.match(/gstack_picker=([^;]+)/);
|
||||
expect(match).not.toBeNull();
|
||||
return match![1];
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe('cookie-picker-routes', () => {
|
||||
@@ -59,21 +73,27 @@ describe('cookie-picker-routes', () => {
|
||||
test('JSON responses include correct CORS origin with port', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers', 9450);
|
||||
const req = new Request('http://127.0.0.1:9450', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9450', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://127.0.0.1:9450');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON responses (not HTML)', () => {
|
||||
describe('JSON responses (with auth)', () => {
|
||||
test('GET /cookie-picker/browsers returns JSON', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -85,9 +105,12 @@ describe('cookie-picker-routes', () => {
|
||||
test('GET /cookie-picker/domains without browser param returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/domains');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -102,10 +125,13 @@ describe('cookie-picker-routes', () => {
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'POST',
|
||||
body: 'not json',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer test-token',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -116,9 +142,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/import missing browser field returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/import');
|
||||
const req = makeReq('POST', { domains: ['.example.com'] });
|
||||
const req = makeReq('POST', { domains: ['.example.com'] }, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -128,9 +154,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/import missing domains returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/import');
|
||||
const req = makeReq('POST', { browser: 'Chrome' });
|
||||
const req = makeReq('POST', { browser: 'Chrome' }, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -143,10 +169,13 @@ describe('cookie-picker-routes', () => {
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'POST',
|
||||
body: '{bad',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer test-token',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -155,9 +184,9 @@ describe('cookie-picker-routes', () => {
|
||||
test('POST /cookie-picker/remove missing domains returns JSON error', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/remove');
|
||||
const req = makeReq('POST', {});
|
||||
const req = makeReq('POST', {}, { 'Authorization': 'Bearer test-token' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
@@ -167,9 +196,12 @@ describe('cookie-picker-routes', () => {
|
||||
test('GET /cookie-picker/imported returns JSON with domain list', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/imported');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
@@ -181,45 +213,148 @@ describe('cookie-picker-routes', () => {
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
test('GET /cookie-picker returns HTML', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toContain('text/html');
|
||||
});
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
test('unknown path returns 404 (with auth)', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/nonexistent');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': 'Bearer test-token' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm);
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth gate security', () => {
|
||||
test('GET /cookie-picker HTML page works without auth token', async () => {
|
||||
describe('one-time code exchange', () => {
|
||||
test('valid code returns 302 redirect with session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
// Request with no Authorization header, but authToken is set on the server
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-secret-token');
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get('Location')).toBe('/cookie-picker');
|
||||
const setCookie = res.headers.get('Set-Cookie') || '';
|
||||
expect(setCookie).toContain('gstack_picker=');
|
||||
expect(setCookie).toContain('HttpOnly');
|
||||
expect(setCookie).toContain('SameSite=Strict');
|
||||
expect(setCookie).toContain('Path=/cookie-picker');
|
||||
expect(setCookie).toContain('Max-Age=3600');
|
||||
expect(res.headers.get('Cache-Control')).toBe('no-store');
|
||||
});
|
||||
|
||||
test('code cannot be reused', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const code = generatePickerCode();
|
||||
const url = makeUrl(`/cookie-picker?code=${code}`);
|
||||
|
||||
// First use: success
|
||||
const req1 = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res1 = await handleCookiePickerRoute(url, req1, bm, 'test-token');
|
||||
expect(res1.status).toBe(302);
|
||||
|
||||
// Second use: rejected
|
||||
const req2 = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
const res2 = await handleCookiePickerRoute(url, req2, bm, 'test-token');
|
||||
expect(res2.status).toBe(403);
|
||||
});
|
||||
|
||||
test('invalid code returns 403', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker?code=not-a-valid-code');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('GET /cookie-picker without code or session returns 403', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session cookie auth', () => {
|
||||
test('valid session cookie grants HTML access', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toContain('text/html');
|
||||
});
|
||||
|
||||
test('HTML response does NOT contain auth token', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const authToken = 'super-secret-auth-token-12345';
|
||||
const session = await getSessionCookie(bm, authToken);
|
||||
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, authToken);
|
||||
const html = await res.text();
|
||||
|
||||
expect(html).not.toContain(authToken);
|
||||
expect(html).not.toContain('AUTH_TOKEN');
|
||||
});
|
||||
|
||||
test('data routes accept session cookie', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const session = await getSessionCookie(bm, 'test-token');
|
||||
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': `gstack_picker=${session}` },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('Content-Type')).toBe('application/json');
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('browsers');
|
||||
});
|
||||
|
||||
test('invalid session cookie returns 403 for HTML', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
method: 'GET',
|
||||
headers: { 'Cookie': 'gstack_picker=fake-session' },
|
||||
});
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-token');
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth gate security', () => {
|
||||
test('GET /cookie-picker/browsers returns 401 without auth', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
// No Authorization header
|
||||
const req = new Request('http://127.0.0.1:9470', { method: 'GET' });
|
||||
|
||||
const res = await handleCookiePickerRoute(url, req, bm, 'test-secret-token');
|
||||
@@ -241,7 +376,7 @@ describe('cookie-picker-routes', () => {
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
|
||||
test('GET /cookie-picker/browsers works with valid auth', async () => {
|
||||
test('GET /cookie-picker/browsers works with valid Bearer auth', async () => {
|
||||
const { bm } = mockBrowserManager();
|
||||
const url = makeUrl('/cookie-picker/browsers');
|
||||
const req = new Request('http://127.0.0.1:9470', {
|
||||
|
||||
@@ -317,4 +317,28 @@ describe('Server auth security', () => {
|
||||
// The ownership check condition must exclude newtab
|
||||
expect(ownershipBlock).toContain("command !== 'newtab'");
|
||||
});
|
||||
|
||||
// CVE fix: cookie-picker HTML must NOT inline the auth token.
|
||||
// getCookiePickerHTML() must not accept an authToken parameter.
|
||||
test('cookie-picker UI does not accept or inline auth token', () => {
|
||||
const uiSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-ui.ts'), 'utf-8');
|
||||
// Function signature must not include authToken
|
||||
expect(uiSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/);
|
||||
// No AUTH_TOKEN interpolation in template
|
||||
expect(uiSrc).not.toContain("AUTH_TOKEN = '${authToken");
|
||||
expect(uiSrc).not.toContain("AUTH_TOKEN = '${auth");
|
||||
});
|
||||
|
||||
// CVE fix: cookie-picker route handler uses one-time code exchange, not open access.
|
||||
test('cookie-picker HTML route requires code or session cookie', () => {
|
||||
const routeSrc = fs.readFileSync(path.join(import.meta.dir, '../src/cookie-picker-routes.ts'), 'utf-8');
|
||||
// Must have code validation
|
||||
expect(routeSrc).toContain('pendingCodes');
|
||||
expect(routeSrc).toContain('validSessions');
|
||||
// Must NOT pass authToken to getCookiePickerHTML
|
||||
expect(routeSrc).not.toMatch(/getCookiePickerHTML\([^)]*authToken/);
|
||||
// Must set HttpOnly session cookie
|
||||
expect(routeSrc).toContain('HttpOnly');
|
||||
expect(routeSrc).toContain('SameSite=Strict');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user