mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
fix(browse): lock local auth to trusted extension
This commit is contained in:
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user