mirror of https://github.com/garrytan/gstack.git
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:
parent
cab774cced
commit
e9d23ddd0f
|
|
@ -15,8 +15,7 @@
|
|||
* 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 { writeSecureFile, mkdirSecure } from './file-permissions';
|
||||
import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie, type Worker } from 'playwright';
|
||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||
import { emitActivity } from './activity';
|
||||
import { validateNavigationUrl } from './url-validation';
|
||||
|
|
@ -196,6 +195,8 @@ export class BrowserManager {
|
|||
// ─── Headed State ────────────────────────────────────────
|
||||
private connectionMode: 'launched' | 'headed' = 'launched';
|
||||
private intentionalDisconnect = false;
|
||||
private extensionAuthToken: string | null = null;
|
||||
private extensionId: string | null = null;
|
||||
|
||||
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
|
||||
// Idempotent threshold trackers: each guardrail fires exactly once per
|
||||
|
|
@ -317,6 +318,83 @@ export class BrowserManager {
|
|||
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
|
||||
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
|
||||
|
|
@ -326,6 +404,14 @@ export class BrowserManager {
|
|||
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).
|
||||
*/
|
||||
|
|
@ -451,22 +537,11 @@ export class BrowserManager {
|
|||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
}
|
||||
// Write auth token for extension bootstrap (still required even when
|
||||
// the extension is component-baked — it reads ~/.gstack/.auth.json at
|
||||
// startup to learn how to call the daemon).
|
||||
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
|
||||
// in .app bundles and breaks codesigning).
|
||||
// Auth bootstrap is provisioned into chrome.storage.local after the
|
||||
// persistent context starts. Do not write a readable file or expose the
|
||||
// token on /health; local processes can read/spoof those surfaces.
|
||||
if (authToken) {
|
||||
const fs = require('fs');
|
||||
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}`);
|
||||
}
|
||||
console.log('[browse] Extension auth will be provisioned via chrome.storage');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,6 +663,7 @@ export class BrowserManager {
|
|||
// synthesis — they're removing leaked automation tells.
|
||||
const { applyStealth } = await import('./stealth');
|
||||
await applyStealth(this.context);
|
||||
await this.provisionExtensionAuth(authToken);
|
||||
await this.context.addInitScript(() => {
|
||||
// Remove CDP runtime artifacts that automation detectors look for
|
||||
// cdc_ prefixed vars are injected by ChromeDriver/CDP
|
||||
|
|
@ -1560,8 +1636,8 @@ export class BrowserManager {
|
|||
if (extensionPath) {
|
||||
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
|
||||
launchArgs.push(`--load-extension=${extensionPath}`);
|
||||
// Auth token is served via /health endpoint now (no file write needed).
|
||||
// Extension reads token from /health on connect.
|
||||
// Extension auth is provisioned into chrome.storage.local after the
|
||||
// headed context starts. Do not use /health as a token bootstrap path.
|
||||
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
|
||||
} else {
|
||||
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 this.provisionExtensionAuth();
|
||||
|
||||
// Register disconnect handler on new browser. Same clean-vs-crash
|
||||
// discrimination as launch() / launchHeaded() above so a user-initiated
|
||||
// Cmd+Q after a handoff doesn't trigger gbd's restart loop.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ interface ServerState {
|
|||
serverPath: string;
|
||||
binaryVersion?: string;
|
||||
mode?: 'launched' | 'headed';
|
||||
extensionId?: string;
|
||||
/** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */
|
||||
configHash?: string;
|
||||
/** Xvfb child PID for cleanup on disconnect. */
|
||||
|
|
@ -1131,6 +1132,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||
stateFile: config.stateFile,
|
||||
serverPort: newState.port,
|
||||
cwd: config.projectDir,
|
||||
extraEnv: {
|
||||
BROWSE_EXTENSION_ID: newState.extensionId || '__gstack_extension_id_unavailable__',
|
||||
},
|
||||
});
|
||||
if (newPid) {
|
||||
console.log(`[browse] Terminal agent started (PID: ${newPid})`);
|
||||
|
|
@ -1223,6 +1227,9 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
|
|||
stateFile: config.stateFile,
|
||||
serverPort: respawned.port,
|
||||
cwd: config.projectDir,
|
||||
extraEnv: {
|
||||
BROWSE_EXTENSION_ID: respawned.extensionId || '__gstack_extension_id_unavailable__',
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.warn(`[browse] Supervisor: terminal-agent respawn failed: ${err?.message || err}`);
|
||||
|
|
|
|||
|
|
@ -1519,6 +1519,9 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
stateFile: cfg.config.stateFile,
|
||||
serverPort: cfg.browsePort,
|
||||
cwd: cfg.config.projectDir,
|
||||
extraEnv: {
|
||||
BROWSE_EXTENSION_ID: cfgBrowserManager.getExtensionId() || '__gstack_extension_id_unavailable__',
|
||||
},
|
||||
});
|
||||
if (pid) {
|
||||
console.log(`[browse] terminal-agent respawned by watchdog (PID: ${pid})`);
|
||||
|
|
@ -1744,14 +1747,11 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
|
|||
mode: browserManager.getConnectionMode(),
|
||||
uptime: Math.floor((Date.now() - startTime) / 1000),
|
||||
tabs: browserManager.getTabCount(),
|
||||
// Auth token for extension bootstrap. Safe: /health is localhost-only.
|
||||
// Previously served unconditionally, but that leaks the token if the
|
||||
// server is tunneled to the internet (ngrok, SSH tunnel).
|
||||
// In headed mode the server is always local, so return token unconditionally
|
||||
// (fixes Playwright Chromium extensions that don't send Origin header).
|
||||
...(browserManager.getConnectionMode() === 'headed' ||
|
||||
req.headers.get('origin')?.startsWith('chrome-extension://')
|
||||
? { token: authToken } : {}),
|
||||
// Public liveness/status only. Do NOT return the root auth token here.
|
||||
// Regression #1324: any local process can spoof Origin and use a
|
||||
// leaked root token to mint /pty-session shell access. The trusted
|
||||
// extension receives bootstrap auth out-of-band via chrome.storage
|
||||
// provisioning during headed launch instead.
|
||||
// The chat queue is gone — Terminal pane is the sole sidebar
|
||||
// surface. Keep `chatEnabled: false` so any older extension
|
||||
// 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).
|
||||
// Same auth model as /activity/stream and /inspector/events: Bearer header
|
||||
// OR view-only SSE-session cookie. Does NOT extend /health (which already
|
||||
// leaks AUTH_TOKEN to any localhost caller in headed mode — see TODOS.md
|
||||
// "Audit /health token distribution"); a separate endpoint with the
|
||||
// standard SSE auth keeps the future /health fix from cascading into the
|
||||
// sidebar footer poll.
|
||||
// OR view-only SSE-session cookie. Keep this separate from /health, which
|
||||
// is public status-only and must never carry AUTH_TOKEN (#1324).
|
||||
if (url.pathname === '/memory' && req.method === 'GET') {
|
||||
const cookieToken = extractSseCookie(req);
|
||||
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
|
||||
|
|
@ -2831,6 +2828,10 @@ export async function start() {
|
|||
|
||||
const port = await findPort();
|
||||
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) ──────────────────────────────
|
||||
// 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
|
||||
// AUTH_TOKEN const was deleted in v1.35.0.0.
|
||||
const envCfg = resolveConfigFromEnv();
|
||||
browserManager.setExtensionAuthToken(envCfg.authToken);
|
||||
|
||||
// Launch browser (headless or headed with extension)
|
||||
// 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'),
|
||||
binaryVersion: readVersionHash() || undefined,
|
||||
mode: browserManager.getConnectionMode(),
|
||||
extensionId: browserManager.getExtensionId() || undefined,
|
||||
// D2 daemon-mismatch detection: CLI computes the same hash from its
|
||||
// resolved flags and refuses if it differs from this stored value.
|
||||
...(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.renameSync(tmpFile, config.stateFile);
|
||||
|
||||
browserManager.serverPort = port;
|
||||
|
||||
// Navigate to welcome page if in headed mode and still on about:blank
|
||||
if (browserManager.getConnectionMode() === 'headed') {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port');
|
||||
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
|
||||
/**
|
||||
* Per-boot generation identifier. Loopback /internal/* callers include
|
||||
|
|
@ -588,7 +588,7 @@ function buildServer() {
|
|||
if (!isExtensionOrigin) {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -94,15 +94,16 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
|
|||
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`, {
|
||||
headers: { Origin: 'chrome-extension://test-extension-id' },
|
||||
});
|
||||
expect(resp.status).toBe(200);
|
||||
const body = await resp.json() as any;
|
||||
expect(body.status).toBeDefined();
|
||||
// Extension bootstrap — local listener delivers the token
|
||||
expect(body.token).toBe(daemon.token);
|
||||
// Regression #1324: Origin is caller-controlled, so /health must stay public
|
||||
// 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 () => {
|
||||
|
|
|
|||
|
|
@ -22,14 +22,15 @@ function sliceBetween(source: string, startMarker: string, endMarker: string): s
|
|||
}
|
||||
|
||||
describe('Server auth security', () => {
|
||||
// Test 1: /health serves token conditionally (headed mode or chrome extension only)
|
||||
test('/health serves token only in headed mode or to chrome extensions', () => {
|
||||
// Test 1: /health is public status only and must never bootstrap root auth.
|
||||
// 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'");
|
||||
// v1.35.0.0: AUTH_TOKEN const was deleted; factory uses cfg-derived authToken.
|
||||
// Token must be conditional, not unconditional
|
||||
expect(healthBlock).toContain('token: authToken');
|
||||
expect(healthBlock).toContain('headed');
|
||||
expect(healthBlock).toContain('chrome-extension://');
|
||||
expect(healthBlock).not.toContain('token: authToken');
|
||||
expect(healthBlock).not.toContain('browserManager.getConnectionMode() === \'headed\'');
|
||||
expect(healthBlock).not.toContain("startsWith('chrome-extension://')");
|
||||
});
|
||||
|
||||
// Test 1b: /health does not expose sensitive browsing state
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ describe('sidepanel-terminal.js: eager auto-connect + injection API', () => {
|
|||
test('forceRestart helper closes ws, disposes xterm, returns to IDLE', () => {
|
||||
expect(TERM_JS).toContain('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('STATE.IDLE');
|
||||
expect(fn).toContain('tryAutoConnect()');
|
||||
|
|
@ -222,8 +222,8 @@ describe('cli.ts: sidebar-agent is no longer spawned', () => {
|
|||
});
|
||||
|
||||
test('Terminal-agent spawn survives', () => {
|
||||
expect(CLI_SRC).toContain('terminal-agent.ts');
|
||||
expect(CLI_SRC).toMatch(/Bun\.spawn\(\['bun',\s*'run',\s*termAgentScript\]/);
|
||||
expect(CLI_SRC).toContain("from './terminal-agent-control'");
|
||||
expect(CLI_SRC).toContain('spawnTerminalAgent({');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ beforeAll(() => {
|
|||
...process.env,
|
||||
BROWSE_STATE_FILE: stateFile,
|
||||
BROWSE_SERVER_PORT: '0', // not used in this test
|
||||
BROWSE_EXTENSION_ID: 'test-extension-id',
|
||||
BROWSE_TERMINAL_BINARY: BASH,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
|
@ -116,10 +117,25 @@ describe('terminal-agent: /ws gates', () => {
|
|||
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 () => {
|
||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
||||
headers: {
|
||||
'Origin': 'chrome-extension://abc123',
|
||||
'Origin': 'chrome-extension://test-extension-id',
|
||||
'Cookie': 'gstack_pty=never-granted',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,43 +33,35 @@ function getBaseUrl() {
|
|||
// ─── Auth Token Bootstrap ─────────────────────────────────────
|
||||
|
||||
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 {
|
||||
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (data.token) authToken = data.token;
|
||||
}
|
||||
const data = await chrome.storage.local.get(['gstackAuthToken', 'port']);
|
||||
if (data.gstackAuthToken) authToken = data.gstackAuthToken;
|
||||
if (data.port) serverPort = data.port;
|
||||
} 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 ────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
if (!base) {
|
||||
setDisconnected();
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry loading auth token if we don't have one yet
|
||||
if (!authToken) await loadAuthToken();
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) });
|
||||
if (!resp.ok) { setDisconnected(); return; }
|
||||
const data = await resp.json();
|
||||
if (data.status === 'healthy') {
|
||||
// Always refresh auth token from /health — the server generates a new
|
||||
// token on each restart, so the old one becomes stale.
|
||||
if (data.token) authToken = data.token;
|
||||
// Health is status-only. Auth is provisioned out-of-band into
|
||||
// chrome.storage by BrowserManager so /health never leaks root auth.
|
||||
// Forward chatEnabled so sidepanel can show/hide chat tab
|
||||
setConnected({ ...data, chatEnabled: !!data.chatEnabled });
|
||||
} else {
|
||||
|
|
|
|||
Loading…
Reference in New Issue