From 7b3f391bbc1e61106eb67c1aeed1eed874a22116 Mon Sep 17 00:00:00 2001 From: Sina Date: Fri, 10 Jul 2026 12:45:47 -0700 Subject: [PATCH 1/2] fix(browse): lock local auth to trusted extension --- ARCHITECTURE.md | 6 +- bin/gstack-browse-migrate.ts | 208 ++++++++++++++++++ bin/gstack-browse-repair.ts | 128 +++++++++++ browse/src/browser-manager.ts | 169 ++++++++++---- browse/src/extension-identity.ts | 17 ++ browse/src/pty-session-cookie.ts | 6 +- browse/src/server.ts | 37 ++-- browse/src/terminal-agent.ts | 16 +- browse/test/extension-auth-bootstrap.test.ts | 55 +++++ browse/test/extension-auth-live.test.ts | 124 +++++++++++ browse/test/pair-agent-e2e.test.ts | 18 +- browse/test/server-auth.test.ts | 10 +- browse/test/sidebar-ux.test.ts | 15 +- .../test/terminal-agent-integration.test.ts | 60 ++--- docs/REMOTE_BROWSER_ACCESS.md | 2 +- docs/designs/GSTACK_BROWSER_V0.md | 2 +- extension/background.js | 38 ++-- extension/manifest.json | 2 + extension/sidepanel-terminal.js | 6 +- extension/sidepanel.js | 20 +- scripts/build-app.sh | 3 +- setup | 21 ++ test/gstack-browse-migration.test.ts | 85 +++++++ test/setup-browse-security-migration.test.ts | 24 ++ 24 files changed, 925 insertions(+), 147 deletions(-) create mode 100644 bin/gstack-browse-migrate.ts create mode 100644 bin/gstack-browse-repair.ts create mode 100644 browse/src/extension-identity.ts create mode 100644 browse/test/extension-auth-bootstrap.test.ts create mode 100644 browse/test/extension-auth-live.test.ts create mode 100644 test/gstack-browse-migration.test.ts create mode 100644 test/setup-browse-security-migration.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3dba8f3ba1..7b4175bc19 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,7 +83,7 @@ The build writes `git rev-parse HEAD` to `browse/dist/.version`. On each CLI inv ### Localhost only -The HTTP server binds to `127.0.0.1`, not `0.0.0.0`. It's not reachable from the network. +The HTTP server binds to `127.0.0.1`, not `0.0.0.0`, and rejects unexpected Host headers to prevent DNS rebinding. `/health` is status-only and never returns credentials. BrowserManager provisions the root credential directly into `chrome.storage.session` for the manifest-key-pinned GStack extension; session storage is restricted to trusted extension contexts, so content scripts cannot read it. ### Dual-listener tunnel architecture (v1.6.0.0) @@ -91,14 +91,14 @@ When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a r The fix is **two HTTP listeners**, not one: -- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves bootstrap (`/health` with token delivery), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded. +- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves status-only `/health`, `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded. - **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s. ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't. | Endpoint | Local listener | Tunnel listener | Notes | |---|---|---|---| -| `GET /health` | public (no token unless headed/extension) | 404 | Token bootstrap for extension happens locally only | +| `GET /health` | public, status-only | 404 | Never returns the root token; extension auth is provisioned outside HTTP | | `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness | | `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent | | `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 | diff --git a/bin/gstack-browse-migrate.ts b/bin/gstack-browse-migrate.ts new file mode 100644 index 0000000000..7aea381bda --- /dev/null +++ b/bin/gstack-browse-migrate.ts @@ -0,0 +1,208 @@ +#!/usr/bin/env bun +/** + * Retire the pre-#1324 unpacked gstack browser extension from GStack's + * dedicated Chromium profile. + * + * The old extension's ID was derived from its installation path, so it cannot + * be a trust anchor. The current extension has a manifest key and a fixed ID. + * This tool only edits ~/.gstack/chromium-profile (or an explicit test/profile + * directory); it never touches a user's normal Chrome profile. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createHash } from 'crypto'; + +const TRUSTED_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk'; +const EXTENSION_NAME = 'gstack browse'; + +type Result = { + profile: string; + status: 'clean' | 'migrated' | 'deferred'; + legacyExtensionIds: string[]; + backup?: string; + reason?: string; +}; + +function usage(): never { + console.error('Usage: gstack-browse-migrate [--check|--apply] [--profile ] [--legacy-extension-path ] [--json]'); + process.exit(2); +} + +function parseArgs(argv: string[]) { + let mode: 'check' | 'apply' = 'check'; + let profile = process.env.CHROMIUM_PROFILE + || path.join(process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack'), 'chromium-profile'); + let json = false; + const legacyExtensionPaths: string[] = []; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--check') mode = 'check'; + else if (argv[i] === '--apply') mode = 'apply'; + else if (argv[i] === '--json') json = true; + else if (argv[i] === '--profile') { + profile = argv[++i] || usage(); + } else if (argv[i] === '--legacy-extension-path') { + legacyExtensionPaths.push(argv[++i] || usage()); + } else usage(); + } + return { mode, profile: path.resolve(profile), json, legacyExtensionPaths }; +} + +function writeAtomic(file: string, content: string): void { + const tmp = `${file}.gstack-migrate-${process.pid}.tmp`; + fs.writeFileSync(tmp, content, { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +function profileDirectories(profileRoot: string): string[] { + if (!fs.existsSync(profileRoot)) return []; + return fs.readdirSync(profileRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && (entry.name === 'Default' || entry.name.startsWith('Profile '))) + .map(entry => path.join(profileRoot, entry.name)); +} + +function findLegacyIds(preferences: any): string[] { + const settings = preferences?.extensions?.settings; + if (!settings || typeof settings !== 'object') return []; + return Object.entries(settings) + .filter(([id, value]: [string, any]) => id !== TRUSTED_EXTENSION_ID && value?.manifest?.name === EXTENSION_NAME) + .map(([id]) => id); +} + +/** + * Before manifest.key, Chromium generated an unpacked extension's ID from the + * absolute extension directory. The current manifest has a fixed key, but the + * old ID can still be derived from that same path during an upgrade. + */ +function legacyIdForExtensionPath(extensionPath: string): string[] { + const candidates = new Set([path.resolve(extensionPath)]); + try { candidates.add(fs.realpathSync(extensionPath)); } catch {} + return [...candidates].map(candidate => [...createHash('sha256').update(candidate).digest('hex').slice(0, 32)] + .map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16))) + .join('')); +} + +function storageLegacyIds(profileDir: string, extensionPaths: string[]): string[] { + const storageRoots = ['Local Extension Settings', 'Sync Extension Settings']; + const storedIds = new Set(); + for (const storageRoot of storageRoots) { + const root = path.join(profileDir, storageRoot); + if (!fs.existsSync(root)) continue; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) storedIds.add(entry.name); + } + } + const knownLegacyIds = extensionPaths.flatMap(legacyIdForExtensionPath); + return knownLegacyIds.filter(id => id !== TRUSTED_EXTENSION_ID && storedIds.has(id)); +} + +function output(result: Result, asJson: boolean): void { + if (asJson) console.log(JSON.stringify(result)); + else { + console.log(`[browse-migrate] ${result.status}: ${result.profile}`); + if (result.legacyExtensionIds.length) console.log(`[browse-migrate] legacy extension IDs: ${result.legacyExtensionIds.join(', ')}`); + if (result.backup) console.log(`[browse-migrate] backup: ${result.backup}`); + if (result.reason) console.log(`[browse-migrate] ${result.reason}`); + } +} + +/** Chromium normally represents SingletonLock as a dangling hostname-PID symlink. */ +function profileLockIsActive(lock: string): boolean { + let stat: fs.Stats; + try { + stat = fs.lstatSync(lock); + } catch (err: any) { + if (err?.code === 'ENOENT') return false; + // If lock state cannot be inspected, do not risk editing the profile. + return true; + } + if (!stat.isSymbolicLink()) return true; + + try { + const target = fs.readlinkSync(lock); + const match = target.match(/^(.*)-(\d+)$/); + if (!match || match[1] !== os.hostname()) return true; + const pid = Number(match[2]); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + try { + process.kill(pid, 0); + return true; + } catch (err: any) { + return err?.code !== 'ESRCH'; + } + } catch { + return true; + } +} + +function migrateProfile(profileRoot: string, mode: 'check' | 'apply', extensionPaths: string[]): Result[] { + const lock = path.join(profileRoot, 'SingletonLock'); + if (profileLockIsActive(lock)) { + return [{ profile: profileRoot, status: 'deferred', legacyExtensionIds: [], reason: 'browser profile is active; close GStack Browser and retry' }]; + } + + const results: Result[] = []; + for (const profileDir of profileDirectories(profileRoot)) { + const preferencesPath = path.join(profileDir, 'Preferences'); + const hasPreferences = fs.existsSync(preferencesPath); + let preferences: any = { extensions: { settings: {} } }; + if (hasPreferences) { + try { + preferences = JSON.parse(fs.readFileSync(preferencesPath, 'utf8')); + } catch { + results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: [], reason: 'Preferences is not valid JSON; left unchanged' }); + continue; + } + } + // Chrome may omit an unpacked extension from Preferences. In that case, + // derive the old ID from the upgraded extension's path and require a + // matching storage directory before acting. This remains narrowly scoped: + // unrelated extension IDs are never selected by a broad directory scan. + const legacyIds = [...new Set([ + ...findLegacyIds(preferences), + ...storageLegacyIds(profileDir, extensionPaths), + ])]; + if (!legacyIds.length) { + results.push({ profile: profileDir, status: 'clean', legacyExtensionIds: [] }); + continue; + } + if (mode === 'check') { + results.push({ profile: profileDir, status: 'deferred', legacyExtensionIds: legacyIds, reason: 'run with --apply to retire legacy extension state' }); + continue; + } + + const migrationRoot = path.join(profileRoot, '..', 'browser-migrations', new Date().toISOString().replace(/[:.]/g, '-')); + const backupDir = path.join(migrationRoot, path.basename(profileDir)); + fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + if (hasPreferences) fs.copyFileSync(preferencesPath, path.join(backupDir, 'Preferences')); + + if (hasPreferences) { + for (const id of legacyIds) delete preferences.extensions?.settings?.[id]; + writeAtomic(preferencesPath, `${JSON.stringify(preferences, null, 2)}\n`); + } + + // Extension storage is the only place the old design could retain its + // daemon credential. Move it aside instead of deleting it so rollback is + // possible without ever copying credentials into the new extension ID. + for (const storageRoot of ['Local Extension Settings', 'Sync Extension Settings']) { + for (const id of legacyIds) { + const source = path.join(profileDir, storageRoot, id); + if (!fs.existsSync(source)) continue; + const target = path.join(backupDir, storageRoot, id); + fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + fs.renameSync(source, target); + } + } + results.push({ profile: profileDir, status: 'migrated', legacyExtensionIds: legacyIds, backup: backupDir }); + } + return results.length ? results : [{ profile: profileRoot, status: 'clean', legacyExtensionIds: [] }]; +} + +const args = parseArgs(process.argv.slice(2)); +const results = migrateProfile(args.profile, args.mode, args.legacyExtensionPaths); +for (const result of results) output(result, args.json); +// Check mode uses "deferred" to report work without failing. Apply mode must +// fail for every deferred profile so the versioned updater never writes its +// done marker while legacy credential state remains untouched. +process.exit(args.mode === 'apply' && results.some(result => result.status === 'deferred') ? 3 : 0); diff --git a/bin/gstack-browse-repair.ts b/bin/gstack-browse-repair.ts new file mode 100644 index 0000000000..d7b41e43b5 --- /dev/null +++ b/bin/gstack-browse-repair.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env bun +/** Repair the Playwright browser cache used by gstack browse. */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const CACHE = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'); +const MAX_INSTALL_MS = 300_000; + +type BrowserSpec = { name: string; revision: string; browserVersion: string }; + +function fail(message: string): never { + console.error(`[browse-repair] ${message}`); + process.exit(1); +} + +function readSpec(name: string): BrowserSpec { + const file = path.join(ROOT, 'node_modules', 'playwright-core', 'browsers.json'); + const data = JSON.parse(fs.readFileSync(file, 'utf8')) as { browsers: BrowserSpec[] }; + const spec = data.browsers.find(browser => browser.name === name); + if (!spec) fail(`Playwright browser spec ${name} not found`); + return spec; +} + +function expectedExecutable(spec: BrowserSpec): string | null { + if (process.platform !== 'darwin' || process.arch !== 'arm64') return null; + if (spec.name === 'chromium') { + return path.join(CACHE, `chromium-${spec.revision}`, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'); + } + if (spec.name === 'chromium-headless-shell') { + return path.join(CACHE, `chromium_headless_shell-${spec.revision}`, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'); + } + return null; +} + +function installationDirectory(spec: BrowserSpec): string { + return spec.name === 'chromium' + ? path.join(CACHE, `chromium-${spec.revision}`) + : path.join(CACHE, `chromium_headless_shell-${spec.revision}`); +} + +function isHealthy(spec: BrowserSpec): boolean { + const executable = expectedExecutable(spec); + if (!executable || !fs.existsSync(executable)) return false; + if (!fs.existsSync(path.join(installationDirectory(spec), 'INSTALLATION_COMPLETE'))) return false; + const result = Bun.spawnSync([executable, '--version'], { stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + return result.exitCode === 0; +} + +function archiveUrl(spec: BrowserSpec): string | null { + if (process.platform !== 'darwin' || process.arch !== 'arm64') return null; + const archive = spec.name === 'chromium' + ? 'chrome-mac-arm64.zip' + : spec.name === 'chromium-headless-shell' + ? 'chrome-headless-shell-mac-arm64.zip' + : null; + return archive ? `https://storage.googleapis.com/chrome-for-testing-public/${spec.browserVersion}/mac-arm64/${archive}` : null; +} + +async function repairMacArtifact(spec: BrowserSpec): Promise { + const url = archiveUrl(spec); + const executable = expectedExecutable(spec); + if (!url || !executable) fail(`no direct repair path for ${process.platform}/${process.arch}`); + const installDir = installationDirectory(spec); + if (fs.existsSync(installDir)) { + const backup = `${installDir}.incomplete-${Date.now()}`; + fs.renameSync(installDir, backup); + console.error(`[browse-repair] preserved incomplete cache at ${backup}`); + } + fs.mkdirSync(installDir, { recursive: true, mode: 0o700 }); + const archive = path.join(os.tmpdir(), `gstack-${spec.name}-${spec.revision}-${process.pid}.zip`); + try { + // macOS ships curl, and its file-backed transfer avoids buffering a + // 150MB+ Chromium archive inside Bun. The earlier fetch + Bun.write path + // could spin indefinitely while materializing a large Response object. + const download = Bun.spawnSync([ + 'curl', '--fail', '--location', '--silent', '--show-error', + '--max-time', String(MAX_INSTALL_MS / 1000), '--output', archive, url, + ], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS + 5_000 }); + if (download.exitCode !== 0) { + fail(`download failed for ${spec.name}: ${download.stderr.toString().trim() || 'timed out'}`); + } + const unpack = Bun.spawnSync(['ditto', '-x', '-k', archive, installDir], { stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS }); + if (unpack.exitCode !== 0) fail(`could not unpack ${spec.name}: ${unpack.stderr.toString().trim()}`); + } finally { + try { fs.unlinkSync(archive); } catch {} + } + if (!expectedExecutable(spec) || !fs.existsSync(expectedExecutable(spec)!)) { + fail(`${spec.name} still failed its executable health check`); + } + // Playwright's registry only recognizes browser directories carrying this + // marker. Without it, a later installer/GC can discard a working repair. + fs.writeFileSync(path.join(installDir, 'INSTALLATION_COMPLETE'), '', { mode: 0o600 }); + if (!isHealthy(spec)) fail(`${spec.name} still failed its registry health check`); +} + +function repairViaPlaywright(): void { + const cli = path.join(ROOT, 'node_modules', 'playwright-core', 'cli.js'); + const result = Bun.spawnSync(['node', cli, 'install', 'chromium', 'chromium-headless-shell'], { + stdout: 'pipe', stderr: 'pipe', timeout: MAX_INSTALL_MS, + }); + if (result.exitCode !== 0) fail(`Playwright install failed: ${result.stderr.toString().trim() || 'timed out'}`); +} + +const repair = process.argv.slice(2).includes('--repair'); +// The direct executable health check below is intentionally macOS/Apple +// Silicon-specific. Other Playwright platforms use different archive layouts; +// defer their validation/install semantics to Playwright's own CLI rather than +// declaring a healthy cache broken on every setup run. +if (process.platform !== 'darwin' || process.arch !== 'arm64') { + if (repair) repairViaPlaywright(); + console.log('[browse-repair] Playwright browser repair delegated to Playwright'); + process.exit(0); +} +const specs = [readSpec('chromium'), readSpec('chromium-headless-shell')]; +const unhealthy = specs.filter(spec => !isHealthy(spec)); +if (!unhealthy.length) { + console.log('[browse-repair] Playwright browser cache is healthy'); + process.exit(0); +} +if (!repair) fail(`missing or unhealthy: ${unhealthy.map(spec => spec.name).join(', ')} (run with --repair)`); + +for (const spec of unhealthy) await repairMacArtifact(spec); + +const stillUnhealthy = specs.filter(spec => !isHealthy(spec)); +if (stillUnhealthy.length) fail(`repair incomplete: ${stillUnhealthy.map(spec => spec.name).join(', ')}`); +console.log('[browse-repair] Playwright browser cache repaired'); diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b51..fd3200c6eb 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -15,8 +15,7 @@ * restores state. Falls back to clean slate on any failure. */ -import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; -import { writeSecureFile, mkdirSecure } from './file-permissions'; +import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie, type Worker } from 'playwright'; import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; @@ -24,6 +23,7 @@ import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; import { withCdpSession } from './cdp-bridge'; import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; +import { isTrustedGstackExtensionWorkerUrl } from './extension-identity'; /** * Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that @@ -196,6 +196,8 @@ export class BrowserManager { // ─── Headed State ──────────────────────────────────────── private connectionMode: 'launched' | 'headed' = 'launched'; private intentionalDisconnect = false; + /** Root auth is provisioned only into the loaded gstack extension's storage. */ + private extensionAuthToken: string | null = null; // ─── Tab Count Guardrail (D5 + Codex single-tab flag) ─────── // Idempotent threshold trackers: each guardrail fires exactly once per @@ -317,6 +319,78 @@ export class BrowserManager { return null; } + /** + * Give the bundled extension its bearer without publishing it through a + * loopback HTTP endpoint. chrome.storage.session is isolated per extension + * ID and restricted to trusted extension contexts, unlike an Origin header, + * which any local caller can forge. + */ + private async provisionExtensionAuth(authToken?: string): Promise { + if (authToken) this.extensionAuthToken = authToken; + const token = authToken ?? this.extensionAuthToken; + if (!token || !this.context) return; + + const writeToken = async (worker: Worker): Promise => { + await worker.evaluate(async ({ storedToken, port }) => { + const chromeApi = (globalThis as any).chrome; + // storage.session defaults to trusted contexts, but set the access + // level explicitly so a future manifest/content-script change cannot + // silently expose the root bearer. The port is non-secret and remains + // in local storage for existing discovery behavior. + await chromeApi.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }); + await chromeApi.storage.session.set({ gstackAuthToken: storedToken }); + await chromeApi.storage.local.remove('gstackAuthToken'); + await chromeApi.storage.local.set({ port }); + }, { storedToken: token, port: this.serverPort || 34567 }); + }; + + // A component-baked extension may start after the browser context. Keep + // listening rather than falling back to the unsafe /health bootstrap. + this.context.on('serviceworker', (worker) => { + void (async () => { + if (!(await this.isGstackExtensionWorker(worker))) return; + await writeToken(worker); + })().catch((err: any) => { + console.warn(`[browse] Could not provision late extension auth storage: ${err.message}`); + }); + }); + + let worker: Worker | undefined; + for (const candidate of this.context.serviceWorkers()) { + if (await this.isGstackExtensionWorker(candidate)) { + worker = candidate; + break; + } + } + if (!worker) { + try { + const candidate = await this.context.waitForEvent('serviceworker', { timeout: 5000 }); + if (await this.isGstackExtensionWorker(candidate)) worker = candidate; + } catch { + console.warn('[browse] Extension service worker not ready; auth will be provisioned when it starts'); + return; + } + } + if (!worker) return; + + try { + await writeToken(worker); + } catch (err: any) { + console.warn(`[browse] Could not provision extension auth storage: ${err.message}`); + } + } + + private async isGstackExtensionWorker(worker: Worker): Promise { + if (!isTrustedGstackExtensionWorkerUrl(worker.url())) return false; + try { + const manifest = await worker.evaluate(() => (globalThis as any).chrome.runtime.getManifest?.()); + return manifest?.name === 'gstack browse' + && manifest?.background?.service_worker === 'background.js'; + } catch { + return false; + } + } + /** * Set the proxy config applied to chromium.launch() in launch() and * launchHeaded(). Called by server.ts at startup once the (optional) SOCKS5 @@ -326,6 +400,10 @@ export class BrowserManager { this.proxyConfig = cfg; } + setExtensionAuthToken(token: string | undefined): void { + if (token) this.extensionAuthToken = token; + } + /** * Get the ref map for external consumers (e.g., /refs endpoint). */ @@ -369,17 +447,44 @@ export class BrowserManager { console.log(`[browse] Extensions loaded from: ${extensionsDir}`); } - this.browser = await chromium.launch({ - headless: useHeadless, - // On Windows, Chromium's sandbox fails when the server is spawned through - // the Bun→Node process chain (GitHub #276). Disable it — local daemon - // browsing user-specified URLs has marginal sandbox benefit. Also disabled - // on Linux root/CI/container, where the sandbox requires unprivileged user - // namespaces that aren't available. - chromiumSandbox: shouldEnableChromiumSandbox(), - ...(launchArgs.length > 0 ? { args: launchArgs } : {}), - ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), - }); + const contextOptions: BrowserContextOptions = { + viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, + deviceScaleFactor: this.deviceScaleFactor, + }; + if (this.customUserAgent) { + contextOptions.userAgent = this.customUserAgent; + } + + if (extensionsDir) { + // Extensions do not attach to incognito contexts created by + // browser.newContext(). An empty userDataDir gives this off-screen mode a + // temporary persistent profile, so its real service worker can receive + // auth without sharing state with the user's headed GStack profile. + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); + this.context = await chromium.launchPersistentContext('', { + ...contextOptions, + headless: false, + chromiumSandbox: shouldEnableChromiumSandbox(), + args: launchArgs, + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), + ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS, + }); + this.browser = this.context.browser(); + if (!this.browser) throw new Error('Persistent extension browser did not start'); + } else { + this.browser = await chromium.launch({ + headless: useHeadless, + // On Windows, Chromium's sandbox fails when the server is spawned through + // the Bun→Node process chain (GitHub #276). Disable it — local daemon + // browsing user-specified URLs has marginal sandbox benefit. Also disabled + // on Linux root/CI/container, where the sandbox requires unprivileged user + // namespaces that aren't available. + chromiumSandbox: shouldEnableChromiumSandbox(), + ...(launchArgs.length > 0 ? { args: launchArgs } : {}), + ...(this.proxyConfig ? { proxy: this.proxyConfig } : {}), + }); + this.context = await this.browser.newContext(contextOptions); + } // Chromium disconnect → distinguish clean user-quit from crash. Both // events look identical to Playwright (one 'disconnected' fires), but @@ -394,15 +499,6 @@ export class BrowserManager { void handleChromiumDisconnect(this.browser); }); - const contextOptions: BrowserContextOptions = { - viewport: { width: this.currentViewport.width, height: this.currentViewport.height }, - deviceScaleFactor: this.deviceScaleFactor, - }; - if (this.customUserAgent) { - contextOptions.userAgent = this.customUserAgent; - } - this.context = await this.browser.newContext(contextOptions); - if (Object.keys(this.extraHeaders).length > 0) { await this.context.setExtraHTTPHeaders(this.extraHeaders); } @@ -414,6 +510,7 @@ export class BrowserManager { // faking those to fixed values flags more bot-like, not less (D7). const { applyStealth } = await import('./stealth'); await applyStealth(this.context); + if (extensionsDir) await this.provisionExtensionAuth(); // Create first tab await this.newTab(); @@ -460,22 +557,11 @@ export class BrowserManager { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); } - // Write auth token for extension bootstrap (still required even when - // the extension is component-baked — it reads ~/.gstack/.auth.json at - // startup to learn how to call the daemon). - // Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only - // in .app bundles and breaks codesigning). + // Auth is provisioned into extension storage after the persistent + // context starts. Do not write a reusable token to a local file or + // return it from a public endpoint. if (authToken) { - const fs = require('fs'); - const path = require('path'); - const gstackDir = path.join(process.env.HOME || '/tmp', '.gstack'); - mkdirSecure(gstackDir); - const authFile = path.join(gstackDir, '.auth.json'); - try { - writeSecureFile(authFile, JSON.stringify({ token: authToken, port: this.serverPort || 34567 })); - } catch (err: any) { - console.warn(`[browse] Could not write .auth.json: ${err.message}`); - } + console.log('[browse] Extension auth will be provisioned via chrome.storage'); } } @@ -604,6 +690,7 @@ export class BrowserManager { // this headed path. const { applyStealth } = await import('./stealth'); await applyStealth(this.context); + await this.provisionExtensionAuth(authToken); // Inject visual indicator — subtle top-edge amber gradient // Extension's content script handles the floating pill @@ -1561,14 +1648,14 @@ export class BrowserManager { if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`); - // Auth token is served via /health endpoint now (no file write needed). - // Extension reads token from /health on connect. + // Auth is provisioned into extension storage after the context starts. + // /health deliberately remains public status-only. console.log(`[browse] Handoff: loading extension from ${extensionPath}`); } else { console.log('[browse] Handoff: extension not found — headed mode without side panel'); } - const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile'); + const userDataDir = resolveChromiumProfile(); fs.mkdirSync(userDataDir, { recursive: true }); // T1: same automation-tell-stripping defaults as launchHeaded(). @@ -1614,6 +1701,8 @@ export class BrowserManager { await newContext.setExtraHTTPHeaders(this.extraHeaders); } + await this.provisionExtensionAuth(); + // Register disconnect handler on new browser. Same clean-vs-crash // discrimination as launch() / launchHeaded() above so a user-initiated // Cmd+Q after a handoff doesn't trigger gbd's restart loop. diff --git a/browse/src/extension-identity.ts b/browse/src/extension-identity.ts new file mode 100644 index 0000000000..a6a5bc8d60 --- /dev/null +++ b/browse/src/extension-identity.ts @@ -0,0 +1,17 @@ +/** + * The public key in extension/manifest.json fixes this extension's Chrome ID. + * A display name is attacker-controlled metadata; this ID is the trust anchor + * used before the daemon provisions its root bearer to extension storage. + */ +export const GSTACK_EXTENSION_ID = 'hjcdllcckghjebjopehjhplcilonljjk'; + +export function isTrustedGstackExtensionWorkerUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return url.protocol === 'chrome-extension:' + && url.hostname === GSTACK_EXTENSION_ID + && url.pathname === '/background.js'; + } catch { + return false; + } +} diff --git a/browse/src/pty-session-cookie.ts b/browse/src/pty-session-cookie.ts index 8871fe471b..014ffe1484 100644 --- a/browse/src/pty-session-cookie.ts +++ b/browse/src/pty-session-cookie.ts @@ -4,9 +4,9 @@ * Why this exists: WebSocket clients in browsers cannot send Authorization * headers on the upgrade request. The terminal-agent's /ws upgrade therefore * authenticates via cookie. We never put the PTY token in /health (codex - * outside-voice finding #2: /health already leaks AUTH_TOKEN to any - * localhost caller in headed mode; reusing that path for shell access would - * widen an existing bug). Instead, the extension does an authenticated + * outside-voice finding #2: the public health endpoint must never carry + * AUTH_TOKEN; reusing it for shell access would widen the trust boundary). + * Instead, the extension does an authenticated * POST /pty-session with the bootstrap AUTH_TOKEN; the server mints a * short-lived cookie scoped to this terminal session and pushes it to the * agent via loopback. The browser then carries the cookie automatically on diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acce..bab58d5b0d 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1665,8 +1665,21 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // `authToken` (the cfg-derived value) explicitly. const browserManager = cfgBrowserManager; + const isExpectedLoopbackHost = (host: string | null): boolean => { + if (!host) return false; + const normalized = host.toLowerCase(); + return normalized === `127.0.0.1:${browsePort}` || normalized === `localhost:${browsePort}`; + }; const makeFetchHandler = (surface: Surface) => async (req: Request): Promise => { + // A loopback bind is not, by itself, a DNS-rebinding defense: after a + // rebinding a page can send Host: attacker.example to 127.0.0.1. Reject + // it before dispatch. Tunnel traffic is a separately authenticated surface. + if (surface === 'local' && req.headers.has('host') && !isExpectedLoopbackHost(req.headers.get('host'))) { + return new Response(JSON.stringify({ error: 'Forbidden host' }), { + status: 403, headers: { 'Content-Type': 'application/json' }, + }); + } const url = new URL(req.url); // ─── Tunnel surface filter (runs before any route dispatch) ── @@ -1777,14 +1790,8 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { mode: browserManager.getConnectionMode(), uptime: Math.floor((Date.now() - startTime) / 1000), tabs: browserManager.getTabCount(), - // Auth token for extension bootstrap. Safe: /health is localhost-only. - // Previously served unconditionally, but that leaks the token if the - // server is tunneled to the internet (ngrok, SSH tunnel). - // In headed mode the server is always local, so return token unconditionally - // (fixes Playwright Chromium extensions that don't send Origin header). - ...(browserManager.getConnectionMode() === 'headed' || - req.headers.get('origin')?.startsWith('chrome-extension://') - ? { token: authToken } : {}), + // Public liveness/status only. Origin is caller-controlled; exposing + // the root bearer here lets a hostile extension mint a PTY session. // The chat queue is gone — Terminal pane is the sole sidebar // surface. Keep `chatEnabled: false` so any older extension // build still treats the chat input as disabled. @@ -2309,7 +2316,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // Dual-listener model: binds a SECOND Bun.serve listener on an // ephemeral 127.0.0.1 port dedicated to tunnel traffic, then points // ngrok.forward() at THAT port. The existing local listener (which - // serves /health+token, /cookie-picker, /inspector/*, welcome, etc.) + // serves /health, /cookie-picker, /inspector/*, welcome, etc.) // is never exposed to ngrok. // // Hard fail if the tunnel listener bind fails — NEVER fall back to @@ -2794,11 +2801,8 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { // GET /memory — diagnostic snapshot (auth required, does NOT reset idle). // Same auth model as /activity/stream and /inspector/events: Bearer header - // OR view-only SSE-session cookie. Does NOT extend /health (which already - // leaks AUTH_TOKEN to any localhost caller in headed mode — see TODOS.md - // "Audit /health token distribution"); a separate endpoint with the - // standard SSE auth keeps the future /health fix from cascading into the - // sidebar footer poll. + // OR view-only SSE-session cookie. Keep this separate from /health, + // which is public status-only and must never carry AUTH_TOKEN. if (url.pathname === '/memory' && req.method === 'GET') { const cookieToken = extractSseCookie(req); if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) { @@ -2864,6 +2868,8 @@ export async function start() { const port = await findPort(); LOCAL_LISTEN_PORT = port; + // The extension needs the real port before it starts and receives auth. + browserManager.serverPort = port; // ─── Proxy config (D8 + codex F5) ────────────────────────────── // BROWSE_PROXY_URL is set by the CLI when --proxy was passed. For SOCKS5 @@ -2961,6 +2967,7 @@ export async function start() { // write so all consumers see the same value. v1.34.x's module-level // AUTH_TOKEN const was deleted in v1.35.0.0. const envCfg = resolveConfigFromEnv(); + browserManager.setExtensionAuthToken(envCfg.authToken); // Launch browser (headless or headed with extension) // BROWSE_HEADLESS_SKIP=1 skips browser launch entirely (for HTTP-only testing) @@ -3017,8 +3024,6 @@ export async function start() { fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, config.stateFile); - browserManager.serverPort = port; - // Navigate to welcome page if in headed mode and still on about:blank if (browserManager.getConnectionMode() === 'headed') { try { diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 2e39d99e40..457be09ae1 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -26,11 +26,14 @@ import * as crypto from 'crypto'; import { writeSecureFile, mkdirSecure } from './file-permissions'; import { safeUnlink } from './error-handling'; import { writeAgentRecord, clearAgentRecord } from './terminal-agent-control'; +import { GSTACK_EXTENSION_ID } from './extension-identity'; const STATE_FILE = process.env.BROWSE_STATE_FILE || path.join(process.env.HOME || '/tmp', '.gstack', 'browse.json'); const PORT_FILE = path.join(path.dirname(STATE_FILE), 'terminal-port'); const BROWSE_SERVER_PORT = parseInt(process.env.BROWSE_SERVER_PORT || '0', 10); -const EXTENSION_ID = process.env.BROWSE_EXTENSION_ID || ''; // optional: tighten Origin check +// Fail closed. The static ID is derived from the bundled extension's manifest +// key, not from a caller-controlled Origin or display name. +const EXTENSION_ID = GSTACK_EXTENSION_ID; const INTERNAL_TOKEN = crypto.randomBytes(32).toString('base64url'); // shared with parent server via env at spawn /** * Per-boot generation identifier. Loopback /internal/* callers include @@ -588,7 +591,7 @@ function buildServer() { if (!isExtensionOrigin) { return new Response('forbidden origin', { status: 403 }); } - if (EXTENSION_ID && origin !== `chrome-extension://${EXTENSION_ID}`) { + if (origin !== `chrome-extension://${EXTENSION_ID}`) { return new Response('forbidden origin', { status: 403 }); } @@ -634,11 +637,10 @@ function buildServer() { const sessionId = validTokens.get(token) ?? null; const upgraded = server.upgrade(req, { data: { cookie: token, sessionId }, - // Echo the protocol back so the browser accepts the upgrade. - // Required when the client sends Sec-WebSocket-Protocol — the - // server MUST select one of the offered protocols, otherwise - // the browser closes the connection immediately. - ...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}), + // Bun negotiates the requested subprotocol itself. Manually adding + // Sec-WebSocket-Protocol duplicates the response header on current + // Bun, causing Chrome and standards-compliant clients to abort the + // otherwise-successful upgrade with code 1006. }); return upgraded ? undefined : new Response('upgrade failed', { status: 500 }); } diff --git a/browse/test/extension-auth-bootstrap.test.ts b/browse/test/extension-auth-bootstrap.test.ts new file mode 100644 index 0000000000..753ed41872 --- /dev/null +++ b/browse/test/extension-auth-bootstrap.test.ts @@ -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)'); + }); +}); diff --git a/browse/test/extension-auth-live.test.ts b/browse/test/extension-auth-live.test.ts new file mode 100644 index 0000000000..80494ace10 --- /dev/null +++ b/browse/test/extension-auth-live.test.ts @@ -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 | 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('probe', { 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); +}); diff --git a/browse/test/pair-agent-e2e.test.ts b/browse/test/pair-agent-e2e.test.ts index 921ae48160..092964000a 100644 --- a/browse/test/pair-agent-e2e.test.ts +++ b/browse/test/pair-agent-e2e.test.ts @@ -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 () => { diff --git a/browse/test/server-auth.test.ts b/browse/test/server-auth.test.ts index 2469a121b3..3e61f12cc5 100644 --- a/browse/test/server-auth.test.ts +++ b/browse/test/server-auth.test.ts @@ -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 diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index 74ced5efd0..e642d99989 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -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)'); }); }); diff --git a/browse/test/terminal-agent-integration.test.ts b/browse/test/terminal-agent-integration.test.ts index cdcbe8de56..d58318f0b2 100644 --- a/browse/test/terminal-agent-integration.test.ts +++ b/browse/test/terminal-agent-integration.test.ts @@ -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); diff --git a/docs/REMOTE_BROWSER_ACCESS.md b/docs/REMOTE_BROWSER_ACCESS.md index 88dc30bb2a..0cec8334c5 100644 --- a/docs/REMOTE_BROWSER_ACCESS.md +++ b/docs/REMOTE_BROWSER_ACCESS.md @@ -56,7 +56,7 @@ All command endpoints require a Bearer token: Authorization: Bearer gsk_sess_... ``` -`/connect` is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. `/health` is unauthenticated on the local listener (bootstrap) but does NOT exist on the tunnel listener (404). +`/connect` is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. `/health` is an unauthenticated, status-only liveness endpoint on the local listener and does NOT exist on the tunnel listener (404); it never returns the root credential. SSE endpoints (`/activity/stream`, `/inspector/events`) accept either a Bearer token or the HttpOnly `gstack_sse` cookie (minted via `POST /sse-session`, 30-minute TTL, stream-scope only — cannot be used against `/command`). As of v1.6.0.0 the `?token=` query-string auth is no longer accepted. diff --git a/docs/designs/GSTACK_BROWSER_V0.md b/docs/designs/GSTACK_BROWSER_V0.md index 7539336ad3..72be21e669 100644 --- a/docs/designs/GSTACK_BROWSER_V0.md +++ b/docs/designs/GSTACK_BROWSER_V0.md @@ -319,7 +319,7 @@ From DESIGN.md: | DMG packaging | **SHIPPED** | 189MB compressed | | `GSTACK_CHROMIUM_PATH` | **SHIPPED** | Custom Chromium binary support | | `BROWSE_EXTENSIONS_DIR` | **SHIPPED** | Extension path override | -| Auth via `/health` | **SHIPPED** | Replaces .auth.json file approach, auto-refreshes on server restart | +| Auth via trusted extension session storage | **SHIPPED** | Fixed extension identity, content-script isolation, auto-refresh on server restart | | Build script | **SHIPPED** | `scripts/build-app.sh` | | Model routing | **SHIPPED** | Sonnet for actions, Opus for analysis (`pickSidebarModel`) | | Debug logging | **SHIPPED** | 40+ silent catches → prefixed console logging across 4 files | diff --git a/extension/background.js b/extension/background.js index d0abe63280..db49534ee9 100644 --- a/extension/background.js +++ b/extension/background.js @@ -33,43 +33,41 @@ function getBaseUrl() { // ─── Auth Token Bootstrap ───────────────────────────────────── async function loadAuthToken() { - if (authToken) return; - // Get token from browse server /health endpoint (localhost-only, safe). - // Previously read from .auth.json in extension dir, but that breaks - // read-only .app bundles and codesigning. - const base = getBaseUrl(); - if (!base) return; try { - const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); - if (resp.ok) { - const data = await resp.json(); - if (data.token) authToken = data.token; - } + // Session storage is restricted to trusted extension contexts. Content + // scripts run in untrusted contexts and cannot read the root bearer even + // though they legitimately use chrome.storage.local for non-secret state. + await chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' }); + const session = await chrome.storage.session.get('gstackAuthToken'); + const local = await chrome.storage.local.get('port'); + if (session.gstackAuthToken) authToken = session.gstackAuthToken; + if (local.port) serverPort = local.port; + // Clean up credentials written by prerelease builds of this migration. + await chrome.storage.local.remove('gstackAuthToken'); } catch (err) { - console.error('[gstack bg] Failed to load auth token:', err.message); + console.error('[gstack bg] Failed to load auth token from trusted extension storage:', err.message); } } // ─── Health Polling ──────────────────────────────────────────── async function checkHealth() { + // Refresh before choosing the target: BrowserManager writes a new token and + // port whenever the daemon restarts. + await loadAuthToken(); const base = getBaseUrl(); if (!base) { setDisconnected(); return; } - // Retry loading auth token if we don't have one yet - if (!authToken) await loadAuthToken(); - try { const resp = await fetch(`${base}/health`, { signal: AbortSignal.timeout(3000) }); if (!resp.ok) { setDisconnected(); return; } const data = await resp.json(); if (data.status === 'healthy') { - // Always refresh auth token from /health — the server generates a new - // token on each restart, so the old one becomes stale. - if (data.token) authToken = data.token; + // /health is intentionally status-only. Auth is delivered through the + // extension's isolated storage, not a caller-controlled HTTP request. // Forward chatEnabled so sidepanel can show/hide chat tab setConnected({ ...data, chatEnabled: !!data.chatEnabled }); } else { @@ -299,7 +297,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } if (msg.type === 'getPort') { - sendResponse({ port: serverPort, connected: isConnected, token: authToken }); + // Content scripts can query the port for UI behavior. Never include a + // credential here; only extension pages may request it via getToken. + sendResponse({ port: serverPort, connected: isConnected }); return true; } diff --git a/extension/manifest.json b/extension/manifest.json index 9625626464..231fdfa78f 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -2,7 +2,9 @@ "manifest_version": 3, "name": "gstack browse", "version": "0.1.0", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoXbtKYtvKA+STN/yRtOWb5ibKZXhHyTtiITl6K1qquF0g4Sd0zFO4LwmkGdzBCTUBLyVESeqjCLpP0WGJ0MS/g/YLMtmzLXWa2Bf/TNvrKIG/TxCfXLWDvdKayhS+lugLUX+tbrhRQiJ96Zoe2cFI0EXHa62mcLL9sNAwqmYNJAXK3uCJlveNa0NpeD8Jjmt3l2S9sjypVvz9JZmHnsgZbbK18VM3V65GpBs2BB/gzS8UDQbvR1vcrA5o9FHjgF0+NLrryNfyCJjm3YAHYJFKVda7uSHr4FlUZXl+IL9YynqODgx4Pp4kiY7bFCI7l9zzJIUkWnorskwzD3ziNUbJQIDAQAB", "description": "Live activity feed and @ref overlays for gstack browse", + "externally_connectable": { "ids": [] }, "permissions": ["sidePanel", "storage", "activeTab", "scripting", "tabs"], "host_permissions": ["http://127.0.0.1:*/", "ws://127.0.0.1:*/"], "action": { diff --git a/extension/sidepanel-terminal.js b/extension/sidepanel-terminal.js index e6287abcae..d97f1b8def 100644 --- a/extension/sidepanel-terminal.js +++ b/extension/sidepanel-terminal.js @@ -305,7 +305,7 @@ setState(STATE.LIVE); ensureXterm(); nextBinaryIsReplay = false; - ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]); + ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]); ws.binaryType = 'arraybuffer'; ws.addEventListener('open', () => { @@ -599,7 +599,7 @@ // SameSite=Strict don't survive the jump from server.ts:34567 to the // agent's random port from a chrome-extension origin, so cookies // alone weren't reliable. - ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${attachToken}`]); + ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [attachToken]); ws.binaryType = 'arraybuffer'; ws.addEventListener('open', () => { @@ -821,7 +821,7 @@ setState(STATE.LIVE); ensureXterm(); - ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [`gstack-pty.${token}`]); + ws = new WebSocket(`ws://127.0.0.1:${terminalPort}/ws`, [token]); ws.binaryType = 'arraybuffer'; ws.addEventListener('open', () => { diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 5856ebdfb8..0b9c61c7d7 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1281,13 +1281,19 @@ async function tryConnect() { const port = resp.port || 34567; - // Step 2: If background says connected + has token, use that - if (resp.port && resp.connected && resp.token) { + // Step 2: Background owns the port; request the token through the separate + // extension-page-only channel. getPort intentionally never returns it. + const tokenResp = await new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'getToken' }, (r) => { + resolve(r || {}); + }); + }); + if (resp.port && resp.connected && tokenResp.token) { setLoadingStatus( `Server found on port ${port}, connecting...`, `token: yes\nStarting SSE + chat polling...` ); - updateConnection(`http://127.0.0.1:${port}`, resp.token); + updateConnection(`http://127.0.0.1:${port}`, tokenResp.token); return; } @@ -1304,12 +1310,12 @@ async function tryConnect() { }); if (healthResp.ok) { const data = await healthResp.json(); - if (data.status === 'healthy' && data.token) { + if (data.status === 'healthy' && tokenResp.token) { setLoadingStatus( `Server healthy on port ${port}, connecting...`, - `token: yes (from /health)\nStarting SSE + activity feed...` + `token: yes (from extension storage)\nStarting SSE + activity feed...` ); - updateConnection(`http://127.0.0.1:${port}`, data.token); + updateConnection(`http://127.0.0.1:${port}`, tokenResp.token); // The SEC shield used to drive off /health.security via the chat // path's classifier; with the chat path ripped, the indicator is // not driven yet. Leaving the shield element hidden by default. @@ -1317,7 +1323,7 @@ async function tryConnect() { } setLoadingStatus( `Server responded but not healthy (attempt ${connectAttempts})`, - `status: ${data.status}\ntoken: ${data.token ? 'yes' : 'no'}` + `status: ${data.status}\ntoken: ${tokenResp.token ? 'yes' : 'no'}` ); } else { setLoadingStatus( diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 8869212ab9..93254a01f0 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -65,7 +65,8 @@ chmod +x "$APP_DIR/Contents/Resources/browse" # Extension cp -r "$ROOT/extension" "$APP_DIR/Contents/Resources/extension" -# Remove .auth.json if present (auth now via /health endpoint) +# Remove legacy file-based auth if present. Current builds provision the +# bearer through the trusted extension's isolated Chrome storage. rm -f "$APP_DIR/Contents/Resources/extension/.auth.json" # Server source (needed for `bun run server.ts` subprocess) diff --git a/setup b/setup index 275236cd36..22cfc948c7 100755 --- a/setup +++ b/setup @@ -1200,6 +1200,27 @@ if [ "$INSTALL_CODEX" -eq 1 ]; then create_agents_sidecar "$SOURCE_GSTACK_DIR" fi +# 7.5. Repair a missing or corrupt Playwright cache before Browse is first +# launched. This is bounded and opt-out so setup cannot hang on a bad network. +# The normal macOS repair uses Chrome for Testing's versioned archive because +# Playwright's installer can leave a partial cache behind after interruption. +if [ "${GSTACK_SKIP_BROWSER_REPAIR:-0}" != "1" ] && [ -f "$SOURCE_GSTACK_DIR/bin/gstack-browse-repair.ts" ]; then + log "Checking Browse browser runtime..." + bun "$SOURCE_GSTACK_DIR/bin/gstack-browse-repair.ts" --repair \ + || log "warning: Browse browser runtime repair failed; run bin/gstack-browse-repair.ts --repair" +fi + +# 7.6. Security migration for the pre-#1324 unpacked Browse extension. This +# intentionally runs on every setup, not only a version transition: a fresh +# install can reuse an existing ~/.gstack Chromium profile. The migrator is +# idempotent and leaves a private rollback copy of only the old extension data. +if [ -f "$SOURCE_GSTACK_DIR/bin/gstack-browse-migrate.ts" ]; then + log "Migrating legacy Browse extension identity..." + bun "$SOURCE_GSTACK_DIR/bin/gstack-browse-migrate.ts" --apply \ + --legacy-extension-path "$SOURCE_GSTACK_DIR/extension" \ + || log "warning: Browse migration deferred; close GStack Browser and rerun ./setup" +fi + # 8. Run pending version migrations # Migrations handle state fixes that ./setup alone can't cover (stale config, # orphaned files, directory structure changes). Each migration is idempotent. diff --git a/test/gstack-browse-migration.test.ts b/test/gstack-browse-migration.test.ts new file mode 100644 index 0000000000..f3c409963e --- /dev/null +++ b/test/gstack-browse-migration.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { createHash } from 'crypto'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const MIGRATOR = path.join(ROOT, 'bin/gstack-browse-migrate.ts'); +const TRUSTED = 'hjcdllcckghjebjopehjhplcilonljjk'; +let legacyExtensionPath: string; +let legacyId: string; +let temp: string; + +beforeEach(() => { + temp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-browse-migration-')); + legacyExtensionPath = path.join(temp, 'legacy-extension'); + fs.mkdirSync(legacyExtensionPath); + legacyId = [...createHash('sha256').update(legacyExtensionPath).digest('hex').slice(0, 32)] + .map(char => String.fromCharCode('a'.charCodeAt(0) + parseInt(char, 16))).join(''); + const profile = path.join(temp, 'Default'); + fs.mkdirSync(path.join(profile, 'Local Extension Settings', legacyId), { recursive: true }); + fs.mkdirSync(path.join(profile, 'Sync Extension Settings', legacyId), { recursive: true }); + fs.writeFileSync(path.join(profile, 'Local Extension Settings', legacyId, '000003.log'), 'legacy token storage'); + fs.writeFileSync(path.join(profile, 'Preferences'), JSON.stringify({ + extensions: { settings: { + [TRUSTED]: { manifest: { name: 'gstack browse' } }, + another_extension: { manifest: { name: 'other extension' } }, + } }, + })); +}); + +afterEach(() => fs.rmSync(temp, { recursive: true, force: true })); + +function run(...args: string[]) { + return spawnSync('bun', [MIGRATOR, '--profile', temp, '--legacy-extension-path', legacyExtensionPath, '--json', ...args], { encoding: 'utf8' }); +} + +describe('gstack-browse-migrate', () => { + test('reports legacy state without modifying it in check mode', () => { + const result = run('--check'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).legacyExtensionIds).toEqual([legacyId]); + expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true); + }); + + test('retires only the legacy gstack extension and preserves a rollback copy', () => { + const result = run('--apply'); + expect(result.status).toBe(0); + const report = JSON.parse(result.stdout); + expect(report.status).toBe('migrated'); + expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(false); + expect(fs.existsSync(path.join(report.backup, 'Local Extension Settings', legacyId, '000003.log'))).toBe(true); + + const preferences = JSON.parse(fs.readFileSync(path.join(temp, 'Default', 'Preferences'), 'utf8')); + expect(preferences.extensions.settings[TRUSTED]).toBeDefined(); + expect(preferences.extensions.settings.another_extension).toBeDefined(); + }); + + test('defers safely while the dedicated GStack Browser profile is active', () => { + const lock = path.join(temp, 'SingletonLock'); + if (process.platform === 'win32') fs.writeFileSync(lock, 'active'); + else fs.symlinkSync(`${os.hostname()}-${process.pid}`, lock); + const result = run('--apply'); + expect(result.status).toBe(3); + expect(JSON.parse(result.stdout).status).toBe('deferred'); + expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true); + }); + + test('does not mistake a dangling stale Chromium lock symlink for an active profile', () => { + if (process.platform === 'win32') return; + fs.symlinkSync(`${os.hostname()}-2147483647`, path.join(temp, 'SingletonLock')); + const result = run('--apply'); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).status).toBe('migrated'); + }); + + test('does not mark an invalid Preferences profile as migrated', () => { + fs.writeFileSync(path.join(temp, 'Default', 'Preferences'), '{not json'); + const result = run('--apply'); + expect(result.status).toBe(3); + expect(JSON.parse(result.stdout).status).toBe('deferred'); + expect(fs.existsSync(path.join(temp, 'Default', 'Local Extension Settings', legacyId))).toBe(true); + }); +}); diff --git a/test/setup-browse-security-migration.test.ts b/test/setup-browse-security-migration.test.ts new file mode 100644 index 0000000000..f613004465 --- /dev/null +++ b/test/setup-browse-security-migration.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP = fs.readFileSync(path.join(ROOT, 'setup'), 'utf8'); +const REPAIR = fs.readFileSync(path.join(ROOT, 'bin/gstack-browse-repair.ts'), 'utf8'); + +describe('setup Browse security recovery', () => { + test('runs the legacy extension migration on every setup, not only version changes', () => { + const beforeVersionMigrations = SETUP.slice(0, SETUP.indexOf('# 8. Run pending version migrations')); + expect(beforeVersionMigrations).toContain('gstack-browse-migrate.ts'); + expect(beforeVersionMigrations).toContain('--legacy-extension-path "$SOURCE_GSTACK_DIR/extension"'); + expect(beforeVersionMigrations).toContain('close GStack Browser and rerun ./setup'); + }); + + test('repairs a broken browser cache with a bounded health-checked path', () => { + expect(REPAIR).toContain('const MAX_INSTALL_MS = 300_000'); + expect(REPAIR).toContain("'chrome-mac-arm64.zip'"); + expect(REPAIR).toContain("'chrome-headless-shell-mac-arm64.zip'"); + expect(REPAIR).toContain("[executable, '--version']"); + expect(REPAIR).toContain("'INSTALLATION_COMPLETE'"); + }); +}); From 16a63dbee642cda6417a5c5ba8e474deb595afdb Mon Sep 17 00:00:00 2001 From: Sina Date: Fri, 10 Jul 2026 12:51:08 -0700 Subject: [PATCH 2/2] docs(browse): document secure extension migration --- BROWSER.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/BROWSER.md b/BROWSER.md index affa0447d1..74cdbc8d5b 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -785,13 +785,27 @@ The root token is written to `/.gstack/browse.json` with chmod 600. Every command that mutates browser state must include `Authorization: Bearer `. +The Side Panel extension does not fetch that token from `/health`. The daemon +only provisions it to the manifest-key-pinned GStack extension worker, using +`chrome.storage.session` with access restricted to trusted extension contexts. +Ordinary web pages, content scripts, and unrelated extensions cannot use the +status-only `/health` endpoint to obtain Browse credentials. + +`./setup` automatically retires legacy path-identified GStack extensions from +the dedicated `~/.gstack/chromium-profile`, moving their old storage into a +private rollback backup under `~/.gstack/browser-migrations/`. If the GStack +Browser is open, migration safely defers; close it and rerun `./setup`. To retry +directly, run `bun bin/gstack-browse-migrate.ts --apply +--legacy-extension-path extension` from the gstack checkout. The migrator never +touches a user's normal Chrome profile. + ### SSE session cookie (v1.6.0.0+) SSE endpoints (`/activity/stream`, `/inspector/events`) accept the Bearer token OR a 30-minute HttpOnly `gstack_sse` cookie minted via `POST /sse-session`. The `?token=` query-param auth is no longer -supported. This is what lets the Chrome extension subscribe to the activity -feed without putting the root token in extension storage. +supported. This keeps the root token out of URLs and limits the stream cookie +to the read-only activity/inspector surface. ### PTY session cookie @@ -1201,6 +1215,7 @@ the global `~/.gstack/browser-skills/foo/` only inside project-a. | `GSTACK_SECURITY_ENSEMBLE` | unset | Set to `deberta` for 3-classifier ensemble (721MB download) | | `GSTACK_STEALTH` | unset | Set to `extended` (also accepts `1`/`true`) to layer six aggressive patches (WebGL spoof, faked plugins, mediaDevices) on top of Layer C. Actively lies; can break sites. | | `GSTACK_CDP_STEALTH` | unset | Set to `on`/`1`/`true` to emit `--gstack-suppress-prepare-stack-trace` (gbrowser Pack 2 / B11 C++ patch only; no-op on stock Chromium) | +| `GSTACK_SKIP_BROWSER_REPAIR` | 0 | Set to `1` to skip setup's bounded Playwright browser-cache health check and repair | | `GSTACK_GPU_VENDOR`, `GSTACK_GPU_RENDERER`, `GSTACK_GPU_CHIPSET` | unset | Per-install GPU spoof fed to the Pack 1 WebGL/UA-CH C++ patches. Set by gbd from the host profile; emitted as `--gstack-gpu-vendor` / `--gstack-gpu-renderer` / `--gstack-ua-model` cmdline switches only when present. | | `GSTACK_PLATFORM` | unset | Host platform classification (`MacARM`/`MacIntel` → `macOS`, `Win32` → `Windows`, `Linux*` → `Linux`) emitted as `--gstack-ua-platform` | | `GSTACK_HW_CONCURRENCY`, `GSTACK_DEVICE_MEMORY` | host profile (fallback 8) | Per-install `hardwareConcurrency`/`deviceMemory` reported by Layer C and emitted as `--gstack-hw-concurrency` / `--gstack-device-memory` for the worker-navigator C++ patch | @@ -1239,6 +1254,7 @@ browse/ │ ├── buffers.ts # Console/network/dialog circular buffers (O(1) ring) │ ├── tab-session.ts # Per-tab session state (load-html replay, ref map scope) │ ├── token-registry.ts # Mint/validate/revoke for root + setup keys + scoped tokens +│ ├── extension-identity.ts # Fixed extension ID + trusted service-worker URL check │ ├── sse-session-cookie.ts # 30-min HttpOnly cookie for /activity/stream + /inspector/events │ ├── pty-session-cookie.ts # Separate scope: live Claude PTY auth │ ├── tunnel-denial-log.ts # ~/.gstack/security/attempts.jsonl writer (salted)