merge: origin/main into garrytan/injection-tuning; bump v1.5.2.0 → v1.6.2.0

Main shipped v1.6.0.0 (security tunnel dual-listener + SSRF + envelope wave) and v1.6.1.0 (Opus 4.7 migration) while this branch was developing injection-tuning. Merging to keep the branch in sync.

CHANGELOG: reverse-chronological order preserved — v1.6.1.0 > v1.6.0.0 > v1.5.2.0 (our branch entry) > v1.5.1.0 > ...
VERSION: bumped to 1.6.2.0 per CLAUDE.md "branch always ahead of main after merge" discipline.
package.json: synced to 1.6.2.0.

Auto-merged: 58+ files (skill docs regenerated from .tmpl changes, routing injection, preamble resolvers). No real conflicts in security-related source files.

Security test suite: 231 pass, 1 skip, 0 fail post-merge. Detection/FP numbers unchanged (56.2% / 22.9%).
This commit is contained in:
Garry Tan
2026-04-22 17:00:33 -07:00
81 changed files with 4210 additions and 857 deletions

View File

@@ -59,6 +59,22 @@ export const PAGE_CONTENT_COMMANDS = new Set([
'snapshot',
]);
/**
* Subset of PAGE_CONTENT_COMMANDS whose output is derived from the
* live page DOM. These channels can carry hidden elements or
* ARIA-injection payloads that the centralized envelope wrap alone
* does not neutralize, so the scoped-token pipeline runs
* `markHiddenElements` on the page before the read and surfaces any
* hits as CONTENT WARNINGS to the LLM.
*
* `console`, `dialog` intentionally excluded — they read separate
* runtime state (console capture, dialog events), not the DOM tree.
*/
export const DOM_CONTENT_COMMANDS = new Set([
'text', 'html', 'links', 'forms', 'accessibility', 'attrs',
'media', 'data', 'ux-audit',
]);
/** Wrap output from untrusted-content commands with trust boundary markers */
export function wrapUntrustedContent(result: string, url: string): string {
// Sanitize URL: remove newlines to prevent marker injection via history.pushState

View File

@@ -200,6 +200,25 @@ export async function cleanupHiddenMarkers(page: Page | Frame): Promise<void> {
const ENVELOPE_BEGIN = '═══ BEGIN UNTRUSTED WEB CONTENT ═══';
const ENVELOPE_END = '═══ END UNTRUSTED WEB CONTENT ═══';
/**
* Defuse envelope sentinels that appear inside attacker-controlled page
* content. Any raw BEGIN/END marker inside `content` gets a zero-width
* space spliced through CONTENT so the marker still renders visibly but
* no longer matches the envelope grep the LLM anchors on.
*
* Both the wrap path (full-page content) and the split path (scoped
* snapshots) must funnel untrusted text through this helper before
* emitting the outer envelope, otherwise a page whose accessibility
* tree contains the literal sentinel can close the envelope early and
* forge a fake "trusted" section in the LLM's view.
*/
export function escapeEnvelopeSentinels(content: string): string {
const zwsp = '\u200B';
return content
.replace(/═══ BEGIN UNTRUSTED WEB CONTENT ═══/g, `═══ BEGIN UNTRUSTED WEB C${zwsp}ONTENT ═══`)
.replace(/═══ END UNTRUSTED WEB CONTENT ═══/g, `═══ END UNTRUSTED WEB C${zwsp}ONTENT ═══`);
}
/**
* Wrap page content in a trust boundary envelope for scoped tokens.
* Escapes envelope markers in content to prevent boundary escape attacks.
@@ -209,11 +228,7 @@ export function wrapUntrustedPageContent(
command: string,
filterWarnings?: string[],
): string {
// Escape envelope markers in content (zero-width space injection)
const zwsp = '\u200B';
const safeContent = content
.replace(/═══ BEGIN UNTRUSTED WEB CONTENT ═══/g, `═══ BEGIN UNTRUSTED WEB C${zwsp}ONTENT ═══`)
.replace(/═══ END UNTRUSTED WEB CONTENT ═══/g, `═══ END UNTRUSTED WEB C${zwsp}ONTENT ═══`);
const safeContent = escapeEnvelopeSentinels(content);
const parts: string[] = [];

View File

@@ -831,15 +831,28 @@ export async function importCookiesViaCdp(
// Launch Chrome headless with remote debugging on the real profile.
//
// Security posture of the debug port:
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. We rely
// on that — the port is NOT exposed to the network. Any local process
// running as the same user could connect and read cookies, but if an
// attacker already has local-user access they can read the cookie DB
// directly. Threat model: no worse than baseline.
// - Chrome binds --remote-debugging-port to 127.0.0.1 by default. The
// port is NOT exposed to the network. Baseline threat: a local
// process running as the same user can connect.
// - Port is randomized in [9222, 9321] to avoid collisions with other
// Chrome-based tools the user may have open. Not cryptographic.
// Chrome-based tools. Not cryptographic — security relies on
// same-user-access baseline, not port secrecy.
// - Chrome is always killed in the finally block below (even on crash).
//
// KNOWN NON-GOAL (tracked as a separate hardening task for the next
// security wave):
// On Windows 10.15+ with App-Bound Encryption (v20) enabled, a
// same-user process that opens the cookie DB directly cannot decrypt
// v20 values — the DPAPI context is bound to the browser process.
// The CDP port bypasses that: `Network.getAllCookies` runs inside the
// browser, so any same-user process that connects to the debug port
// before we kill Chrome could exfiltrate decrypted v20 cookies.
// Fix direction: switch to `--remote-debugging-pipe` so the CDP
// transport is a parent/child stdio pipe, not TCP. Requires
// restructuring the extractCookiesViaCdp WebSocket client; deferred
// to a follow-up because the transport swap is non-trivial and the
// baseline threat is still "attacker already has same-user access."
//
// Debugging note: if this path starts failing after a Chrome update,
// check the Chrome version logged below — Chrome's ABE key format (v20)
// or /json/list shape can change between major versions.

View File

@@ -8,7 +8,7 @@ import { getCleanText } from './read-commands';
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand } from './commands';
import { validateNavigationUrl } from './url-validation';
import { checkScope, type TokenInfo } from './token-registry';
import { validateOutputPath, escapeRegExp } from './path-security';
import { validateOutputPath, validateReadPath, SAFE_DIRECTORIES, escapeRegExp } from './path-security';
// Re-export for backward compatibility (tests import from meta-commands)
export { validateOutputPath, escapeRegExp } from './path-security';
import * as Diff from 'diff';
@@ -134,6 +134,17 @@ function parsePdfArgs(args: string[]): ParsedPdfArgs {
}
function parsePdfFromFile(payloadPath: string): ParsedPdfArgs {
// Parity with load-html --from-file (browse/src/write-commands.ts) and
// the direct load-html <file> path: every caller-supplied file path
// must pass validateReadPath so the safe-dirs policy can't be skirted
// by routing reads through the --from-file shortcut.
try {
validateReadPath(path.resolve(payloadPath));
} catch {
throw new Error(
`pdf: --from-file ${payloadPath} must be under ${SAFE_DIRECTORIES.join(' or ')} (security policy). Copy the payload into the project tree or /tmp first.`
);
}
const raw = fs.readFileSync(payloadPath, 'utf8');
const json = JSON.parse(raw);
const out: ParsedPdfArgs = {

View File

@@ -19,7 +19,7 @@ import { handleWriteCommand } from './write-commands';
import { handleMetaCommand } from './meta-commands';
import { handleCookiePickerRoute, hasActivePicker } from './cookie-picker-routes';
import { sanitizeExtensionUrl } from './sidebar-utils';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
import { COMMAND_DESCRIPTIONS, PAGE_CONTENT_COMMANDS, DOM_CONTENT_COMMANDS, wrapUntrustedContent, canonicalizeCommand, buildUnknownCommandError, ALL_COMMANDS } from './commands';
import {
wrapUntrustedPageContent, datamarkContent,
runContentFilters, type ContentFilterResult,
@@ -41,6 +41,11 @@ import { inspectElement, modifyStyle, resetModifications, getModificationHistory
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
import { safeUnlink, safeUnlinkQuiet, safeKill } from './error-handling';
import { logTunnelDenial } from './tunnel-denial-log';
import {
mintSseSessionToken, validateSseSessionToken, extractSseCookie,
buildSseSetCookie, SSE_COOKIE_NAME,
} from './sse-session-cookie';
import * as fs from 'fs';
import * as net from 'net';
import * as path from 'path';
@@ -59,9 +64,101 @@ const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 1
// Sidebar chat is always enabled in headed mode (ungated in v0.12.0)
// ─── Tunnel State ───────────────────────────────────────────────
//
// Dual-listener architecture: the daemon binds TWO HTTP listeners when a
// tunnel is active. The local listener serves bootstrap + CLI + sidebar
// (never exposed to ngrok). The tunnel listener serves only the pairing
// ceremony and scoped-token command endpoints (the ONLY port ngrok forwards).
//
// Security property comes from physical port separation: a tunnel caller
// cannot reach bootstrap endpoints because they live on a different TCP
// socket, not because of any per-request check.
let tunnelActive = false;
let tunnelUrl: string | null = null;
let tunnelListener: any = null; // ngrok listener handle
let tunnelListener: any = null; // ngrok listener handle
let tunnelServer: ReturnType<typeof Bun.serve> | null = null; // tunnel HTTP listener
/** Which HTTP listener accepted this request. */
export type Surface = 'local' | 'tunnel';
/**
* Paths reachable over the tunnel surface. Everything else returns 404.
*
* `/connect` is the only unauthenticated tunnel endpoint — POST for setup-key
* exchange, GET for an `{alive: true}` probe used by /pair and /tunnel/start
* to detect dead ngrok tunnels. Other paths in this set require a scoped
* token via Authorization: Bearer.
*
* Updating this set is a deliberate security decision. Every addition widens
* the tunnel attack surface.
*/
const TUNNEL_PATHS = new Set<string>([
'/connect',
'/command',
'/sidebar-chat',
]);
/**
* Commands reachable via POST /command over the tunnel surface. A paired
* remote agent can drive the browser (goto, click, text, etc.) but cannot
* configure the daemon, bootstrap new sessions, import cookies, or reach
* extension-inspector state. This allowlist maps to the eng-review decision
* logged in the CEO plan for sec-wave v1.6.0.0.
*/
const TUNNEL_COMMANDS = new Set<string>([
'goto', 'click', 'text', 'screenshot',
'html', 'links', 'forms', 'accessibility',
'attrs', 'media', 'data',
'scroll', 'press', 'type', 'select', 'wait', 'eval',
]);
/**
* Read ngrok authtoken from env var, ~/.gstack/ngrok.env, or ngrok's native
* config files. Returns null if nothing found. Shared between the
* /tunnel/start handler and the BROWSE_TUNNEL=1 auto-start flow.
*/
function resolveNgrokAuthtoken(): string | null {
let authtoken = process.env.NGROK_AUTHTOKEN;
if (authtoken) return authtoken;
const home = process.env.HOME || '';
const ngrokEnvPath = path.join(home, '.gstack', 'ngrok.env');
if (fs.existsSync(ngrokEnvPath)) {
try {
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
if (match) return match[1].trim();
} catch {}
}
const ngrokConfigs = [
path.join(home, 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
path.join(home, '.config', 'ngrok', 'ngrok.yml'),
path.join(home, '.ngrok2', 'ngrok.yml'),
];
for (const conf of ngrokConfigs) {
try {
const content = fs.readFileSync(conf, 'utf-8');
const match = content.match(/authtoken:\s*(.+)/);
if (match) return match[1].trim();
} catch {}
}
return null;
}
/**
* Tear down the tunnel: close the ngrok listener and stop the tunnel-surface
* Bun.serve listener. Safe to call with nothing running. Always clears
* tunnel state regardless of individual close failures.
*/
async function closeTunnel(): Promise<void> {
try { if (tunnelListener) await tunnelListener.close(); } catch {}
try { if (tunnelServer) tunnelServer.stop(true); } catch {}
tunnelListener = null;
tunnelServer = null;
tunnelUrl = null;
tunnelActive = false;
}
function validateAuth(req: Request): boolean {
const header = req.headers.get('authorization');
@@ -689,6 +786,27 @@ function killAgent(targetTabId?: number | null): void {
agentStartTime = null;
currentMessage = null;
agentStatus = 'idle';
// Reset per-tab agent state too. Without this, /sidebar-command on the
// same tab after a kill would see tabState.status === 'processing' (the
// legacy globals-only reset missed it) and fall into the queue branch
// instead of spawning. When a specific tab was targeted, reset only
// that tab; otherwise reset ALL tabs (e.g. session-new kills everything).
if (targetTabId != null) {
const state = tabAgents.get(targetTabId);
if (state) {
state.status = 'idle';
state.startTime = null;
state.currentMessage = null;
state.queue = [];
}
} else {
for (const state of tabAgents.values()) {
state.status = 'idle';
state.startTime = null;
state.currentMessage = null;
state.queue = [];
}
}
}
// Agent health check — detect hung processes
@@ -1085,18 +1203,39 @@ async function handleCommandInternal(
const session = browserManager.getActiveSession();
// Per-request warnings collected during hidden-element detection,
// surfaced into the envelope the LLM sees. Carries across the read
// phase into the centralized wrap block below.
let hiddenContentWarnings: string[] = [];
if (READ_COMMANDS.has(command)) {
const isScoped = tokenInfo && tokenInfo.clientId !== 'root';
// Hidden element stripping for scoped tokens on text command
if (isScoped && command === 'text') {
// Hidden-element / ARIA-injection detection for every scoped
// DOM-reading channel (text, html, links, forms, accessibility,
// attrs, data, media, ux-audit). Previously only `text` received
// stripping; other channels let hidden injection payloads reach
// the LLM despite the envelope wrap. Detections become CONTENT
// WARNINGS on the outgoing envelope so the model can see what it
// would have otherwise trusted silently.
if (isScoped && DOM_CONTENT_COMMANDS.has(command)) {
const page = session.getPage();
const strippedDescs = await markHiddenElements(page);
if (strippedDescs.length > 0) {
console.warn(`[browse] Content security: stripped ${strippedDescs.length} hidden elements for ${tokenInfo.clientId}`);
}
try {
const target = session.getActiveFrameOrPage();
result = await getCleanTextWithStripping(target);
const strippedDescs = await markHiddenElements(page);
if (strippedDescs.length > 0) {
console.warn(`[browse] Content security: ${strippedDescs.length} hidden elements flagged on ${command} for ${tokenInfo.clientId}`);
hiddenContentWarnings = strippedDescs.slice(0, 8).map(d =>
`hidden content: ${d.slice(0, 120)}`,
);
if (strippedDescs.length > 8) {
hiddenContentWarnings.push(`hidden content: +${strippedDescs.length - 8} more flagged elements`);
}
}
if (command === 'text') {
const target = session.getActiveFrameOrPage();
result = await getCleanTextWithStripping(target);
} else {
result = await handleReadCommand(command, args, session, browserManager);
}
} finally {
await cleanupHiddenMarkers(page);
}
@@ -1167,10 +1306,14 @@ async function handleCommandInternal(
if (command === 'text') {
result = datamarkContent(result);
}
// Enhanced envelope wrapping for scoped tokens
// Enhanced envelope wrapping for scoped tokens.
// Merge per-request hidden-element warnings with content-filter
// warnings so both reach the LLM through the same CONTENT
// WARNINGS header.
const combinedWarnings = [...filterResult.warnings, ...hiddenContentWarnings];
result = wrapUntrustedPageContent(
result, command,
filterResult.warnings.length > 0 ? filterResult.warnings : undefined,
combinedWarnings.length > 0 ? combinedWarnings : undefined,
);
} else {
// Root token: basic wrapping (backward compat, Decision 2)
@@ -1407,11 +1550,62 @@ async function start() {
}
const startTime = Date.now();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
fetch: async (req) => {
const url = new URL(req.url);
// ─── Request handler factory ────────────────────────────────────
//
// Same logic serves both the local listener (bootstrap, CLI, sidebar) and
// the tunnel listener (pairing + scoped-token commands). The factory
// closes over `surface` so the filter that runs before route dispatch
// knows which socket accepted the request.
//
// On the tunnel surface: reject anything not in TUNNEL_PATHS (404), reject
// root-token bearers (403), and require a scoped token for everything
// except /connect. Denials are logged to ~/.gstack/security/attempts.jsonl.
const makeFetchHandler = (surface: Surface) => async (req: Request): Promise<Response> => {
const url = new URL(req.url);
// ─── Tunnel surface filter (runs before any route dispatch) ──
if (surface === 'tunnel') {
const isGetConnect = req.method === 'GET' && url.pathname === '/connect';
const allowed = TUNNEL_PATHS.has(url.pathname);
if (!allowed && !isGetConnect) {
logTunnelDenial(req, url, 'path_not_on_tunnel');
return new Response(JSON.stringify({ error: 'Not found' }), {
status: 404, headers: { 'Content-Type': 'application/json' },
});
}
if (isRootRequest(req)) {
logTunnelDenial(req, url, 'root_token_on_tunnel');
return new Response(JSON.stringify({
error: 'Root token rejected on tunnel surface',
hint: 'Remote agents must pair via /connect to receive a scoped token.',
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
if (url.pathname !== '/connect' && !getTokenInfo(req)) {
logTunnelDenial(req, url, 'missing_scoped_token');
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
}
}
// GET /connect — alive probe. Unauth on both surfaces. Used by /pair
// and /tunnel/start to detect dead ngrok tunnels via the tunnel URL,
// since /health is not tunnel-reachable under the dual-listener design.
//
// Shares the same rate limit as POST /connect — otherwise a tunnel
// caller can probe unlimited GETs and lock out nothing, which makes
// the endpoint a free daemon-enumeration surface.
if (url.pathname === '/connect' && req.method === 'GET') {
if (!checkConnectRateLimit()) {
return new Response(JSON.stringify({ error: 'Rate limited' }), {
status: 429, headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ alive: true }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
// Cookie picker routes — HTML page unauthenticated, data/action routes require auth
if (url.pathname.startsWith('/cookie-picker')) {
@@ -1421,14 +1615,23 @@ async function start() {
// Welcome page — served when GStack Browser launches in headed mode
if (url.pathname === '/welcome') {
const welcomePath = (() => {
// Check project-local designs first, then global
const slug = process.env.GSTACK_SLUG || 'unknown';
// Gate GSTACK_SLUG on a strict regex BEFORE interpolating it into
// the filesystem path. Without this, a slug like "../../etc/passwd"
// would resolve to ~/.gstack/projects/../../etc/passwd/... — path
// traversal. Not exploitable today (attacker needs local env-var
// access), but the gate is one regex and buys us defense-in-depth.
const rawSlug = process.env.GSTACK_SLUG || 'unknown';
const slug = /^[a-z0-9_-]+$/.test(rawSlug) ? rawSlug : 'unknown';
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
const projectWelcome = `${homeDir}/.gstack/projects/${slug}/designs/welcome-page-20260331/finalized.html`;
if (fs.existsSync(projectWelcome)) return projectWelcome;
// Fallback: built-in welcome page from gstack install
const skillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
const builtinWelcome = `${skillRoot}/browse/src/welcome.html`;
// Fallback: built-in welcome page from gstack install. Reject
// SKILL_ROOT values containing '..' for the same defense-in-depth
// reason as the GSTACK_SLUG regex above. Not exploitable today
// (env set at install time), but the gate is one check.
const rawSkillRoot = process.env.GSTACK_SKILL_ROOT || `${homeDir}/.claude/skills/gstack`;
if (rawSkillRoot.includes('..')) return null;
const builtinWelcome = `${rawSkillRoot}/browse/src/welcome.html`;
if (fs.existsSync(builtinWelcome)) return builtinWelcome;
return null;
})();
@@ -1614,11 +1817,14 @@ async function start() {
domains: pairBody.domains,
rateLimit: pairBody.rateLimit,
});
// Verify tunnel is actually alive before reporting it (ngrok may have died externally)
// Verify tunnel is actually alive before reporting it (ngrok may have died externally).
// Probe via GET /connect — under dual-listener /health is NOT on the tunnel allowlist,
// so the old probe would return 404 and always mark the tunnel as dead.
let verifiedTunnelUrl: string | null = null;
if (tunnelActive && tunnelUrl) {
try {
const probe = await fetch(`${tunnelUrl}/health`, {
const probe = await fetch(`${tunnelUrl}/connect`, {
method: 'GET',
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
@@ -1626,15 +1832,11 @@ async function start() {
verifiedTunnelUrl = tunnelUrl;
} else {
console.warn(`[browse] Tunnel probe failed (HTTP ${probe.status}), marking tunnel as dead`);
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
await closeTunnel();
}
} catch {
console.warn('[browse] Tunnel probe timed out or unreachable, marking tunnel as dead');
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
await closeTunnel();
}
}
return new Response(JSON.stringify({
@@ -1652,16 +1854,29 @@ async function start() {
}
// ─── /tunnel/start — start ngrok tunnel on demand (root-only) ──
//
// 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.)
// is never exposed to ngrok.
//
// Hard fail if the tunnel listener bind fails — NEVER fall back to
// the local port, which would silently defeat the whole security
// property.
if (url.pathname === '/tunnel/start' && req.method === 'POST') {
if (!isRootRequest(req)) {
return new Response(JSON.stringify({ error: 'Root token required' }), {
status: 403, headers: { 'Content-Type': 'application/json' },
});
}
if (tunnelActive && tunnelUrl) {
// Verify tunnel is still alive before returning cached URL
if (tunnelActive && tunnelUrl && tunnelServer) {
// Verify tunnel is still alive before returning cached URL.
// Probe GET /connect (the only unauth-reachable path on the tunnel
// surface); /health is NOT tunnel-reachable under dual-listener.
try {
const probe = await fetch(`${tunnelUrl}/health`, {
const probe = await fetch(`${tunnelUrl}/connect`, {
method: 'GET',
headers: { 'ngrok-skip-browser-warning': 'true' },
signal: AbortSignal.timeout(5000),
});
@@ -1671,53 +1886,49 @@ async function start() {
});
}
} catch {}
// Tunnel is dead, reset and fall through to restart
// Tunnel is dead — tear down cleanly before restarting
console.warn('[browse] Cached tunnel is dead, restarting...');
tunnelActive = false;
tunnelUrl = null;
tunnelListener = null;
await closeTunnel();
}
// 1) Resolve ngrok authtoken from env / .gstack / native config
const authtoken = resolveNgrokAuthtoken();
if (!authtoken) {
return new Response(JSON.stringify({
error: 'No ngrok authtoken found',
hint: 'Run: ngrok config add-authtoken YOUR_TOKEN',
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
// 2) Bind the tunnel listener on an ephemeral port. HARD FAIL if
// this errors — never fall back to the local port.
let boundTunnel: ReturnType<typeof Bun.serve>;
try {
boundTunnel = Bun.serve({
port: 0,
hostname: '127.0.0.1',
fetch: makeFetchHandler('tunnel'),
});
} catch (err: any) {
return new Response(JSON.stringify({
error: `Failed to bind tunnel listener: ${err.message}`,
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
const tunnelPort = boundTunnel.port;
// 3) Point ngrok at the TUNNEL port (not the local port). If this
// fails, tear the listener back down so we don't leak sockets.
try {
// Read ngrok authtoken: env var > ~/.gstack/ngrok.env > ngrok native config
let authtoken = process.env.NGROK_AUTHTOKEN;
if (!authtoken) {
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
if (fs.existsSync(ngrokEnvPath)) {
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
if (match) authtoken = match[1].trim();
}
}
if (!authtoken) {
// Check ngrok's native config files
const ngrokConfigs = [
path.join(process.env.HOME || '', 'Library', 'Application Support', 'ngrok', 'ngrok.yml'),
path.join(process.env.HOME || '', '.config', 'ngrok', 'ngrok.yml'),
path.join(process.env.HOME || '', '.ngrok2', 'ngrok.yml'),
];
for (const conf of ngrokConfigs) {
try {
const content = fs.readFileSync(conf, 'utf-8');
const match = content.match(/authtoken:\s*(.+)/);
if (match) { authtoken = match[1].trim(); break; }
} catch {}
}
}
if (!authtoken) {
return new Response(JSON.stringify({
error: 'No ngrok authtoken found',
hint: 'Run: ngrok config add-authtoken YOUR_TOKEN',
}), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
const ngrok = await import('@ngrok/ngrok');
const domain = process.env.NGROK_DOMAIN;
const forwardOpts: any = { addr: server!.port, authtoken };
const forwardOpts: any = { addr: tunnelPort, authtoken };
if (domain) forwardOpts.domain = domain;
tunnelListener = await ngrok.forward(forwardOpts);
tunnelUrl = tunnelListener.url();
tunnelServer = boundTunnel;
tunnelActive = true;
console.log(`[browse] Tunnel started on demand: ${tunnelUrl}`);
console.log(`[browse] Tunnel listener bound on 127.0.0.1:${tunnelPort}, ngrok → ${tunnelUrl}`);
// Update state file
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
@@ -1730,12 +1941,50 @@ async function start() {
status: 200, headers: { 'Content-Type': 'application/json' },
});
} catch (err: any) {
// Clean up BOTH ngrok and the Bun listener on failure. If
// ngrok.forward() succeeded but tunnelListener.url() or the
// state-file write threw, we'd otherwise leak an active ngrok
// session on the user's account.
try { if (tunnelListener) await tunnelListener.close(); } catch {}
try { boundTunnel.stop(true); } catch {}
tunnelListener = null;
return new Response(JSON.stringify({
error: `Failed to start tunnel: ${err.message}`,
error: `Failed to open ngrok tunnel: ${err.message}`,
}), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
}
// ─── SSE session cookie mint (auth required) ──────────────────
//
// Issues a short-lived view-only token in an HttpOnly SameSite=Strict
// cookie so EventSource calls can authenticate without putting the
// root token in a URL. The returned cookie is valid ONLY on the SSE
// endpoints (/activity/stream, /inspector/events); it is not a
// scoped token and cannot be used against /command.
//
// The extension calls this once at bootstrap with the root Bearer
// header, then opens EventSource with `withCredentials: true` which
// sends the cookie back automatically.
if (url.pathname === '/sse-session' && req.method === 'POST') {
if (!validateAuth(req)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const minted = mintSseSessionToken();
return new Response(JSON.stringify({
expiresAt: minted.expiresAt,
cookie: SSE_COOKIE_NAME,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': buildSseSetCookie(minted.token),
},
});
}
// Refs endpoint — auth required, does NOT reset idle timer
if (url.pathname === '/refs') {
if (!validateAuth(req)) {
@@ -1757,9 +2006,14 @@ async function start() {
// Activity stream — SSE, auth required, does NOT reset idle timer
if (url.pathname === '/activity/stream') {
// Inline auth: accept Bearer header OR ?token= query param (EventSource can't send headers)
const streamToken = url.searchParams.get('token');
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
// Auth: Bearer header OR view-only SSE session cookie (EventSource
// can't send Authorization headers, so the extension fetches a cookie
// via POST /sse-session first, then opens EventSource with
// withCredentials: true). The ?token= query param is NO LONGER
// accepted — URLs leak to logs/referer/history. See N1 in the
// v1.6.0.0 security wave plan.
const cookieToken = extractSseCookie(req);
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
@@ -2272,7 +2526,20 @@ async function start() {
});
}
resetIdleTimer();
const body = await req.json();
const body = await req.json() as any;
// Tunnel surface: only commands in TUNNEL_COMMANDS are allowed.
// Paired remote agents drive the browser but cannot configure the
// daemon, launch new browsers, import cookies, or rotate tokens.
if (surface === 'tunnel') {
const cmd = canonicalizeCommand(body?.command);
if (!cmd || !TUNNEL_COMMANDS.has(cmd)) {
logTunnelDenial(req, url, `disallowed_command:${body?.command}`);
return new Response(JSON.stringify({
error: `Command '${body?.command}' is not allowed over the tunnel surface`,
hint: `Tunnel commands: ${[...TUNNEL_COMMANDS].sort().join(', ')}`,
}), { status: 403, headers: { 'Content-Type': 'application/json' } });
}
}
return handleCommand(body, tokenInfo);
}
@@ -2376,8 +2643,10 @@ async function start() {
// GET /inspector/events — SSE for inspector state changes (auth required)
if (url.pathname === '/inspector/events' && req.method === 'GET') {
const streamToken = url.searchParams.get('token');
if (!validateAuth(req) && streamToken !== AUTH_TOKEN) {
// Same auth model as /activity/stream: Bearer OR view-only cookie.
// ?token= query param dropped (see N1 in the v1.6.0.0 security plan).
const cookieToken = extractSseCookie(req);
if (!validateAuth(req) && !validateSseSessionToken(cookieToken)) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { 'Content-Type': 'application/json' },
});
@@ -2437,7 +2706,13 @@ async function start() {
}
return new Response('Not found', { status: 404 });
},
};
// ─── End of makeFetchHandler ────────────────────────────────────
const server = Bun.serve({
port,
hostname: '127.0.0.1',
fetch: makeFetchHandler('local'),
});
// Write state file (atomic: write .tmp then rename)
@@ -2497,37 +2772,34 @@ async function start() {
initSidebarSession();
// ─── Tunnel startup (optional) ────────────────────────────────
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set.
// Reads NGROK_AUTHTOKEN from env or ~/.gstack/ngrok.env.
// Reads NGROK_DOMAIN for dedicated domain (stable URL).
// Start ngrok tunnel if BROWSE_TUNNEL=1 is set. Uses the dual-listener
// pattern: bind a dedicated tunnel listener on an ephemeral port and
// point ngrok.forward() at IT, not the local daemon port.
if (process.env.BROWSE_TUNNEL === '1') {
try {
// Read ngrok authtoken from env or config file
let authtoken = process.env.NGROK_AUTHTOKEN;
if (!authtoken) {
const ngrokEnvPath = path.join(process.env.HOME || '', '.gstack', 'ngrok.env');
if (fs.existsSync(ngrokEnvPath)) {
const envContent = fs.readFileSync(ngrokEnvPath, 'utf-8');
const match = envContent.match(/^NGROK_AUTHTOKEN=(.+)$/m);
if (match) authtoken = match[1].trim();
}
}
if (!authtoken) {
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
} else {
const authtoken = resolveNgrokAuthtoken();
if (!authtoken) {
console.error('[browse] BROWSE_TUNNEL=1 but no NGROK_AUTHTOKEN found. Set it via env var or ~/.gstack/ngrok.env');
} else {
let boundTunnel: ReturnType<typeof Bun.serve> | null = null;
try {
boundTunnel = Bun.serve({
port: 0,
hostname: '127.0.0.1',
fetch: makeFetchHandler('tunnel'),
});
const tunnelPort = boundTunnel.port;
const ngrok = await import('@ngrok/ngrok');
const domain = process.env.NGROK_DOMAIN;
const forwardOpts: any = {
addr: port,
authtoken,
};
const forwardOpts: any = { addr: tunnelPort, authtoken };
if (domain) forwardOpts.domain = domain;
tunnelListener = await ngrok.forward(forwardOpts);
tunnelUrl = tunnelListener.url();
tunnelServer = boundTunnel;
tunnelActive = true;
console.log(`[browse] Tunnel active: ${tunnelUrl}`);
console.log(`[browse] Tunnel listener bound on 127.0.0.1:${tunnelPort}, ngrok → ${tunnelUrl}`);
// Update state file with tunnel URL
const stateContent = JSON.parse(fs.readFileSync(config.stateFile, 'utf-8'));
@@ -2535,9 +2807,15 @@ async function start() {
const tmpState = config.stateFile + '.tmp';
fs.writeFileSync(tmpState, JSON.stringify(stateContent, null, 2), { mode: 0o600 });
fs.renameSync(tmpState, config.stateFile);
} catch (err: any) {
console.error(`[browse] Failed to start tunnel: ${err.message}`);
// Same cleanup as /tunnel/start's error path: tear down BOTH
// ngrok and the Bun listener so we don't leak an ngrok session
// if the error happened after ngrok.forward() resolved.
try { if (tunnelListener) await tunnelListener.close(); } catch {}
try { if (boundTunnel) boundTunnel.stop(true); } catch {}
tunnelListener = null;
}
} catch (err: any) {
console.error(`[browse] Failed to start tunnel: ${err.message}`);
}
}
}

View File

@@ -21,6 +21,7 @@ import type { Page, Frame, Locator } from 'playwright';
import type { TabSession, RefEntry } from './tab-session';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
import { escapeEnvelopeSentinels } from './content-security';
// Roles considered "interactive" for the -i flag
const INTERACTIVE_ROLES = new Set([
@@ -613,8 +614,14 @@ export async function handleSnapshot(
parts.push(...trustedRefs);
parts.push('');
}
// Defuse any envelope sentinel that appears inside the page's own
// accessibility text. Without this, a page whose rendered content
// contains the literal `═══ END UNTRUSTED WEB CONTENT ═══` string
// can close the envelope early and forge a fake "trusted" block
// for the LLM. Same escape that wrapUntrustedPageContent applies.
const safeUntrusted = untrustedLines.map(escapeEnvelopeSentinels);
parts.push('═══ BEGIN UNTRUSTED WEB CONTENT ═══');
parts.push(...untrustedLines);
parts.push(...safeUntrusted);
parts.push('═══ END UNTRUSTED WEB CONTENT ═══');
return parts.join('\n');
}

View File

@@ -0,0 +1,125 @@
/**
* View-only session cookie registry for SSE endpoints.
*
* Why this exists: EventSource cannot send Authorization headers, so
* /activity/stream and /inspector/events historically took a `?token=`
* query param with the root AUTH_TOKEN. URLs leak through browser history,
* referer headers, server logs, crash reports, and refactoring accidents
* (Codex's plan-review outside voice called this out). This module issues
* a separate short-lived token, scoped to SSE reads only, delivered via
* an HttpOnly SameSite=Strict cookie that EventSource can pick up with
* `withCredentials: true`.
*
* Design notes:
* - TTL 30 minutes. Long enough for a normal coding session; short enough
* that a leaked cookie expires quickly.
* - Scope is implicit: validating a cookie only grants read access to
* /activity/stream and /inspector/events. The cookie is NEVER valid on
* /command, /token, or any mutating endpoint. Matches the
* cookie-picker-auth-isolation pattern (prior learning, 10/10 confidence):
* cookie-based session tokens must not be valid as scoped tokens.
* - In-memory only. No persistence across daemon restarts — extension
* re-mints on reconnect.
* - Tokens are 32 random bytes (URL-safe base64). 256 bits, unbruteforceable.
*/
import * as crypto from 'crypto';
interface Session {
createdAt: number;
expiresAt: number;
}
const TTL_MS = 30 * 60 * 1000; // 30 minutes
const MAX_SESSIONS = 10_000; // Upper bound on registry size
const sessions = new Map<string, Session>();
export const SSE_COOKIE_NAME = 'gstack_sse';
/** Mint a fresh view-only SSE session token. */
export function mintSseSessionToken(): { token: string; expiresAt: number } {
// 32 random bytes → 43-char URL-safe base64 (no padding)
const token = crypto.randomBytes(32).toString('base64url');
const now = Date.now();
const expiresAt = now + TTL_MS;
sessions.set(token, { createdAt: now, expiresAt });
pruneExpired(now);
return { token, expiresAt };
}
/**
* Validate a token. Returns true only if the token exists AND is not expired.
* Expired tokens are lazily removed, and we opportunistically prune a few
* additional expired entries on every validate so the registry can't grow
* unboundedly under sustained mint + reconnect pressure.
*/
export function validateSseSessionToken(token: string | null | undefined): boolean {
if (!token) return false;
const s = sessions.get(token);
if (!s) {
pruneExpired(Date.now());
return false;
}
if (Date.now() > s.expiresAt) {
sessions.delete(token);
pruneExpired(Date.now());
return false;
}
return true;
}
/** Parse the SSE session token from a Cookie header. */
export function extractSseCookie(req: Request): string | null {
const cookieHeader = req.headers.get('cookie');
if (!cookieHeader) return null;
for (const part of cookieHeader.split(';')) {
const [name, ...valueParts] = part.trim().split('=');
if (name === SSE_COOKIE_NAME) {
return valueParts.join('=') || null;
}
}
return null;
}
/**
* Build the Set-Cookie header value for the SSE session cookie.
* - HttpOnly: not readable from JS (mitigates XSS token exfiltration)
* - SameSite=Strict: not sent on cross-site requests (mitigates CSRF)
* - Path=/: scope to the whole origin so SSE endpoints can read it
* - Max-Age matches the TTL
*
* Secure is intentionally omitted: the daemon binds to 127.0.0.1 over
* plain HTTP, and setting Secure would prevent the browser from ever
* sending the cookie back. If gstack ever ships over HTTPS, add Secure.
*/
export function buildSseSetCookie(token: string): string {
const maxAge = Math.floor(TTL_MS / 1000);
return `${SSE_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
}
/** Build a Set-Cookie header that clears the SSE session cookie. */
export function buildSseClearCookie(): string {
return `${SSE_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
}
function pruneExpired(now: number): void {
// Opportunistic cleanup: check up to 20 entries per call so we don't
// stall on a massive registry. O(1) amortized. Runs on every mint
// AND on every validate so a steady reconnect flow can't outpace it.
let checked = 0;
for (const [token, session] of sessions) {
if (checked++ >= 20) break;
if (session.expiresAt <= now) sessions.delete(token);
}
// Hard cap as a backstop — if something still gets past opportunistic
// cleanup (e.g., all unexpired but registry enormous), drop the oldest.
while (sessions.size > MAX_SESSIONS) {
const first = sessions.keys().next().value;
if (!first) break;
sessions.delete(first);
}
}
// Test-only reset.
export function __resetSseSessions(): void {
sessions.clear();
}

View File

@@ -473,10 +473,18 @@ export function restoreRegistry(state: TokenRegistryState): void {
}
}
// ─── Connect endpoint rate limiter (brute-force protection) ─────
// ─── Connect endpoint rate limiter (flood protection) ─────
//
// Global-only cap. Setup keys are 24 random bytes (unbruteforceable), so
// rate limiting here is not about preventing key guessing. It caps
// bandwidth, CPU, and log-flood damage from someone who discovered the
// ngrok URL. A legitimate pair-agent session hits /connect once, so
// 300/min is 60x that pattern and never hit accidentally. Per-IP tracking
// was considered and rejected: adds a bounded Map + LRU for defense
// already adequate at the global layer.
let connectAttempts: { ts: number }[] = [];
const CONNECT_RATE_LIMIT = 3; // attempts per minute
const CONNECT_RATE_LIMIT = 300; // attempts per minute (~5/sec average)
const CONNECT_WINDOW_MS = 60000;
export function checkConnectRateLimit(): boolean {
@@ -486,3 +494,8 @@ export function checkConnectRateLimit(): boolean {
connectAttempts.push({ ts: now });
return true;
}
// Test-only reset.
export function __resetConnectRateLimit(): void {
connectAttempts = [];
}

View File

@@ -0,0 +1,94 @@
/**
* Append-only log of tunnel-surface auth denials.
*
* Records every time a tunneled request is rejected by enforceTunnelPolicy
* (root token sent over tunnel, missing scoped token, disallowed command, etc).
* Gives operators visibility into who is actually probing their tunneled
* daemons so the next security wave can be driven by real attack data.
*
* Design notes:
* - Async via fs.promises.appendFile. NEVER appendFileSync — blocking the event
* loop on every denial during a flood is exactly what an attacker wants.
* (Prior learning: sync-audit-log-io, 10/10 confidence.)
* - Rate-capped at 60 writes/minute globally. Excess denials are counted in
* memory but not written to disk — prevents disk DoS.
* - Writes to ~/.gstack/security/attempts.jsonl, shared with the prompt-injection
* attempt log. File rotation is handled by the existing security pipeline.
*/
import { promises as fsp } from 'fs';
import * as path from 'path';
import * as os from 'os';
const LOG_DIR = path.join(os.homedir(), '.gstack', 'security');
const LOG_PATH = path.join(LOG_DIR, 'attempts.jsonl');
const RATE_CAP = 60; // writes per minute
const WINDOW_MS = 60_000;
const writeTimestamps: number[] = [];
let droppedSinceLastWrite = 0;
let dirEnsured = false;
async function ensureDir(): Promise<void> {
if (dirEnsured) return;
try {
await fsp.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
dirEnsured = true;
} catch {
// Swallow — log writes are best-effort. Failure to mkdir just means
// subsequent appends will also fail and be caught below.
}
}
export interface TunnelDenialEntry {
reason: string;
path: string;
method: string;
sourceIp: string;
}
export function logTunnelDenial(req: Request, url: URL, reason: string): void {
const now = Date.now();
// Drop stale timestamps
while (writeTimestamps.length && writeTimestamps[0] < now - WINDOW_MS) {
writeTimestamps.shift();
}
if (writeTimestamps.length >= RATE_CAP) {
droppedSinceLastWrite += 1;
return;
}
writeTimestamps.push(now);
const sourceIp =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
const entry: Record<string, unknown> = {
ts: new Date(now).toISOString(),
kind: 'tunnel_auth_denial',
reason,
path: url.pathname,
method: req.method,
sourceIp,
};
if (droppedSinceLastWrite > 0) {
entry.droppedSinceLastWrite = droppedSinceLastWrite;
droppedSinceLastWrite = 0;
}
// Fire and forget. Never await, never block the request path.
void (async () => {
try {
await ensureDir();
await fsp.appendFile(LOG_PATH, JSON.stringify(entry) + '\n');
} catch {
// Swallow — log writes are best-effort. If disk is full or ACLs block
// us, we don't want to crash the server.
}
})();
}
// Test-only reset. Never called in production.
export function __resetTunnelDenialLog(): void {
writeTimestamps.length = 0;
droppedSinceLastWrite = 0;
dirEnsured = false;
}

View File

@@ -188,6 +188,19 @@ export async function handleWriteCommand(
if (args[i] === '--from-file') {
const payloadPath = args[++i];
if (!payloadPath) throw new Error('load-html: --from-file requires a path');
// Parity with the sibling `load-html <file>` path below (line 249):
// that branch runs every `file://` target through validateReadPath
// so the safe-dirs policy can't be side-stepped. Same policy must
// apply here — otherwise --from-file becomes a read-anywhere escape
// hatch for any caller that can pick the payload path (e.g., an
// MCP caller issuing load-html with an attacker-influenced path).
try {
validateReadPath(path.resolve(payloadPath));
} catch {
throw new Error(
`load-html: --from-file ${payloadPath} must be under ${SAFE_DIRECTORIES.join(' or ')} (security policy). Copy the payload into the project tree or /tmp first.`
);
}
const raw = fs.readFileSync(payloadPath, 'utf8');
let json: any;
try { json = JSON.parse(raw); }
@@ -1188,7 +1201,16 @@ export async function handleWriteCommand(
contentType = match[1];
buffer = Buffer.from(match[2], 'base64');
} else {
// Strategy 1: Direct URL via page.request.fetch()
// Strategy 1: Direct URL via page.request.fetch().
// Gate the URL through the same validator `goto` uses. Without
// this check, download + scrape bypass the navigation
// blocklist and a caller with write scope can read
// http://169.254.169.254/latest/meta-data/ (AWS IMDSv1), the
// GCP/Azure metadata equivalents, or any internal IPv4/IPv6
// the server happens to route to. The response body is then
// returned to the caller (base64) or written to disk where
// GET /file serves it back.
await validateNavigationUrl(url);
const response = await page.request.fetch(url, { timeout: 30000 });
const status = response.status();
if (status >= 400) {
@@ -1286,6 +1308,10 @@ export async function handleWriteCommand(
for (let i = 0; i < toDownload.length; i++) {
const { url, type } = toDownload[i];
try {
// Same gate as the download command — page.request.fetch
// must not reach cloud metadata, ULA ranges, or the rest of
// the blocklist. See url-validation.ts for the full list.
await validateNavigationUrl(url);
const response = await page.request.fetch(url, { timeout: 30000 });
if (response.status() >= 400) throw new Error(`HTTP ${response.status()}`);
const ct = response.headers()['content-type'] || 'application/octet-stream';