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
@@ -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);