mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-16 01:02:13 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
125
browse/src/sse-session-cookie.ts
Normal file
125
browse/src/sse-session-cookie.ts
Normal 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();
|
||||
}
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
94
browse/src/tunnel-denial-log.ts
Normal file
94
browse/src/tunnel-denial-log.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user