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 <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.
This commit is contained in:
garrytan committed 2026-10-03 20:37:03 +00:00
1 parent 7372769dce
commit 85e8a7aaf5
7 files changed
+95 -30

No files matched your search

+1 -1
View File
@@ -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
+25
View File
@@ -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 <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://<id>` 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}`;
}
+6 -9
View File
@@ -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<string>([
]);
/**
* 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://<this id>`. 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;
}
+4 -9
View File
@@ -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://<id>. 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 });
}
+19 -1
View File
@@ -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({
+39 -9
View File
@@ -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<number> {
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<number> {
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);
+1 -1
View File
@@ -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');
});