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:
Garry Tan
2026-04-07 18:49:45 -10:00
parent 6cc094cd41
commit 360bb2e192
5 changed files with 302 additions and 68 deletions

View File

@@ -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' },

View File

@@ -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');

View File

@@ -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': {