fix(browse): stop health auth token exposure

Fixes #1324

- make /health public status-only, even for spoofed chrome-extension origins
- provision extension auth through bundled extension chrome.storage
- pin terminal-agent websocket auth to the detected gstack extension id
- add focused regressions for token leakage and extension auth bootstrap
This commit is contained in:
maxpetrusenkoagent 2026-06-09 15:39:51 -04:00
parent cab774cced
commit e9d23ddd0f
10 changed files with 247 additions and 68 deletions

View File

@ -15,8 +15,7 @@
* restores state. Falls back to clean slate on any failure. * restores state. Falls back to clean slate on any failure.
*/ */
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie, type Worker } from 'playwright';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { emitActivity } from './activity'; import { emitActivity } from './activity';
import { validateNavigationUrl } from './url-validation'; import { validateNavigationUrl } from './url-validation';
@ -196,6 +195,8 @@ export class BrowserManager {
// ─── Headed State ──────────────────────────────────────── // ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched'; private connectionMode: 'launched' | 'headed' = 'launched';
private intentionalDisconnect = false; private intentionalDisconnect = false;
private extensionAuthToken: string | null = null;
private extensionId: string | null = null;
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ─────── // ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
// Idempotent threshold trackers: each guardrail fires exactly once per // Idempotent threshold trackers: each guardrail fires exactly once per
@ -317,6 +318,83 @@ export class BrowserManager {
return null; return null;
} }
/**
* Provision root auth to the trusted bundled extension without exposing it
* on a public HTTP endpoint. The service worker runs in the extension's
* isolated chrome-extension:// origin, so chrome.storage.local is readable
* by gstack's extension pages but not by arbitrary localhost processes or
* other extensions.
*/
private async provisionExtensionAuth(authToken?: string): Promise<void> {
if (authToken) this.extensionAuthToken = authToken;
const token = authToken ?? this.extensionAuthToken;
if (!token || !this.context) return;
const writeToken = async (worker: Worker): Promise<void> => {
this.rememberExtensionId(worker);
await worker.evaluate(({ token: storedToken, port }) => {
return (globalThis as any).chrome.storage.local.set({ gstackAuthToken: storedToken, port });
}, { token, port: this.serverPort || 34567 });
};
this.context.on('serviceworker', (worker) => {
void (async () => {
if (!(await this.isGstackExtensionWorker(worker))) return;
await writeToken(worker);
})().catch((err: any) => {
console.warn(`[browse] Could not provision late extension auth storage: ${err.message}`);
});
});
let worker: Worker | undefined;
for (const candidate of this.context.serviceWorkers()) {
if (await this.isGstackExtensionWorker(candidate)) {
worker = candidate;
break;
}
}
if (!worker) {
try {
const candidate = await this.context.waitForEvent('serviceworker', { timeout: 5000 });
if (await this.isGstackExtensionWorker(candidate)) worker = candidate;
} catch {
// Component-baked/custom builds may delay worker startup until the
// sidepanel is opened. The event listener above provisions auth when
// the worker appears. Do not fall back to /health token leakage.
console.warn('[browse] Extension service worker not ready yet; auth storage will be provisioned on startup');
return;
}
}
if (!worker) {
console.warn('[browse] GStack extension service worker not ready yet; auth storage will be provisioned on startup');
return;
}
try {
await writeToken(worker);
} catch (err: any) {
console.warn(`[browse] Could not provision extension auth storage: ${err.message}`);
}
}
private async isGstackExtensionWorker(worker: Worker): Promise<boolean> {
if (!worker.url().startsWith('chrome-extension://')) return false;
try {
const manifest = await worker.evaluate(() => (globalThis as any).chrome.runtime.getManifest?.());
return manifest?.name === 'gstack browse'
&& manifest?.background?.service_worker === 'background.js';
} catch {
return false;
}
}
private rememberExtensionId(worker: Worker): void {
const match = worker.url().match(/^chrome-extension:\/\/([^/]+)/);
if (match?.[1]) this.extensionId = match[1];
}
/** /**
* Set the proxy config applied to chromium.launch() in launch() and * Set the proxy config applied to chromium.launch() in launch() and
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
@ -326,6 +404,14 @@ export class BrowserManager {
this.proxyConfig = cfg; this.proxyConfig = cfg;
} }
setExtensionAuthToken(token: string | undefined): void {
if (token) this.extensionAuthToken = token;
}
getExtensionId(): string | null {
return this.extensionId;
}
/** /**
* Get the ref map for external consumers (e.g., /refs endpoint). * Get the ref map for external consumers (e.g., /refs endpoint).
*/ */
@ -451,22 +537,11 @@ export class BrowserManager {
launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`);
} }
// Write auth token for extension bootstrap (still required even when // Auth bootstrap is provisioned into chrome.storage.local after the
// the extension is component-baked — it reads ~/.gstack/.auth.json at // persistent context starts. Do not write a readable file or expose the
// startup to learn how to call the daemon). // token on /health; local processes can read/spoof those surfaces.
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
// in .app bundles and breaks codesigning).
if (authToken) { if (authToken) {
const fs = require('fs'); console.log('[browse] Extension auth will be provisioned via chrome.storage');
const path = require('path');
const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack');
mkdirSecure(gstackDir);
const authFile = path.join(gstackDir, '.auth.json');
try {
writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 }));
} catch (err: any) {
console.warn(`[browse] Could not write .auth.json: ${err.message}`);
}
} }
} }
@ -588,6 +663,7 @@ export class BrowserManager {
// synthesis — they're removing leaked automation tells. // synthesis — they're removing leaked automation tells.
const { applyStealth } = await import('./stealth'); const { applyStealth } = await import('./stealth');
await applyStealth(this.context); await applyStealth(this.context);
await this.provisionExtensionAuth(authToken);
await this.context.addInitScript(() => { await this.context.addInitScript(() => {
// Remove CDP runtime artifacts that automation detectors look for // Remove CDP runtime artifacts that automation detectors look for
// cdc_ prefixed vars are injected by ChromeDriver/CDP // cdc_ prefixed vars are injected by ChromeDriver/CDP
@ -1560,8 +1636,8 @@ export class BrowserManager {
if (extensionPath) { if (extensionPath) {
launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--disable-extensions-except=${extensionPath}`);
launchArgs.push(`--load-extension=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`);
// Auth token is served via /health endpoint now (no file write needed). // Extension auth is provisioned into chrome.storage.local after the
// Extension reads token from /health on connect. // headed context starts. Do not use /health as a token bootstrap path.
console.log(`[browse] Handoff: loading extension from ${extensionPath}`); console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
} else { } else {
console.log('[browse] Handoff: extension not found — headed mode without side panel'); console.log('[browse] Handoff: extension not found — headed mode without side panel');
@ -1605,6 +1681,8 @@ export class BrowserManager {
await newContext.setExtraHTTPHeaders(this.extraHeaders); await newContext.setExtraHTTPHeaders(this.extraHeaders);
} }
await this.provisionExtensionAuth();
// Register disconnect handler on new browser. Same clean-vs-crash // Register disconnect handler on new browser. Same clean-vs-crash
// discrimination as launch() / launchHeaded() above so a user-initiated // discrimination as launch() / launchHeaded() above so a user-initiated
// Cmd+Q after a handoff doesn't trigger gbd's restart loop. // Cmd+Q after a handoff doesn't trigger gbd's restart loop.

View File

@ -97,6 +97,7 @@ interface ServerState {
serverPath: string; serverPath: string;
binaryVersion?: string; binaryVersion?: string;
mode?: 'launched' | 'headed'; mode?: 'launched' | 'headed';
extensionId?: string;
/** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */
configHash?: string; configHash?: string;
/** Xvfb child PID for cleanup on disconnect. */ /** Xvfb child PID for cleanup on disconnect. */
@ -1131,6 +1132,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
stateFile: config.stateFile, stateFile: config.stateFile,
serverPort: newState.port, serverPort: newState.port,
cwd: config.projectDir, cwd: config.projectDir,
extraEnv: {
BROWSE_EXTENSION_ID: newState.extensionId || '__gstack_extension_id_unavailable__',
},
}); });
if (newPid) { if (newPid) {
console.log(`[browse] Terminal agent started (PID: ${newPid})`); console.log(`[browse] Terminal agent started (PID: ${newPid})`);
@ -1223,6 +1227,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
stateFile: config.stateFile, stateFile: config.stateFile,
serverPort: respawned.port, serverPort: respawned.port,
cwd: config.projectDir, cwd: config.projectDir,
extraEnv: {
BROWSE_EXTENSION_ID: respawned.extensionId || '__gstack_extension_id_unavailable__',
},
}); });
} catch (err: any) { } catch (err: any) {
console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`); console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`);

View File

@ -1519,6 +1519,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
stateFile: cfg.config.stateFile, stateFile: cfg.config.stateFile,
serverPort: cfg.browsePort, serverPort: cfg.browsePort,
cwd: cfg.config.projectDir, cwd: cfg.config.projectDir,
extraEnv: {
BROWSE_EXTENSION_ID: cfgBrowserManager.getExtensionId() || '__gstack_extension_id_unavailable__',
},
}); });
if (pid) { if (pid) {
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`); console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
@ -1744,14 +1747,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
mode: browserManager.getConnectionMode(), mode: browserManager.getConnectionMode(),
uptime: Math.floor((Date.now() - startTime) / 1000), uptime: Math.floor((Date.now() - startTime) / 1000),
tabs: browserManager.getTabCount(), tabs: browserManager.getTabCount(),
// Auth token for extension bootstrap. Safe: /health is localhost-only. // Public liveness/status only. Do NOT return the root auth token here.
// Previously served unconditionally, but that leaks the token if the // Regression #1324: any local process can spoof Origin and use a
// server is tunneled to the internet (ngrok, SSH tunnel). // leaked root token to mint /pty-session shell access. The trusted
// In headed mode the server is always local, so return token unconditionally // extension receives bootstrap auth out-of-band via chrome.storage
// (fixes Playwright Chromium extensions that don't send Origin header). // provisioning during headed launch instead.
...(browserManager.getConnectionMode() === 'headed' ||
req.headers.get('origin')?.startsWith('chrome-extension://')
? { token: authToken } : {}),
// The chat queue is gone — Terminal pane is the sole sidebar // The chat queue is gone — Terminal pane is the sole sidebar
// surface. Keep `chatEnabled: false` so any older extension // surface. Keep `chatEnabled: false` so any older extension
// build still treats the chat input as disabled. // build still treats the chat input as disabled.
@ -2761,11 +2761,8 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// GET /memory — diagnostic snapshot (auth required, does NOT reset idle). // GET /memory — diagnostic snapshot (auth required, does NOT reset idle).
// Same auth model as /activity/stream and /inspector/events: Bearer header // Same auth model as /activity/stream and /inspector/events: Bearer header
// OR view-only SSE-session cookie. Does NOT extend /health (which already // OR view-only SSE-session cookie. Keep this separate from /health, which
// leaks AUTH_TOKEN to any localhost caller in headed mode — see TODOS.md // is public status-only and must never carry AUTH_TOKEN (#1324).
// "Audit /health token distribution"); a separate endpoint with the
// standard SSE auth keeps the future /health fix from cascading into the
// sidebar footer poll.
if (url.pathname === '/memory' && req.method === 'GET') { if (url.pathname === '/memory' && req.method === 'GET') {
const cookieToken = extractSseCookie(req); const cookieToken = extractSseCookie(req);
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) { if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
@ -2831,6 +2828,10 @@ export async function start() {
const port = await findPort(); const port = await findPort();
LOCAL_LISTEN_PORT = port; LOCAL_LISTEN_PORT = port;
// Extension auth bootstrap happens during browser launch, before Bun.serve
// writes the state file. Set the selected port now so chrome.storage gets
// the real daemon port instead of falling back to the historical 34567.
browserManager.serverPort = port;
// ─── Proxy config (D8 + codex F5) ────────────────────────────── // ─── Proxy config (D8 + codex F5) ──────────────────────────────
// BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5
@ -2928,6 +2929,7 @@ export async function start() {
// write so all consumers see the same value. v1.34.x's module-level // write so all consumers see the same value. v1.34.x's module-level
// AUTH_TOKEN const was deleted in v1.35.0.0. // AUTH_TOKEN const was deleted in v1.35.0.0.
const envCfg = resolveConfigFromEnv(); const envCfg = resolveConfigFromEnv();
browserManager.setExtensionAuthToken(envCfg.authToken);
// Launch browser (headless or headed with extension) // Launch browser (headless or headed with extension)
// BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing) // BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing)
@ -2972,6 +2974,7 @@ export async function start() {
serverPath: path.resolve(import.meta.dir, 'server.ts'), serverPath: path.resolve(import.meta.dir, 'server.ts'),
binaryVersion: readVersionHash() || undefined, binaryVersion: readVersionHash() || undefined,
mode: browserManager.getConnectionMode(), mode: browserManager.getConnectionMode(),
extensionId: browserManager.getExtensionId() || undefined,
// D2 daemon-mismatch detection: CLI computes the same hash from its // D2 daemon-mismatch detection: CLI computes the same hash from its
// resolved flags and refuses if it differs from this stored value. // resolved flags and refuses if it differs from this stored value.
...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}), ...(process.env.BROWSE_CONFIG_HASH ? { configHash: process.env.BROWSE_CONFIG_HASH } : {}),
@ -2984,8 +2987,6 @@ export async function start() {
fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });
fs.renameSync(tmpFile, config.stateFile); fs.renameSync(tmpFile, config.stateFile);
browserManager.serverPort = port;
// Navigate to welcome page if in headed mode and still on about:blank // Navigate to welcome page if in headed mode and still on about:blank
if (browserManager.getConnectionMode() === 'headed') { if (browserManager.getConnectionMode() === 'headed') {
try { try {

View File

@ -30,7 +30,7 @@ import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json');
const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port'); const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10); const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10);
const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || '__gstack_extension_id_unavailable__'; // fail closed if spawn did not pin the bundled extension
const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn
/** /**
* Per-boot generation identifier. Loopback /internal/* callers include * Per-boot generation identifier. Loopback /internal/* callers include
@ -588,7 +588,7 @@ function buildServer() {
if (!isExtensionOrigin) { if (!isExtensionOrigin) {
return new Response('forbidden origin', { status: 403 }); return new Response('forbidden origin', { status: 403 });
} }
if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) { if (origin !== `chrome-extension://${EXTENSION_ID}`) {
return new Response('forbidden origin', { status: 403 }); return new Response('forbidden origin', { status: 403 });
} }

View File

@ -0,0 +1,83 @@
/**
* Extension auth bootstrap regression tests for #1324.
*
* /health is a public liveness endpoint, so it must never carry the root
* auth token. The trusted bundled extension gets auth via chrome.storage
* populated by BrowserManager after the extension service worker starts.
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
const ROOT = path.resolve(new URL(import.meta.url).pathname, '..', '..');
const BACKGROUND_SRC = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8');
const SERVER_SRC = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8');
function sliceBetween(source: string, startMarker: string, endMarker: string): string {
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) throw new Error(`Marker not found: ${startMarker}`);
const endIdx = source.indexOf(endMarker, startIdx + startMarker.length);
if (endIdx === -1) throw new Error(`End marker not found: ${endMarker}`);
return source.slice(startIdx, endIdx);
}
describe('extension auth bootstrap', () => {
test('background loads auth token from chrome.storage instead of /health', () => {
const loadAuthToken = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling');
expect(loadAuthToken).toContain('chrome.storage.local.get');
expect(loadAuthToken).toContain('gstackAuthToken');
expect(loadAuthToken).not.toContain('/health');
});
test('background refreshes storage auth and port before health checks', () => {
const loadAuthToken = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling');
expect(loadAuthToken).not.toContain('if (authToken) return');
expect(loadAuthToken).toContain('if (data.port) serverPort = data.port');
expect(loadAuthToken).not.toContain('if (data.port && !serverPort)');
const checkHealth = sliceBetween(BACKGROUND_SRC, 'async function checkHealth()', 'function setConnected');
expect(checkHealth.indexOf('await loadAuthToken()')).toBeGreaterThan(-1);
expect(checkHealth.indexOf('await loadAuthToken()')).toBeLessThan(checkHealth.indexOf('const base = getBaseUrl()'));
});
test('BrowserManager provisions root auth into extension storage after headed launch', () => {
expect(BROWSER_MANAGER_SRC).toContain('provisionExtensionAuth');
expect(BROWSER_MANAGER_SRC).toContain('chrome.storage.local.set');
expect(BROWSER_MANAGER_SRC).toContain('gstackAuthToken');
expect(BROWSER_MANAGER_SRC).toContain('waitForEvent(\'serviceworker\'');
expect(BROWSER_MANAGER_SRC).toContain("this.context.on('serviceworker'");
expect(BROWSER_MANAGER_SRC).toContain('await this.provisionExtensionAuth(authToken)');
});
test('BrowserManager only provisions auth to the bundled gstack extension worker', () => {
expect(BROWSER_MANAGER_SRC).toContain('isGstackExtensionWorker');
expect(BROWSER_MANAGER_SRC).toContain('chrome.runtime.getManifest');
expect(BROWSER_MANAGER_SRC).toContain("manifest?.name === 'gstack browse'");
expect(BROWSER_MANAGER_SRC).toContain("manifest?.background?.service_worker === 'background.js'");
expect(BROWSER_MANAGER_SRC).not.toContain(".find((w) => w.url().startsWith('chrome-extension://'))");
});
test('server preserves auth for later handoff without using /health', () => {
expect(BROWSER_MANAGER_SRC).toContain('setExtensionAuthToken');
expect(SERVER_SRC).toContain('browserManager.setExtensionAuthToken(envCfg.authToken)');
expect(BROWSER_MANAGER_SRC).toContain('await this.provisionExtensionAuth();');
expect(BROWSER_MANAGER_SRC).not.toContain('Extension reads token from /health');
});
test('server port is known before extension auth is provisioned', () => {
const startBody = sliceBetween(SERVER_SRC, 'export async function start()', 'const startTime = Date.now();');
expect(startBody.indexOf('browserManager.serverPort = port')).toBeGreaterThan(-1);
expect(startBody.indexOf('browserManager.serverPort = port')).toBeLessThan(startBody.indexOf('await browserManager.launchHeaded(envCfg.authToken)'));
});
test('terminal agent spawn is pinned to the bundled extension id', () => {
const terminalSpawnEnv = sliceBetween(SERVER_SRC, 'const state: Record<string, unknown> = {', 'fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 });');
expect(BROWSER_MANAGER_SRC).toContain('getExtensionId()');
expect(terminalSpawnEnv).toContain('extensionId');
expect(terminalSpawnEnv).toContain('browserManager.getExtensionId()');
const cliSpawn = sliceBetween(fs.readFileSync(path.join(ROOT, 'src', 'cli.ts'), 'utf-8'), 'const newPid = spawnTerminalAgent({', 'if (newPid) {');
expect(cliSpawn).toContain('BROWSE_EXTENSION_ID: newState.extensionId');
});
});

View File

@ -94,15 +94,16 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
if (daemon) killDaemon(daemon); if (daemon) killDaemon(daemon);
}); });
test('GET /health returns daemon status and includes token for chrome-extension origin', async () => { test('GET /health never includes root token even for chrome-extension origin', async () => {
const resp = await fetch(`${daemon.baseUrl}/health`, { const resp = await fetch(`${daemon.baseUrl}/health`, {
headers: { Origin: 'chrome-extension://test-extension-id' }, headers: { Origin: 'chrome-extension://test-extension-id' },
}); });
expect(resp.status).toBe(200); expect(resp.status).toBe(200);
const body = await resp.json() as any; const body = await resp.json() as any;
expect(body.status).toBeDefined(); expect(body.status).toBeDefined();
// Extension bootstrap — local listener delivers the token // Regression #1324: Origin is caller-controlled, so /health must stay public
expect(body.token).toBe(daemon.token); // status-only. The bundled extension gets auth via chrome.storage provisioning.
expect(body.token).toBeUndefined();
}); });
test('GET /health without chrome-extension origin does NOT include token', async () => { test('GET /health without chrome-extension origin does NOT include token', async () => {

View File

@ -22,14 +22,15 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
} }
describe('Server auth security', () => { describe('Server auth security', () => {
// Test 1: /health serves token conditionally (headed mode or chrome extension only) // Test 1: /health is public status only and must never bootstrap root auth.
test('/health serves token only in headed mode or to chrome extensions', () => { // Regression for #1324: any local process can spoof an Origin header, so
// returning `token: authToken` from /health lets a malicious extension or curl
// mint a PTY session and drive the user's shell.
test('/health never serves the root auth token', () => {
const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/connect'"); const healthBlock = sliceBetween(SERVER_SRC, "url.pathname === '/health'", "url.pathname === '/connect'");
// v1.35.0.0: AUTH_TOKEN const was deleted; factory uses cfg-derived authToken. expect(healthBlock).not.toContain('token: authToken');
// Token must be conditional, not unconditional expect(healthBlock).not.toContain('browserManager.getConnectionMode() === \'headed\'');
expect(healthBlock).toContain('token: authToken'); expect(healthBlock).not.toContain("startsWith('chrome-extension://')");
expect(healthBlock).toContain('headed');
expect(healthBlock).toContain('chrome-extension://');
}); });
// Test 1b: /health does not expose sensitive browsing state // Test 1b: /health does not expose sensitive browsing state

View File

@ -157,7 +157,7 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => { test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => {
expect(TERM_JS).toContain('function forceRestart'); expect(TERM_JS).toContain('function forceRestart');
const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart')); const fn = TERM_JS.slice(TERM_JS.indexOf('function forceRestart'));
expect(fn).toContain('ws && ws.close()'); expect(fn).toContain('ws && ws.close(');
expect(fn).toContain('term.dispose()'); expect(fn).toContain('term.dispose()');
expect(fn).toContain('STATE.IDLE'); expect(fn).toContain('STATE.IDLE');
expect(fn).toContain('tryAutoConnect()'); expect(fn).toContain('tryAutoConnect()');
@ -222,8 +222,8 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
}); });
test('Terminal-agent spawn survives', () => { test('Terminal-agent spawn survives', () => {
expect(CLI_SRC).toContain('terminal-agent.ts'); expect(CLI_SRC).toContain("from './terminal-agent-control'");
expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/); expect(CLI_SRC).toContain('spawnTerminalAgent({');
}); });
}); });

View File

@ -59,6 +59,7 @@ beforeAll(() => {
...process.env, ...process.env,
BROWSE_STATE_FILE: stateFile, BROWSE_STATE_FILE: stateFile,
BROWSE_SERVER_PORT: '0', // not used in this test BROWSE_SERVER_PORT: '0', // not used in this test
BROWSE_EXTENSION_ID: 'test-extension-id',
BROWSE_TERMINAL_BINARY: BASH, BROWSE_TERMINAL_BINARY: BASH,
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
@ -116,10 +117,25 @@ describe('terminal-agent: /ws gates', () => {
expect(resp.status).toBe(403); expect(resp.status).toBe(403);
}); });
test('rejects upgrades from a different extension id even with a granted cookie', async () => {
const cookie = 'wrong-extension-token-very-long-yes';
const granted = await grantToken(cookie);
expect(granted.status).toBe(200);
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://attacker-extension-id',
'Cookie': `gstack_pty=${cookie}`,
},
});
expect(resp.status).toBe(403);
expect(await resp.text()).toBe('forbidden origin');
});
test('rejects extension-Origin upgrades without a granted cookie', async () => { test('rejects extension-Origin upgrades without a granted cookie', async () => {
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, { const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: { headers: {
'Origin': 'chrome-extension://abc123', 'Origin': 'chrome-extension://test-extension-id',
'Cookie': 'gstack_pty=never-granted', 'Cookie': 'gstack_pty=never-granted',
}, },
}); });

View File

@ -33,43 +33,35 @@ function getBaseUrl() {
// ─── Auth Token Bootstrap ───────────────────────────────────── // ─── Auth Token Bootstrap ─────────────────────────────────────
async function loadAuthToken() { async function loadAuthToken() {
if (authToken) return;
// Get token from browse server /health endpoint (localhost-only, safe).
// Previously read from .auth.json in extension dir, but that breaks
// read-only .app bundles and codesigning.
const base = getBaseUrl();
if (!base) return;
try { try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); const data = await chrome.storage.local.get(['gstackAuthToken', 'port']);
if (resp.ok) { if (data.gstackAuthToken) authToken = data.gstackAuthToken;
const data = await resp.json(); if (data.port) serverPort = data.port;
if (data.token) authToken = data.token;
}
} catch (err) { } catch (err) {
console.error('[gstack bg] Failed to load auth token:', err.message); console.error('[gstack bg] Failed to load auth token from extension storage:', err.message);
} }
} }
// ─── Health Polling ──────────────────────────────────────────── // ─── Health Polling ────────────────────────────────────────────
async function checkHealth() { async function checkHealth() {
// BrowserManager can provision a fresh token/port after this service worker
// starts. Refresh storage before choosing the base URL so we do not keep an
// old token or the historical 34567 fallback forever.
await loadAuthToken();
const base = getBaseUrl(); const base = getBaseUrl();
if (!base) { if (!base) {
setDisconnected(); setDisconnected();
return; return;
} }
// Retry loading auth token if we don't have one yet
if (!authToken) await loadAuthToken();
try { try {
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setDisconnected(); return; } if (!resp.ok) { setDisconnected(); return; }
const data = await resp.json(); const data = await resp.json();
if (data.status === 'healthy') { if (data.status === 'healthy') {
// Always refresh auth token from /health — the server generates a new // Health is status-only. Auth is provisioned out-of-band into
// token on each restart, so the old one becomes stale. // chrome.storage by BrowserManager so /health never leaks root auth.
if (data.token) authToken = data.token;
// Forward chatEnabled so sidepanel can show/hide chat tab // Forward chatEnabled so sidepanel can show/hide chat tab
setConnected({ ...data, chatEnabled: !!data.chatEnabled }); setConnected({ ...data, chatEnabled: !!data.chatEnabled });
} else { } else {