fix(browse): lock local auth to trusted extension

This commit is contained in:
Sina
2026-07-10 12:48:25 -07:00
parent 7c9df1c568
commit 7b3f391bbc
24 changed files with 925 additions and 147 deletions
+129 -40
View File
@@ -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';
@@ -24,6 +23,7 @@ import { TabSession, type RefEntry } from './tab-session';
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
import { withCdpSession } from './cdp-bridge';
import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot';
import { isTrustedGstackExtensionWorkerUrl } from './extension-identity';
/**
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
@@ -196,6 +196,8 @@ export class BrowserManager {
// ─── Headed State ────────────────────────────────────────
private connectionMode: 'launched' | 'headed' = 'launched';
private intentionalDisconnect = false;
/** Root auth is provisioned only into the loaded gstack extension's storage. */
private extensionAuthToken: string | null = null;
// ─── Tab Count Guardrail (D5 + Codex single-tab flag) ───────
// Idempotent threshold trackers: each guardrail fires exactly once per
@@ -317,6 +319,78 @@ export class BrowserManager {
return null;
}
/**
* Give the bundled extension its bearer without publishing it through a
* loopback HTTP endpoint. chrome.storage.session is isolated per extension
* ID and restricted to trusted extension contexts, unlike an Origin header,
* which any local caller can forge.
*/
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> => {
await worker.evaluate(async ({ storedToken, port }) => {
const chromeApi = (globalThis as any).chrome;
// storage.session defaults to trusted contexts, but set the access
// level explicitly so a future manifest/content-script change cannot
// silently expose the root bearer. The port is non-secret and remains
// in local storage for existing discovery behavior.
await chromeApi.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
await chromeApi.storage.session.set({ gstackAuthToken: storedToken });
await chromeApi.storage.local.remove('gstackAuthToken');
await chromeApi.storage.local.set({ port });
}, { storedToken: token, port: this.serverPort || 34567 });
};
// A component-baked extension may start after the browser context. Keep
// listening rather than falling back to the unsafe /health bootstrap.
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 {
console.warn('[browse] Extension service worker not ready; auth will be provisioned when it starts');
return;
}
}
if (!worker) 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 (!isTrustedGstackExtensionWorkerUrl(worker.url())) 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;
}
}
/**
* Set the proxy config applied to chromium.launch() in launch() and
* launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5
@@ -326,6 +400,10 @@ export class BrowserManager {
this.proxyConfig = cfg;
}
setExtensionAuthToken(token: string | undefined): void {
if (token) this.extensionAuthToken = token;
}
/**
* Get the ref map for external consumers (e.g., /refs endpoint).
*/
@@ -369,17 +447,44 @@ export class BrowserManager {
console.log(`[browse] Extensions loaded from: ${extensionsDir}`);
}
this.browser = await chromium.launch({
headless: useHeadless,
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
// on Linux root/CI/container, where the sandbox requires unprivileged user
// namespaces that aren't available.
chromiumSandbox: shouldEnableChromiumSandbox(),
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
});
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
if (extensionsDir) {
// Extensions do not attach to incognito contexts created by
// browser.newContext(). An empty userDataDir gives this off-screen mode a
// temporary persistent profile, so its real service worker can receive
// auth without sharing state with the user's headed GStack profile.
const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth');
this.context = await chromium.launchPersistentContext('', {
...contextOptions,
headless: false,
chromiumSandbox: shouldEnableChromiumSandbox(),
args: launchArgs,
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS,
});
this.browser = this.context.browser();
if (!this.browser) throw new Error('Persistent extension browser did not start');
} else {
this.browser = await chromium.launch({
headless: useHeadless,
// On Windows, Chromium's sandbox fails when the server is spawned through
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
// on Linux root/CI/container, where the sandbox requires unprivileged user
// namespaces that aren't available.
chromiumSandbox: shouldEnableChromiumSandbox(),
...(launchArgs.length > 0 ? { args: launchArgs } : {}),
...(this.proxyConfig ? { proxy: this.proxyConfig } : {}),
});
this.context = await this.browser.newContext(contextOptions);
}
// Chromium disconnect → distinguish clean user-quit from crash. Both
// events look identical to Playwright (one 'disconnected' fires), but
@@ -394,15 +499,6 @@ export class BrowserManager {
void handleChromiumDisconnect(this.browser);
});
const contextOptions: BrowserContextOptions = {
viewport: { width: this.currentViewport.width, height: this.currentViewport.height },
deviceScaleFactor: this.deviceScaleFactor,
};
if (this.customUserAgent) {
contextOptions.userAgent = this.customUserAgent;
}
this.context = await this.browser.newContext(contextOptions);
if (Object.keys(this.extraHeaders).length > 0) {
await this.context.setExtraHTTPHeaders(this.extraHeaders);
}
@@ -414,6 +510,7 @@ export class BrowserManager {
// faking those to fixed values flags more bot-like, not less (D7).
const { applyStealth } = await import('./stealth');
await applyStealth(this.context);
if (extensionsDir) await this.provisionExtensionAuth();
// Create first tab
await this.newTab();
@@ -460,22 +557,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 is provisioned into extension storage after the persistent
// context starts. Do not write a reusable token to a local file or
// return it from a public endpoint.
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');
}
}
@@ -604,6 +690,7 @@ export class BrowserManager {
// this headed path.
const { applyStealth } = await import('./stealth');
await applyStealth(this.context);
await this.provisionExtensionAuth(authToken);
// Inject visual indicator — subtle top-edge amber gradient
// Extension's content script handles the floating pill
@@ -1561,14 +1648,14 @@ 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.
// Auth is provisioned into extension storage after the context starts.
// /health deliberately remains public status-only.
console.log(`[browse] Handoff: loading extension from ${extensionPath}`);
} else {
console.log('[browse] Handoff: extension not found — headed mode without side panel');
}
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
const userDataDir = resolveChromiumProfile();
fs.mkdirSync(userDataDir, { recursive: true });
// T1: same automation-tell-stripping defaults as launchHeaded().
@@ -1614,6 +1701,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.
+17
View File
@@ -0,0 +1,17 @@
/**
* The public key in extension/manifest.json fixes this extension's Chrome ID.
* A display name is attacker-controlled metadata; this ID is the trust anchor
* used before the daemon provisions its root bearer to extension storage.
*/
export const GSTACK_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk';
export function isTrustedGstackExtensionWorkerUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return url.protocol === 'chrome-extension:'
&& url.hostname === GSTACK_EXTENSION_ID
&& url.pathname === '/background.js';
} catch {
return false;
}
}
+3 -3
View File
@@ -4,9 +4,9 @@
* Why this exists: WebSocket clients in browsers cannot send Authorization
* headers on the upgrade request. The terminal-agent's /ws upgrade therefore
* authenticates via cookie. We never put the PTY token in /health (codex
* outside-voice finding #2: /health already leaks AUTH_TOKEN to any
* localhost caller in headed mode; reusing that path for shell access would
* widen an existing bug). Instead, the extension does an authenticated
* outside-voice finding #2: the public health endpoint must never carry
* AUTH_TOKEN; reusing it for shell access would widen the trust boundary).
* Instead, the extension does an authenticated
* POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a
* short-lived cookie scoped to this terminal session and pushes it to the
* agent via loopback. The browser then carries the cookie automatically on
+21 -16
View File
@@ -1665,8 +1665,21 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// `authToken` (the cfg-derived value) explicitly.
const browserManager = cfgBrowserManager;
const isExpectedLoopbackHost = (host: string | null): boolean => {
if (!host) return false;
const normalized = host.toLowerCase();
return normalized === `127.0.0.1:${browsePort}` || normalized === `localhost:${browsePort}`;
};
const makeFetchHandler = (surface: Surface) => async (req: Request): Promise<Response> => {
// A loopback bind is not, by itself, a DNS-rebinding defense: after a
// rebinding a page can send Host: attacker.example to 127.0.0.1. Reject
// it before dispatch. Tunnel traffic is a separately authenticated surface.
if (surface === 'local' && req.headers.has('host') && !isExpectedLoopbackHost(req.headers.get('host'))) {
return new Response(JSON.stringify({ error: 'Forbidden host' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
const url = new URL(req.url);
// ─── Tunnel surface filter (runs before any route dispatch) ──
@@ -1777,14 +1790,8 @@ 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. Origin is caller-controlled; exposing
// the root bearer here lets a hostile extension mint a PTY session.
// 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.
@@ -2309,7 +2316,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle {
// Dual-listener model: binds a SECOND Bun.serve listener on an
// ephemeral 127.0.0.1 port dedicated to tunnel traffic, then points
// ngrok.forward() at THAT port. The existing local listener (which
// serves /health+token, /cookie-picker, /inspector/*, welcome, etc.)
// serves /health, /cookie-picker, /inspector/*, welcome, etc.)
// is never exposed to ngrok.
//
// Hard fail if the tunnel listener bind fails — NEVER fall back to
@@ -2794,11 +2801,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.
if (url.pathname === '/memory' && req.method === 'GET') {
const cookieToken = extractSseCookie(req);
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
@@ -2864,6 +2868,8 @@ export async function start() {
const port = await findPort();
LOCAL_LISTEN_PORT = port;
// The extension needs the real port before it starts and receives auth.
browserManager.serverPort = port;
// ─── Proxy config (D8 + codex F5) ──────────────────────────────
// BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5
@@ -2961,6 +2967,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)
@@ -3017,8 +3024,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 {
+9 -7
View File
@@ -26,11 +26,14 @@ import * as crypto from 'crypto';
import { writeSecureFile, mkdirSecure } from './file-permissions';
import { safeUnlink } from './error-handling';
import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control';
import { GSTACK_EXTENSION_ID } from './extension-identity';
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
// Fail closed. The static ID is derived from the bundled extension's manifest
// key, not from a caller-controlled Origin or display name.
const EXTENSION_ID = GSTACK_EXTENSION_ID;
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 +591,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 });
}
@@ -634,11 +637,10 @@ function buildServer() {
const sessionId = validTokens.get(token) ?? null;
const upgraded = server.upgrade(req, {
data: { cookie: token, sessionId },
// Echo the protocol back so the browser accepts the upgrade.
// Required when the client sends Sec-WebSocket-Protocol — the
// server MUST select one of the offered protocols, otherwise
// the browser closes the connection immediately.
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
// Bun negotiates the requested subprotocol itself. Manually adding
// Sec-WebSocket-Protocol duplicates the response header on current
// Bun, causing Chrome and standards-compliant clients to abort the
// otherwise-successful upgrade with code 1006.
});
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
}
@@ -0,0 +1,55 @@
/** Regression guards for the #1324 auth bootstrap redesign. */
import { describe, expect, test } from 'bun:test';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import { GSTACK_EXTENSION_ID, isTrustedGstackExtensionWorkerUrl } from '../src/extension-identity';
const ROOT = path.resolve(import.meta.dir, '../..');
const BACKGROUND_SRC = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf-8');
const BROWSER_MANAGER_SRC = fs.readFileSync(path.join(ROOT, 'browse/src/browser-manager.ts'), 'utf-8');
const MANIFEST = JSON.parse(fs.readFileSync(path.join(ROOT, 'extension/manifest.json'), 'utf-8'));
function sliceBetween(source: string, start: string, end: string): string {
const startIndex = source.indexOf(start);
const endIndex = source.indexOf(end, startIndex + start.length);
if (startIndex < 0 || endIndex < 0) throw new Error(`Could not find ${start} through ${end}`);
return source.slice(startIndex, endIndex);
}
describe('extension auth bootstrap', () => {
test('reads root auth from trusted session storage, never /health or local storage', () => {
const loadAuth = sliceBetween(BACKGROUND_SRC, 'async function loadAuthToken()', '// ─── Health Polling');
expect(loadAuth).toContain('chrome.storage.session.get');
expect(loadAuth).toContain("accessLevel: 'TRUSTED_CONTEXTS'");
expect(loadAuth).toContain('gstackAuthToken');
expect(loadAuth).not.toContain('/health');
});
test('pins auth provisioning to the manifest-derived extension ID', () => {
const manifestId = [...createHash('sha256')
.update(Buffer.from(MANIFEST.key, 'base64'))
.digest('hex')
.slice(0, 32)]
.map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16)))
.join('');
expect(manifestId).toBe(GSTACK_EXTENSION_ID);
expect(isTrustedGstackExtensionWorkerUrl(`chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)).toBe(true);
expect(isTrustedGstackExtensionWorkerUrl('chrome-extension://attacker/background.js')).toBe(false);
expect(BROWSER_MANAGER_SRC).toContain('provisionExtensionAuth');
expect(BROWSER_MANAGER_SRC).toContain('chromeApi.storage.session.set');
expect(BROWSER_MANAGER_SRC).toContain("accessLevel: 'TRUSTED_CONTEXTS'");
expect(BROWSER_MANAGER_SRC).toContain("chromeApi.storage.local.remove('gstackAuthToken')");
expect(BROWSER_MANAGER_SRC).toContain('isGstackExtensionWorker');
expect(BROWSER_MANAGER_SRC).toContain('isTrustedGstackExtensionWorkerUrl(worker.url())');
expect(BROWSER_MANAGER_SRC).toContain('if (extensionsDir) await this.provisionExtensionAuth()');
});
test('does not return root auth through the content-script port channel', () => {
const getPort = sliceBetween(BACKGROUND_SRC, "if (msg.type === 'getPort')", "if (msg.type === 'getTabState')");
expect(getPort).not.toContain('authToken');
const getToken = sliceBetween(BACKGROUND_SRC, "if (msg.type === 'getToken')", "if (msg.type === 'fetchRefs')");
expect(getToken).toContain('if (sender.tab)');
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* Opt-in real-Chromium receipt for the extension auth trust boundary.
* Run with: GSTACK_LIVE_BROWSER_TESTS=1 bun test browse/test/extension-auth-live.test.ts
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { BrowserManager } from '../src/browser-manager';
import { GSTACK_EXTENSION_ID } from '../src/extension-identity';
const RUN = process.env.GSTACK_LIVE_BROWSER_TESTS === '1' && process.platform === 'darwin';
const originalProfile = process.env.CHROMIUM_PROFILE;
const originalExtensionsDir = process.env.BROWSE_EXTENSIONS_DIR;
let temp = '';
let manager: BrowserManager | null = null;
let probeServer: ReturnType<typeof Bun.serve> | null = null;
describe.skipIf(!RUN)('extension auth live Chromium', () => {
beforeAll(() => {
temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extension-auth-live-'));
process.env.CHROMIUM_PROFILE = path.join(temp, 'chromium-profile');
probeServer = Bun.serve({
hostname: '127.0.0.1',
port: 0,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/page') return new Response('<!doctype html><title>probe</title>', { headers: { 'Content-Type': 'text/html' } });
if (url.pathname === '/health') return Response.json({ status: 'healthy', mode: 'headed', tabs: 1 });
if (url.pathname === '/probe') {
return Response.json({ authorization: req.headers.get('authorization') });
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
});
afterAll(async () => {
await manager?.close();
probeServer?.stop(true);
if (originalProfile === undefined) delete process.env.CHROMIUM_PROFILE;
else process.env.CHROMIUM_PROFILE = originalProfile;
if (originalExtensionsDir === undefined) delete process.env.BROWSE_EXTENSIONS_DIR;
else process.env.BROWSE_EXTENSIONS_DIR = originalExtensionsDir;
if (temp) fs.rmSync(temp, { recursive: true, force: true });
});
test('provisions and rotates auth for trusted pages while content scripts cannot read it', async () => {
const token = `live-${crypto.randomUUID()}`;
manager = new BrowserManager();
manager.serverPort = probeServer!.port;
await manager.launchHeaded(token);
const context = (manager as any).context;
const worker = context.serviceWorkers().find((candidate: any) => candidate.url() === `chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)
?? await context.waitForEvent('serviceworker', { timeout: 10_000 });
expect(worker.url()).toBe(`chrome-extension://${GSTACK_EXTENSION_ID}/background.js`);
const stored = await worker.evaluate(async () => {
const api = (globalThis as any).chrome.storage;
return {
session: await api.session.get('gstackAuthToken'),
local: await api.local.get('gstackAuthToken'),
};
});
expect(stored.session.gstackAuthToken).toBe(token);
expect(stored.local.gstackAuthToken).toBeUndefined();
const page = context.pages()[0] ?? await context.newPage();
await page.goto(`http://127.0.0.1:${probeServer!.port}/page`);
const contentRead = await worker.evaluate(async (pageUrl: string) => {
const chromeApi = (globalThis as any).chrome;
const [tab] = await chromeApi.tabs.query({ url: pageUrl });
const [injection] = await chromeApi.scripting.executeScript({
target: { tabId: tab.id },
func: async () => {
try {
const data = await (globalThis as any).chrome.storage.session.get('gstackAuthToken');
return { token: data.gstackAuthToken ?? null, error: null };
} catch (err: any) {
return { token: null, error: String(err?.message || err) };
}
},
});
return injection.result;
}, page.url());
expect(contentRead.token).toBeNull();
const authenticated = await worker.evaluate(async (port: number) => {
const chromeApi = (globalThis as any).chrome;
const { gstackAuthToken } = await chromeApi.storage.session.get('gstackAuthToken');
const resp = await fetch(`http://127.0.0.1:${port}/probe`, {
headers: { Authorization: `Bearer ${gstackAuthToken}` },
});
return resp.json();
}, probeServer!.port);
expect(authenticated.authorization).toBe(`Bearer ${token}`);
const rotated = `rotated-${crypto.randomUUID()}`;
await (manager as any).provisionExtensionAuth(rotated);
const rotatedStore = await worker.evaluate(async () => (
(globalThis as any).chrome.storage.session.get('gstackAuthToken')
));
expect(rotatedStore.gstackAuthToken).toBe(rotated);
// The off-screen extension mode is a separate supported launch path. It
// must use a persistent context too; browser.newContext() has no extension
// service worker and previously made this mode silently unauthenticated.
await manager.close();
process.env.BROWSE_EXTENSIONS_DIR = path.resolve(import.meta.dir, '../../extension');
const offscreenToken = `offscreen-${crypto.randomUUID()}`;
manager = new BrowserManager();
manager.serverPort = probeServer!.port;
manager.setExtensionAuthToken(offscreenToken);
await manager.launch();
const offscreenContext = (manager as any).context;
const offscreenWorker = offscreenContext.serviceWorkers().find((candidate: any) => candidate.url() === `chrome-extension://${GSTACK_EXTENSION_ID}/background.js`)
?? await offscreenContext.waitForEvent('serviceworker', { timeout: 10_000 });
const offscreenStore = await offscreenWorker.evaluate(async () => (
(globalThis as any).chrome.storage.session.get('gstackAuthToken')
));
expect(offscreenStore.gstackAuthToken).toBe(offscreenToken);
}, 60_000);
});
+15 -3
View File
@@ -94,15 +94,27 @@ 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 for a forged 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);
expect(body.token).toBeUndefined();
});
test('the leaked-health attack cannot mint a PTY session without a bearer', async () => {
const resp = await fetch(`${daemon.baseUrl}/pty-session`, { method: 'POST' });
expect(resp.status).toBe(401);
});
test('local routes reject a DNS-rebinding-style Host header', async () => {
const resp = await fetch(`${daemon.baseUrl}/health`, {
headers: { Host: `attacker.example:${daemon.port}` },
});
expect(resp.status).toBe(403);
expect((await resp.json() as any).error).toBe('Forbidden host');
});
test('GET /health without chrome-extension origin does NOT include token', async () => {
+3 -7
View File
@@ -22,14 +22,10 @@ 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('/health is public status-only and never bootstraps root auth', () => {
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("startsWith('chrome-extension://')");
});
// Test 1b: /health does not expose sensitive browsing state
+8 -7
View File
@@ -1405,22 +1405,23 @@ describe('sidebar auth race prevention', () => {
const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8');
const spSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8');
test('getPort response includes authToken (not just port + connected)', () => {
// The auth race: sidepanel calls getPort, gets {port, connected} but no token.
// All subsequent requests fail 401. Token must be in the getPort response.
test('getPort response never includes authToken', () => {
// Content scripts can call getPort. Root auth must remain behind getToken,
// which rejects content-script contexts.
const getPortHandler = bgSrc.slice(
bgSrc.indexOf("msg.type === 'getPort'"),
bgSrc.indexOf("msg.type === 'setPort'"),
);
expect(getPortHandler).toContain('token: authToken');
expect(getPortHandler).not.toContain('authToken');
});
test('tryConnect uses token from getPort response', () => {
// Sidepanel must pass resp.token to updateConnection, not null
test('tryConnect uses the extension-page-only getToken response', () => {
const start = spSrc.indexOf('function tryConnect()');
const end = spSrc.indexOf('\ntryConnect();', start); // top-level call after the function
const tryConnectFn = spSrc.slice(start, end);
expect(tryConnectFn).toContain('resp.token');
expect(tryConnectFn).toContain("type: 'getToken'");
expect(tryConnectFn).toContain('tokenResp.token');
expect(tryConnectFn).not.toContain('resp.token');
expect(tryConnectFn).not.toContain('updateConnection(url, null)');
});
});
+31 -29
View File
@@ -18,6 +18,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { GSTACK_EXTENSION_ID } from '../src/extension-identity';
const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts');
const BASH = '/bin/bash';
@@ -119,12 +120,25 @@ describe('terminal-agent: /ws gates', () => {
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://${GSTACK_EXTENSION_ID}`,
'Cookie': 'gstack_pty=never-granted',
},
});
expect(resp.status).toBe(401);
});
test('rejects a different extension ID even with a granted cookie', async () => {
const cookie = 'wrong-extension-token-very-long-yes';
expect((await grantToken(cookie)).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');
});
});
describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () => {
@@ -135,7 +149,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
'Cookie': `gstack_pty=${cookie}`,
},
} as any);
@@ -199,32 +213,20 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const token = 'sec-protocol-token-must-be-at-least-seventeen-chars';
await grantToken(token);
// We exercise the protocol path by raw-handshaking via fetch+Upgrade,
// because Bun's test-client WebSocket constructor doesn't propagate
// `protocols` cleanly when also passed `headers` (the constructor
// detects the third-arg form unreliably). Real browsers (Chromium)
// use the standard protocols arg fine — the server-side handler is
// identical either way, so this test still locks the load-bearing
// invariant: the agent accepts a token via Sec-WebSocket-Protocol
// and echoes the protocol back so a browser would accept the upgrade.
const handshakeKey = 'dGhlIHNhbXBsZSBub25jZQ==';
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
headers: {
'Connection': 'Upgrade',
'Upgrade': 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': handshakeKey,
'Sec-WebSocket-Protocol': `gstack-pty.${token}`,
'Origin': 'chrome-extension://test-extension-id',
},
// Use a standards-compliant client. Raw fetch+Upgrade cannot complete a
// WebSocket handshake under current Bun and previously hid a duplicated
// Sec-WebSocket-Protocol response header.
const protocol = token;
const probe = Bun.spawnSync(['node', '-e', `
const WebSocket = require('ws');
const ws = new WebSocket(process.argv[1], process.argv[2], { origin: process.argv[3] });
ws.once('open', () => { if (ws.protocol !== process.argv[2]) process.exit(2); ws.close(); process.exit(0); });
ws.once('error', error => { console.error(error); process.exit(1); });
setTimeout(() => process.exit(3), 4000);
`, `ws://127.0.0.1:${agentPort}/ws`, protocol, `chrome-extension://${GSTACK_EXTENSION_ID}`], {
stdout: 'pipe', stderr: 'pipe', timeout: 5000,
});
// 101 Switching Protocols + protocol echoed back = browser would accept.
// 401/403/anything else = browser would close the connection immediately
// (the bug we hit in manual dogfood).
expect(resp.status).toBe(101);
expect(resp.headers.get('upgrade')?.toLowerCase()).toBe('websocket');
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
expect(probe.exitCode, probe.stderr.toString()).toBe(0);
});
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
@@ -235,7 +237,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Protocol': 'gstack-pty.never-granted-token',
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
},
});
expect(resp.status).toBe(401);
@@ -247,7 +249,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, {
headers: {
'Origin': 'chrome-extension://test-extension-id',
'Origin': `chrome-extension://${GSTACK_EXTENSION_ID}`,
'Cookie': `gstack_pty=${cookie}`,
},
} as any);