mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
7372769dce
commit
85e8a7aaf5
7 files changed
+95
-30
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user