From 85e8a7aaf5ea731d87edd220df53cda506e39e6b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 20:37:03 +0000 Subject: [PATCH] D1: pin the terminal-agent /ws Origin to the gstack extension (#1324) GSTACK_EXTENSION_ID moves to browse/src/extension-id.ts, shared by /extension-token and the terminal agent's /ws gate. /ws now returns 403 for every origin except the pinned extension. Forks override the ID with gstack-config set browse_extension_id , honored by both routes; the BROWSE_EXTENSION_ID env var is no longer read. Test sites that used fake extension IDs now use the pinned origin. --- browse/scripts/extension-id.ts | 2 +- browse/src/extension-id.ts | 25 ++++++++++ browse/src/server.ts | 15 +++--- browse/src/terminal-agent.ts | 13 ++--- browse/test/extension-token.test.ts | 20 +++++++- .../test/terminal-agent-integration.test.ts | 48 +++++++++++++++---- browse/test/terminal-agent.test.ts | 2 +- 7 files changed, 95 insertions(+), 30 deletions(-) create mode 100644 browse/src/extension-id.ts diff --git a/browse/scripts/extension-id.ts b/browse/scripts/extension-id.ts index cc9a7ba7f..2e6490665 100755 --- a/browse/scripts/extension-id.ts +++ b/browse/scripts/extension-id.ts @@ -8,7 +8,7 @@ * 0-9a-f to a-p (the "mpdecimal" alphabet). Pinning the public key in the * manifest pins the ID, which lets the browse server verify the Origin * header on POST /extension-token against a single known extension - * identity (GSTACK_EXTENSION_ID in browse/src/server.ts). + * identity (GSTACK_EXTENSION_ID in browse/src/extension-id.ts). * * The private half of the keypair is intentionally NOT in the repo — the * extension is loaded unpacked (or baked into Browser.app), so only the diff --git a/browse/src/extension-id.ts b/browse/src/extension-id.ts new file mode 100644 index 000000000..e921797c7 --- /dev/null +++ b/browse/src/extension-id.ts @@ -0,0 +1,25 @@ +/** + * The one extension origin allowed to reach browse's local control surfaces: + * POST /extension-token on the server and the terminal agent's /ws. + * + * GSTACK_EXTENSION_ID is derived from the "key" field in + * extension/manifest.json (first 16 bytes of SHA-256 of the DER public key, + * hex nibbles mapped 0-9a-f → a-p). Reproduce with: + * bun browse/scripts/extension-id.ts + * If the manifest keypair is ever rotated, update this constant in the same + * commit. + * + * Forks and self-built extensions override it with + * `gstack-config set browse_extension_id `. There is deliberately no + * environment override: a project's .env must never be able to widen it. + */ +import { readGstackConfigYamlKey } from './config'; + +export const GSTACK_EXTENSION_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep'; + +/** `chrome-extension://` for the pinned or configured extension. */ +export function allowedExtensionOrigin(): string { + const configured = readGstackConfigYamlKey('browse_extension_id'); + const id = configured && /^[a-p]{32}$/.test(configured) ? configured : GSTACK_EXTENSION_ID; + return `chrome-extension://${id}`; +} diff --git a/browse/src/server.ts b/browse/src/server.ts index b86bb7e69..57bfb63ca 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -46,6 +46,7 @@ import { } from './port-allocator'; import { acquireAgentStateLock, readAgentRecord, clearAgentRecord, isOurAgent, isAgentRecordLive, isAgentRecordGone, stopAgentByRecord, spawnTerminalAgent } from './terminal-agent-control'; import { isProcessAlive } from './error-handling'; +import { allowedExtensionOrigin } from './extension-id'; import { sanitizeBody, stripLoneSurrogates } from './sanitize'; import { startSocksBridge, testUpstream, type BridgeHandle } from './socks-bridge'; import { parseProxyConfig, toUpstreamConfig, ProxyConfigError } from './proxy-config'; @@ -296,15 +297,11 @@ const TUNNEL_PATHS = new Set([ ]); /** - * The gstack sidebar extension's pinned Chrome extension ID. Derived from - * the "key" field in extension/manifest.json (first 16 bytes of SHA-256 of - * the DER public key, hex nibbles mapped 0-9a-f → a-p). Reproduce with: - * bun browse/scripts/extension-id.ts - * POST /extension-token releases AUTH_TOKEN only to an Origin of exactly - * `chrome-extension://`. If the manifest keypair is ever rotated, - * this constant must be updated in the same commit. + * POST /extension-token releases AUTH_TOKEN only to an Origin of exactly the + * pinned extension, or the one set with `gstack-config set browse_extension_id` + * (browse/src/extension-id.ts, shared with the terminal agent's /ws gate). */ -export const GSTACK_EXTENSION_ID = 'dgbkdbjebeiblbajiilljmhjdpmiglep'; +export { GSTACK_EXTENSION_ID } from './extension-id'; /** * The extension-origin auth check (POST /extension-token): Origin is exactly @@ -320,7 +317,7 @@ function isPinnedExtensionRequest(req: Request): boolean { } catch (err) { if (!(err instanceof TypeError)) throw err; // TypeError = malformed Host } - const originOk = req.headers.get('origin') === `chrome-extension://${GSTACK_EXTENSION_ID}`; + const originOk = req.headers.get('origin') === allowedExtensionOrigin(); const hostOk = hostname === '127.0.0.1' || hostname === 'localhost'; return originOk && hostOk; } diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 88e89a719..7ce237a3e 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -29,6 +29,7 @@ import { safeUnlink } from './error-handling'; import { writeAgentRecord, readAgentRecord, clearAgentRecord, readAgentStartTime, acquireAgentStateLock } from './terminal-agent-control'; import { findAvailablePort } from './port-allocator'; import { extractPtyCookie } from './pty-session-cookie'; +import { allowedExtensionOrigin } from './extension-id'; import { createPtyLifecycle, disposePtyProcess, ptyCompletionReason, type PtyCompletion, type PtyLifecycle, @@ -43,7 +44,6 @@ const OWNER_WATCHDOG_MS = parseInt( process.env.GSTACK_TERMINAL_OWNER_WATCHDOG_MS || '15000', 10, ); -const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn /** * Per-boot generation identifier. Loopback /internal/* callers include @@ -620,8 +620,8 @@ function buildServer(port: number) { } // /ws — WebSocket upgrade. CRITICAL gates: - // (1) Origin must be chrome-extension://. Cross-site WS hijacking - // defense — required, not optional. + // (1) Origin must be exactly the pinned (or gstack-config configured) + // extension. Any other origin, other extensions included, is 403. // (2) Token must be in validTokens. We accept the token via two // transports for compatibility: // - Sec-WebSocket-Protocol (preferred for browsers — the only @@ -634,12 +634,7 @@ function buildServer(port: number) { // validTokens Set, populated by the parent server's // authenticated /pty-session → /internal/grant chain. if (url.pathname === '/ws') { - const origin = req.headers.get('origin') || ''; - const isExtensionOrigin = origin.startsWith('chrome-extension://'); - if (!isExtensionOrigin) { - return new Response('forbidden origin', { status: 403 }); - } - if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) { + if (req.headers.get('origin') !== allowedExtensionOrigin()) { return new Response('forbidden origin', { status: 403 }); } diff --git a/browse/test/extension-token.test.ts b/browse/test/extension-token.test.ts index cc66d6d80..87dcffd96 100644 --- a/browse/test/extension-token.test.ts +++ b/browse/test/extension-token.test.ts @@ -31,7 +31,7 @@ import { BrowserManager } from '../src/browser-manager'; import { resolveConfig } from '../src/config'; import { usePrivateStateRoot } from '../../test/helpers/private-state-root'; -usePrivateStateRoot(); +const privateRoot = usePrivateStateRoot(); const PINNED_ORIGIN = `chrome-extension://${GSTACK_EXTENSION_ID}`; const fixtureDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-extension-token-'))); @@ -168,6 +168,24 @@ describe('POST /extension-token pinned-origin bootstrap', () => { expect(JSON.stringify(body)).not.toContain('host'); }); + test('gstack-config browse_extension_id moves the token to the configured extension; the env var does not', async () => { + const fork = 'b'.repeat(32); + const cfg = makeConfig(); + const handle = buildFetchHandler(cfg); + const status = async (origin: string) => (await handle.fetchLocal(tokenRequest({ Origin: origin, Host: '127.0.0.1:34567' }), null)).status; + const saved = process.env.BROWSE_EXTENSION_ID; + process.env.BROWSE_EXTENSION_ID = fork; + try { + expect(await status(`chrome-extension://${fork}`)).toBe(403); + fs.writeFileSync(path.join(privateRoot.dir, 'config.yaml'), `browse_extension_id: ${fork}\n`); + expect(await status(`chrome-extension://${fork}`)).toBe(200); + expect(await status(PINNED_ORIGIN)).toBe(403); + } finally { + if (saved === undefined) delete process.env.BROWSE_EXTENSION_ID; + else process.env.BROWSE_EXTENSION_ID = saved; + } + }); + test('missing Origin → 403', async () => { const handle = buildFetchHandler(makeConfig()); const resp = await handle.fetchLocal(tokenRequest({ diff --git a/browse/test/terminal-agent-integration.test.ts b/browse/test/terminal-agent-integration.test.ts index f45b381fd..5b80cd057 100644 --- a/browse/test/terminal-agent-integration.test.ts +++ b/browse/test/terminal-agent-integration.test.ts @@ -18,9 +18,13 @@ 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-id'; const AGENT_SCRIPT = path.join(import.meta.dir, '../src/terminal-agent.ts'); const BASH = '/bin/bash'; +const EXT_ORIGIN = `chrome-extension://${GSTACK_EXTENSION_ID}`; +const ENV_EXTENSION_ID = 'a'.repeat(32); +const FORK_EXTENSION_ID = 'b'.repeat(32); let stateDir: string; let agentProc: any; @@ -73,6 +77,8 @@ beforeAll(() => { BROWSE_STATE_FILE: stateFile, BROWSE_SERVER_PORT: '0', // not used in this test BROWSE_TERMINAL_BINARY: terminalCli, + GSTACK_STATE_ROOT: stateDir, + BROWSE_EXTENSION_ID: ENV_EXTENSION_ID, }, stdio: ['ignore', 'pipe', 'pipe'], }); @@ -128,7 +134,7 @@ describe('terminal-agent: /internal/grant and /internal/revoke bearer auth', () function wsStatus(token: string): Promise { return fetch(`http://127.0.0.1:${agentPort}/ws`, { - headers: { 'Origin': 'chrome-extension://abc123', 'Cookie': `gstack_pty=${token}` }, + headers: { 'Origin': EXT_ORIGIN, 'Cookie': `gstack_pty=${token}` }, }).then((r) => r.status); } @@ -173,10 +179,34 @@ describe('terminal-agent: /ws gates', () => { expect(resp.status).toBe(403); }); + function wsOriginStatus(origin: string): Promise { + return fetch(`http://127.0.0.1:${agentPort}/ws`, { + headers: { Origin: origin, Cookie: 'gstack_pty=never-granted' }, + }).then((r) => r.status); + } + + test('rejects every extension Origin except the pinned one, even BROWSE_EXTENSION_ID from the environment (#1324)', async () => { + expect(await wsOriginStatus('chrome-extension://abc123')).toBe(403); + expect(await wsOriginStatus(`chrome-extension://${ENV_EXTENSION_ID}`)).toBe(403); + expect(await wsOriginStatus(EXT_ORIGIN)).toBe(401); + }); + + test('gstack-config browse_extension_id replaces the pinned ID for forks', async () => { + const config = path.join(stateDir, 'config.yaml'); + fs.writeFileSync(config, `browse_extension_id: ${FORK_EXTENSION_ID}\n`); + try { + expect(await wsOriginStatus(`chrome-extension://${FORK_EXTENSION_ID}`)).toBe(401); + expect(await wsOriginStatus(EXT_ORIGIN)).toBe(403); + } finally { + fs.rmSync(config, { force: true }); + } + expect(await wsOriginStatus(EXT_ORIGIN)).toBe(401); + }); + 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': EXT_ORIGIN, 'Cookie': 'gstack_pty=never-granted', }, }); @@ -192,7 +222,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': EXT_ORIGIN, 'Cookie': `gstack_pty=${cookie}`, }, } as any); @@ -274,7 +304,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () = 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': handshakeKey, 'Sec-WebSocket-Protocol': `gstack-pty.${token}`, - 'Origin': 'chrome-extension://test-extension-id', + 'Origin': EXT_ORIGIN, }, }); @@ -307,7 +337,7 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () = 'Sec-WebSocket-Version: 13\r\n' + 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' + `Sec-WebSocket-Protocol: gstack-pty.${token}\r\n` + - 'Origin: chrome-extension://test-extension-id\r\n' + + `Origin: ${EXT_ORIGIN}\r\n` + '\r\n'; let buf = ''; const socket = require('net').connect(agentPort, '127.0.0.1', () => socket.write(req)); @@ -333,7 +363,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': EXT_ORIGIN, }, }); expect(resp.status).toBe(401); @@ -345,7 +375,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': EXT_ORIGIN, 'Cookie': `gstack_pty=${cookie}`, }, } as any); @@ -393,7 +423,7 @@ describe('terminal-agent: owned PTY completion and restart', () => { const granted = await internal('grant', { token, sessionId }); expect(granted.status).toBe(200); const ws = new WebSocket(`ws://127.0.0.1:${agentPort}/ws`, { - headers: { Origin: 'chrome-extension://test-extension-id', Cookie: `gstack_pty=${token}` }, + headers: { Origin: EXT_ORIGIN, Cookie: `gstack_pty=${token}` }, } as any); const events: Array<{ type: string; [key: string]: any }> = []; let output = ''; @@ -472,7 +502,7 @@ describe('terminal-agent: owned PTY completion and restart', () => { expect(old.closed()).toBe(1000); expect(replacement.closed()).toBeNull(); const revoked = await fetch(`http://127.0.0.1:${agentPort}/ws`, { - headers: { Origin: 'chrome-extension://test-extension-id', Cookie: `gstack_pty=${oldToken}` }, + headers: { Origin: EXT_ORIGIN, Cookie: `gstack_pty=${oldToken}` }, }); expect(revoked.status).toBe(401); diff --git a/browse/test/terminal-agent.test.ts b/browse/test/terminal-agent.test.ts index c4ef37bc3..f066d1a23 100644 --- a/browse/test/terminal-agent.test.ts +++ b/browse/test/terminal-agent.test.ts @@ -117,7 +117,7 @@ describe('Source-level guard: terminal-agent', () => { // missing-origin attempt would surface the 401 cookie message and // signal to attackers that they need to forge a cookie. const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')")); - expect(wsHandler).toContain('chrome-extension://'); + expect(wsHandler).toContain('allowedExtensionOrigin()'); expect(wsHandler).toContain('forbidden origin'); });