mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 01:31:26 +08: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': {
|
||||
|
||||
Reference in New Issue
Block a user