From a7593d70ef1b6500d1f6457c58cf7c9896cf6062 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 8 Apr 2026 10:10:13 -0700 Subject: [PATCH] fix: cookie picker auth token leak (v0.15.17.0) (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * chore: bump version and changelog (v0.15.17.0) Co-Authored-By: Claude Opus 4.6 * fix: browse-snapshot E2E turn budget too tight (7 → 9) The agent consistently uses 8 turns for 5 snapshot commands because it reads the saved annotated PNG to verify it was created. All 3 CI attempts hit error_max_turns at exactly 8. Bumping to 9 gives headroom. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- CHANGELOG.md | 5 + VERSION | 2 +- browse/src/cookie-picker-routes.ts | 108 +++++++++-- browse/src/cookie-picker-ui.ts | 7 +- browse/src/write-commands.ts | 6 +- browse/test/cookie-picker-routes.test.ts | 225 ++++++++++++++++++----- browse/test/server-auth.test.ts | 24 +++ test/skill-e2e-bws.test.ts | 2 +- 8 files changed, 309 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4bb4c62..d687fdb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [0.16.1.0] - 2026-04-08 + +### Fixed +- Cookie picker no longer leaks the browse server auth token. Previously, opening the cookie picker page exposed the master bearer token in the HTML source, letting any local process extract it and execute arbitrary JavaScript in your browser session. Now uses a one-time code exchange with an HttpOnly session cookie. The token never appears in HTML, URLs, or browser history. (Reported by Horoshi at Vagabond Research, CVSS 7.8) + ## [0.16.0.0] - 2026-04-07 ### Added diff --git a/VERSION b/VERSION index 70d644c0..84c82737 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.16.0.0 +0.16.1.0 diff --git a/browse/src/cookie-picker-routes.ts b/browse/src/cookie-picker-routes.ts index 775fc0d0..a78741cc 100644 --- a/browse/src/cookie-picker-routes.ts +++ b/browse/src/cookie-picker-routes.ts @@ -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(); +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(); +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' }, diff --git a/browse/src/cookie-picker-ui.ts b/browse/src/cookie-picker-ui.ts index 03089b08..bf151adb 100644 --- a/browse/src/cookie-picker-ui.ts +++ b/browse/src/cookie-picker-ui.ts @@ -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 ` @@ -341,7 +341,6 @@ export function getCookiePickerHTML(serverPort: number, authToken?: string): str