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.
|
* 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.
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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({');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue