mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-17 17:51:27 +08:00
security: tunnel dual-listener + SSRF + envelope + path wave (v1.6.0.0) (#1137)
* refactor(security): loosen /connect rate limit from 3/min to 300/min
Setup keys are 24 random bytes (unbruteforceable), so a tight rate limit
does not meaningfully prevent key guessing. It exists only to cap
bandwidth, CPU, and log-flood damage from someone who discovered the
ngrok URL. A legitimate pair-agent session hits /connect once; 300/min
is 60x that pattern and never hit accidentally.
3/min caused pairing to fail on any retry flow (network blip, second
paired client) with no upside. Per-IP tracking was considered and
rejected — adds a bounded Map + LRU for defense already adequate at the
global layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(security): add tunnel-denial-log module for attack visibility
Append-only log of tunnel-surface auth denials to
~/.gstack/security/attempts.jsonl. Gives operators visibility into who
is probing tunneled daemons so the next security wave can be driven by
real attack data instead of speculation.
Design notes:
- Async via fs.promises.appendFile. Never appendFileSync — blocking the
event loop on every denial during a flood is what an attacker wants
(prior learning: sync-audit-log-io, 10/10 confidence).
- In-process rate cap at 60 writes/minute globally. Excess denials are
counted in memory but not written to disk — prevents disk DoS.
- Writes to the same ~/.gstack/security/attempts.jsonl used by the
prompt-injection attempt log. File rotation is handled by the existing
security pipeline (10MB, 5 generations).
No consumers in this commit; wired up in the dual-listener refactor that
follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(security): dual-listener tunnel architecture
The /health endpoint leaked AUTH_TOKEN to any caller that hit the ngrok
URL (spoofing chrome-extension:// origin, or catching headed mode).
Surfaced by @garagon in PR #1026; the original fix was header-inference
on the single port. Codex's outside-voice review during /plan-ceo-review
called that approach brittle (ngrok header behavior could change, local
proxies would false-positive), and pushed for the structural fix.
This is that fix. Stop making /health a root-token bootstrap endpoint on
any surface the tunnel can reach. The server now binds two HTTP
listeners when a tunnel is active. The local listener (extension, CLI,
sidebar) stays on 127.0.0.1 and is never exposed to ngrok. ngrok
forwards only to the tunnel listener, which serves only /connect
(unauth, rate-limited) and /command with a locked allowlist of
browser-driving commands. Security property comes from physical port
separation, not from header inference — a tunnel caller cannot reach
/health or /cookie-picker or /inspector because they live on a
different TCP socket.
What this commit adds to browse/src/server.ts:
* Surface type ('local' | 'tunnel') and TUNNEL_PATHS +
TUNNEL_COMMANDS allowlists near the top of the file.
* makeFetchHandler(surface) factory replacing the single fetch arrow;
closure-captures the surface so the filter that runs before route
dispatch knows which socket accepted the request.
* Tunnel filter at dispatch entry: 404s anything not on TUNNEL_PATHS,
403s root-token bearers with a clear pairing hint, 401s non-/connect
requests that lack a scoped token. Every denial is logged via
logTunnelDenial (from tunnel-denial-log).
* GET /connect alive probe (unauth on both surfaces) so /pair and
/tunnel/start can detect dead ngrok tunnels without reaching
/health — /health is no longer tunnel-reachable.
* Lazy tunnel listener lifecycle. /tunnel/start binds a dedicated
Bun.serve on an ephemeral port, points ngrok.forward at THAT port
(not the local port), hard-fails on bind error (no local fallback),
tears down cleanly on ngrok failure. BROWSE_TUNNEL=1 startup uses
the same pattern.
* closeTunnel() helper — single teardown path for both the ngrok
listener and the tunnel Bun.serve listener.
* resolveNgrokAuthtoken() helper — shared authtoken lookup across
/tunnel/start and BROWSE_TUNNEL=1 startup (was duplicated).
* TUNNEL_COMMANDS check in /command dispatch: on the tunnel surface,
commands outside the allowlist return 403 with a list of allowed
commands as a hint.
* Probe paths in /pair and /tunnel/start migrated from /health to
GET /connect — the only unauth path reachable on the tunnel surface
under the new architecture.
Test updates in browse/test/server-auth.test.ts:
* /pair liveness-verify test: assert via closeTunnel() helper instead
of the inline `tunnelActive = false; tunnelUrl = null` lines that
the helper subsumes.
* /tunnel/start cached-tunnel test: same closeTunnel() adaptation.
Credit
Derived from PR #1026 by @garagon — thanks for flagging the critical
bug that drove the architectural rewrite. The per-request
isTunneledRequest approach from #1026 is superseded by physical port
separation here; the underlying report remains the root cause for the
entire v1.6.0.0 wave.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(security): add source-level guards for dual-listener architecture
23 source-level assertions that keep future contributors from silently
widening the tunnel surface during a routine refactor. Covers:
* Surface type + tunnelServer state variable shape
* TUNNEL_PATHS is a closed set of /connect, /command, /sidebar-chat
(and NOT /health, /welcome, /cookie-picker, /inspector/*, /pair,
/token, /refs, /activity/stream, /tunnel/{start,stop})
* TUNNEL_COMMANDS includes browser-driving ops only (and NOT
launch-browser, tunnel-start, token-mint, cookie-import, etc.)
* makeFetchHandler(surface) factory exists and is wired to both
listeners with the correct surface parameter
* Tunnel filter runs BEFORE any route dispatch, with 404/403/401
responses and logged denials for each reason
* GET /connect returns {alive: true} unauth
* /command dispatch enforces TUNNEL_COMMANDS on tunnel surface
* closeTunnel() helper tears down ngrok + Bun.serve listener
* /tunnel/start binds on ephemeral port, points ngrok at TUNNEL_PORT
(not local port), hard-fails on bind error (no fallback), probes
cached tunnel via GET /connect (not /health), tears down on
ngrok.forward failure
* BROWSE_TUNNEL=1 startup uses the dual-listener pattern
* logTunnelDenial wired for all three denial reasons
* /connect rate limit is 300/min, not 3/min
All 23 tests pass. Behavioral integration tests (spawn subprocess, real
network) live in the E2E suite that lands later in this wave.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security: gate download + scrape through validateNavigationUrl (SSRF)
The `goto` command was correctly wired through validateNavigationUrl,
but `download` and `scrape` called page.request.fetch(url, ...) directly.
A caller with the default write scope could hit the /command endpoint
and ask the daemon to fetch http://169.254.169.254/latest/meta-data/
(AWS IMDSv1) or the GCP/Azure/internal equivalents. The response body
comes back as base64 or lands on disk where GET /file serves it.
Fix: call validateNavigationUrl(url) immediately before each
page.request.fetch() call site in download and in the scrape loop.
Same blocklist that already protects `goto`: file://, javascript:,
data:, chrome://, cloud metadata (IPv4 all encodings, IPv6 ULA,
metadata.*.internal).
Tests: extend browse/test/url-validation.test.ts with a source-level
guard that walks every `await page.request.fetch(` call site and
asserts a validateNavigationUrl call precedes it within the same
branch. Regression trips before code review if a future refactor
drops the gate.
* security: route splitForScoped through envelope sentinel escape
The scoped-token snapshot path in snapshot.ts built its untrusted
block by pushing the raw accessibility-tree lines between the literal
`═══ BEGIN UNTRUSTED WEB CONTENT ═══` / `═══ END UNTRUSTED WEB CONTENT ═══`
sentinels. The full-page wrap path in content-security.ts already
applied a zero-width-space escape on those exact strings to prevent
sentinel injection, but the scoped path skipped it.
Net effect: a page whose rendered text contains the literal sentinel
can close the envelope early from inside untrusted content and forge
a fake "trusted" block for the LLM. That includes fabricating
interactive `@eN` references the agent will act on.
Fix:
* Extract the zero-width-space escape into a named, exported helper
`escapeEnvelopeSentinels(content)` in content-security.ts.
* Have `wrapUntrustedPageContent` call it (behavior unchanged on
that path — same bytes out).
* Import the helper in snapshot.ts and map it over `untrustedLines`
in the `splitForScoped` branch before pushing the BEGIN sentinel.
Tests: add a describe block in content-security.test.ts that covers
* `escapeEnvelopeSentinels` defuses BEGIN and END markers;
* `escapeEnvelopeSentinels` leaves normal text untouched;
* `wrapUntrustedPageContent` still emits exactly one real envelope
pair when hostile content contains forged sentinels;
* snapshot.ts imports the helper;
* the scoped-snapshot branch calls `escapeEnvelopeSentinels` before
pushing the BEGIN sentinel (source-level regression — if a future
refactor reorders this, the test trips).
* security: extend hidden-element detection to all DOM-reading channels
The Confusion Protocol envelope wrap (`wrapUntrustedPageContent`)
covers every scoped PAGE_CONTENT_COMMAND, but the hidden-element
ARIA-injection detection layer only ran for `text`. Other DOM-reading
channels (html, links, forms, accessibility, attrs, data, media,
ux-audit) returned their output through the envelope with no hidden-
content filter, so a page serving a display:none div that instructs
the agent to disregard prior system messages, or an aria-label that
claims to put the LLM in admin mode, leaked the injection payload on
any non-text channel. The envelope alone does not mitigate this, and
the page itself never rendered the hostile content to the human
operator.
Fix:
* New export `DOM_CONTENT_COMMANDS` in commands.ts — the subset of
PAGE_CONTENT_COMMANDS that derives its output from the live DOM.
Console and dialog stay out; they read separate runtime state.
* server.ts runs `markHiddenElements` + `cleanupHiddenMarkers` for
every scoped command in this set. `text` keeps its existing
`getCleanTextWithStripping` path (hidden elements physically
stripped before the read). All other channels keep their output
format but emit flagged elements as CONTENT WARNINGS on the
envelope, so the LLM sees what it would otherwise have consumed
silently.
* Hidden-element descriptions merge into `combinedWarnings`
alongside content-filter warnings before the wrap call.
Tests: new describe block in content-security.test.ts covering
* `DOM_CONTENT_COMMANDS` export shape and channel membership;
* dispatch gates on `DOM_CONTENT_COMMANDS.has(command)`, not the
literal `text` string;
* hiddenContentWarnings plumbs into `combinedWarnings` and reaches
wrapUntrustedPageContent;
* DOM_CONTENT_COMMANDS is a strict subset of PAGE_CONTENT_COMMANDS.
Existing datamarking, envelope wrap, centralized-wrapping, and chain
security suites stay green (52 pass, 0 fail).
* security: validate --from-file payload paths for parity with direct paths
The direct `load-html <file>` path runs every caller-supplied file path
through validateReadPath() so reads stay confined to SAFE_DIRECTORIES
(cwd, TEMP_DIR). The `load-html --from-file <payload.json>` shortcut
and its sibling `pdf --from-file <payload.json>` skipped that check and
went straight to fs.readFileSync(). An MCP caller that picks the
payload path (or any caller whose payload argument is reachable from
attacker-influenced text) could use --from-file as a read-anywhere
escape hatch for the safe-dirs policy.
Fix: call validateReadPath(path.resolve(payloadPath)) before readFileSync
at both sites. Error surface mirrors the direct-path branch so ops and
agent errors stay consistent.
Test coverage in browse/test/from-file-path-validation.test.ts:
- source-level: validateReadPath precedes readFileSync in the load-html
--from-file branch (write-commands.ts) and the pdf --from-file parser
(meta-commands.ts)
- error-message parity: both sites reference SAFE_DIRECTORIES
Related security audit pattern: R3 F002 (validateNavigationUrl gap on
download/scrape) and R3 F008 (markHiddenElements gap on 10 DOM commands)
were the same shape — a defense that existed on the primary code path
but not its shortcut sibling. This PR closes the same class of gap on
the --from-file shortcuts.
* fix(design): escape url.origin when injecting into served HTML
serve.ts injected url.origin into a single-quoted JS string in
the response body. A local request with a crafted Host header
(e.g. Host: "evil'-alert(1)-'x") would break out of the string
and execute JS in the 127.0.0.1:<port> origin opened by the
design board. Low severity — bound to localhost, requires a
local attacker — but no reason not to escape.
Fix: JSON.stringify(url.origin) produces a properly quoted,
escaped JS string literal in one call.
Also includes Prettier reformatting (single→double quotes,
trailing commas, line wrapping) applied by the repo's
PostToolUse formatter hook. Security change is the one line
in the HTML injection; everything else is whitespace/style.
* fix(scripts): drop shell:true from slop-diff npx invocations
spawnSync('npx', [...], { shell: true }) invokes /bin/sh -c
with the args concatenated, subjecting them to shell parsing
(word splitting, glob expansion, metacharacter interpretation).
No user input reaches these calls today, so not exploitable —
but the posture is wrong: npx + shell args should be direct.
Fix: scope shell:true to process.platform === 'win32' where
npx is actually a .cmd requiring the shell. POSIX runs the
npx binary directly with array-form args.
Also includes Prettier reformatting (single→double quotes,
trailing commas, line wrapping) applied by the repo's
PostToolUse formatter hook. Security-relevant change is just
the two shell:true -> shell: process.platform === 'win32'
lines; everything else is whitespace/style.
* security(E3): gate GSTACK_SLUG on /welcome path traversal
The /welcome handler interpolates GSTACK_SLUG directly into the filesystem
path used to locate the project-local welcome page. Without validation, a
slug like "../../etc/passwd" would resolve to
~/.gstack/projects/../../etc/passwd/designs/welcome-page-20260331/finalized.html
— classic path traversal.
Not exploitable today: GSTACK_SLUG is set by the gstack CLI at daemon launch,
and an attacker would already need local env-var access to poison it. But
the gate is one regex (^[a-z0-9_-]+$), and a defense-in-depth pass costs us
nothing when the cost of being wrong is arbitrary file read via /welcome.
Fall back to the safe 'unknown' literal when the slug fails validation —
same fallback the code already uses when GSTACK_SLUG is unset. No behavior
change for legitimate slugs (they all match the regex).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(N1): replace ?token= SSE auth with HttpOnly session cookie
Activity stream and inspector events SSE endpoints accepted the root
AUTH_TOKEN via `?token=` query param (EventSource can't send Authorization
headers). URLs leak to browser history, referer headers, server logs,
crash reports, and refactoring accidents. Codex flagged this during the
/plan-ceo-review outside voice pass.
New auth model: the extension calls POST /sse-session with a Bearer token
and receives a view-only session cookie (HttpOnly, SameSite=Strict, 30-min
TTL). EventSource is opened with `withCredentials: true` so the browser
sends the cookie back on the SSE connection. The ?token= query param is
GONE — no more URL-borne secrets.
Scope isolation (prior learning cookie-picker-auth-isolation, 10/10
confidence): the SSE session cookie grants access to /activity/stream and
/inspector/events ONLY. The token is never valid against /command, /token,
or any mutating endpoint. A leaked cookie can watch activity; it cannot
execute browser commands.
Components
* browse/src/sse-session-cookie.ts — registry: mint/validate/extract/
build-cookie. 256-bit tokens, 30-min TTL, lazy expiry pruning,
no imports from token-registry (scope isolation enforced by module
boundary).
* browse/src/server.ts — POST /sse-session mint endpoint (requires
Bearer). /activity/stream and /inspector/events now accept Bearer
OR the session cookie, and reject ?token= query param.
* extension/sidepanel.js — ensureSseSessionCookie() bootstrap call,
EventSource opened with withCredentials:true on both SSE endpoints.
Tested via the source guards; behavioral test is the E2E pairing
flow that lands later in the wave.
* browse/test/sse-session-cookie.test.ts — 20 unit tests covering
mint entropy, TTL enforcement, cookie flag invariants, cookie
parsing from multi-cookie headers, and scope-isolation contract
guard (module must not import token-registry).
* browse/test/server-auth.test.ts — existing /activity/stream auth
test updated to assert the new cookie-based gate and the absence
of the ?token= query param.
Cookie flag choices:
* HttpOnly: token not readable from page JS (mitigates XSS
exfiltration).
* SameSite=Strict: cookie not sent on cross-site requests (mitigates
CSRF). Fine for SSE because the extension connects to 127.0.0.1
directly.
* Path=/: cookie scoped to the whole origin.
* Max-Age=1800: 30 minutes, matches TTL. Extension re-mints on
reconnect when daemon restarts.
* Secure NOT set: daemon binds to 127.0.0.1 over plain HTTP. Adding
Secure would block the browser from ever sending the cookie back.
Add Secure when gstack ships over HTTPS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* security(N2): document Windows v20 ABE elevation path on CDP port
The existing comment around the cookie-import-browser --remote-debugging-port
launch claimed "threat model: no worse than baseline." That's wrong on
Windows with App-Bound Encryption v20. A same-user local process that
opens the cookie SQLite DB directly CANNOT decrypt v20 values (DPAPI
context is bound to the browser process). The CDP port lets them bypass
that: connect to the debug port, call Network.getAllCookies inside Chrome,
walk away with decrypted v20 cookies.
The correct fix is to switch from TCP --remote-debugging-port to
--remote-debugging-pipe so the CDP transport is a stdio pipe, not a
socket. That requires restructuring the CDP WebSocket client in this
module and Playwright doesn't expose the pipe transport out of the box.
Non-trivial, deferred from the v1.6.0.0 wave.
This commit updates the comment to correctly describe the threat and
points at the tracking issue. No code change to the launch itself.
Follow-up: #1136.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(E2): document dual-listener tunnel architecture in ARCHITECTURE.md
Adds an explicit per-endpoint disposition table to the Security model
section, covering the v1.6.0.0 dual-listener refactor. Every HTTP
endpoint now has a documented local-vs-tunnel answer. Future audits
(and future contributors wondering "is it safe to add X to the tunnel
surface?") can read this instead of reverse-engineering server.ts.
Also documents:
* Why physical port separation beats per-request header inference
(ngrok behavior drift, local proxies can forge headers, etc.)
* Tunnel surface denial logging → ~/.gstack/security/attempts.jsonl
* SSE session cookie model (gstack_sse, 30-min TTL, stream-scope only,
module-boundary-enforced scope isolation)
* N2 non-goal for Windows v20 ABE via CDP port (tracking #1136)
No code changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(E1): end-to-end pair-agent flow against a spawned daemon
Spawns the browse daemon as a subprocess with BROWSE_HEADLESS_SKIP=1 so
the HTTP layer runs without a real browser. Exercises:
* GET /health — token delivery for chrome-extension origin, withheld
otherwise (the F1 + PR #1026 invariant)
* GET /connect — alive probe returns {alive:true} unauth
* POST /pair — root Bearer required (403 without), returns setup_key
* POST /connect — setup_key exchange mints a distinct scoped token
* POST /command — 401 without auth
* POST /sse-session — Bearer required, Set-Cookie has HttpOnly +
SameSite=Strict (the N1 invariant)
* GET /activity/stream — 401 without auth
* GET /activity/stream?token= — 401 (the old ?token= query param is
REJECTED, which is the whole point of N1)
* GET /welcome — serves HTML, does not leak /etc/passwd content under
the default 'unknown' slug (E3 regex gate)
12 behavioral tests, ~220ms end-to-end, no network dependencies, no
ngrok, no real browser. This is the receipt for the wave's central
'pair-agent still works + the security boundary holds' claim.
Tunnel-port binding (/tunnel/start) is deliberately NOT exercised here
— it requires an ngrok authtoken and live network. The dual-listener
route allowlist is covered by source-level guards in
dual-listener.test.ts; behavioral tunnel testing belongs in a separate
paid-evals harness.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* release(v1.6.0.0): bump VERSION + CHANGELOG for security wave
Architectural bump, not patch: dual-listener HTTP refactor changes the
daemon's tunnel-exposure model. See CHANGELOG for the full release
summary (~950 words) covering the five root causes this wave closes:
1. /health token leak over ngrok (F1 + E3 + test infra)
2. /cookie-picker + /inspector exposed over the tunnel (F1)
3. ?token=<ROOT> in SSE URLs leaking to logs/referer/history (N1)
4. /welcome GSTACK_SLUG path traversal (E3)
5. Windows v20 ABE elevation via CDP port (N2 — documented non-goal,
tracked as #1136)
Plus the base PRs: SSRF gate (#1029), envelope sentinel escape (#1031),
DOM-channel hidden-element coverage (#1032), --from-file path validation
(#1103), and 2 commits from #1073 (@theqazi).
VERSION + package.json bumped to 1.6.0.0. CHANGELOG entry covers
credits (@garagon, @Hybirdss, @HMAKT99, @theqazi), review lineage (CEO
→ Codex outside voice → Eng), and the non-goal tracking issue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: pre-landing review findings (4 auto-fixes)
Addresses 4 findings from the Claude adversarial subagent on the
v1.6.0.0 security wave diff. No user-visible behavior change; all
are defense-in-depth hardening of newly-introduced code.
1. GET /connect rate-limited (was POST-only) [HIGH conf 8/10]
Attacker discovering the ngrok URL could probe unlimited GETs for
daemon enumeration. Now shares the global /connect counter.
2. ngrok listener leak on tunnel startup failure [MEDIUM conf 8/10]
If ngrok.forward() resolved but tunnelListener.url() or the
state-file write threw, the Bun listener was torn down but the
ngrok session was leaked. Fixed in BOTH /tunnel/start and
BROWSE_TUNNEL=1 startup paths.
3. GSTACK_SKILL_ROOT path-traversal gate [MEDIUM conf 8/10]
Symmetric with E3's GSTACK_SLUG regex gate — reject values
containing '..' before interpolating into the welcome-page path.
4. SSE session registry pruning [LOW conf 7/10]
pruneExpired() only checked 10 entries per mint call. Now runs
on every validate too, checks 20 entries, with a hard 10k cap as
backstop. Prevents registry growth under sustained extension
reconnect pressure.
Tests remain green (56/56 in sse-session-cookie + dual-listener +
pair-agent-e2e suites).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update project documentation for v1.6.0.0
Reflect the dual-listener tunnel architecture, SSE session cookies,
SSRF guards, and Windows v20 ABE non-goal across the three docs
users actually read for remote-agent and browser auth context:
- docs/REMOTE_BROWSER_ACCESS.md: rewrote Architecture diagram for
dual listeners, fixed /connect rate limit (3/min → 300/min),
removed stale "/health requires no auth" (now 404 on tunnel),
added SSE cookie auth, expanded Security Model with tunnel
allowlist, SSRF guards, /welcome path traversal defense, and
the Windows v20 ABE tracking note.
- BROWSER.md: added dual-listener paragraph to Authentication and
linked to ARCHITECTURE.md endpoint table. Replaced the stale
?token= SSE auth note with the HttpOnly gstack_sse cookie flow.
- CLAUDE.md: added Transport-layer security section above the
sidebar prompt-injection stack so contributors editing server.ts,
sse-session-cookie.ts, or tunnel-denial-log.ts see the load-bearing
module boundaries before touching them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(make-pdf): write --from-file payload to /tmp, not os.tmpdir()
make-pdf's browseClient wrote its --from-file payload to os.tmpdir(),
which is /var/folders/... on macOS. v1.6.0.0's PR #1103 cherry-pick
tightened browse load-html --from-file to validate against the
safe-dirs allowlist ([TEMP_DIR, cwd] where TEMP_DIR is '/tmp' on
macOS/Linux, os.tmpdir() on Windows). This closed a CLI/API parity
gap but broke make-pdf on macOS because /var/folders/... is outside
the allowlist.
Fix: mirror browse's TEMP_DIR convention — use '/tmp' on non-Windows,
os.tmpdir() on Windows. The make-pdf-gate CI failure on macOS-latest
(run 72440797490) is caused by exactly this: the payload file was
rejected by validateReadPath.
Verified locally: the combined-gate e2e test now passes after
rebuilding make-pdf/dist/pdf.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(sidebar): killAgent resets per-tab state; align tests with current agent event format
Two pre-existing bugs surfaced while running the full e2e suite on the
sec-wave branch. Both pre-date v1.6.0.0 (same failures on main at
e23ff280) but blocked the ship verification, so fixing now.
### Bug 1: killAgent leaked stale per-tab state
`killAgent()` reset the legacy globals (agentProcess, agentStatus,
etc.) but never touched the per-tab `tabAgents` Map. Meanwhile
`/sidebar-command` routes on `tabState.status` from that Map, not the
legacy globals. Consequence: after a kill (including the implicit
kill in `/sidebar-session/new`), the next /sidebar-command on the
same tab saw `tabState.status === 'processing'` and fell into the
queue branch, silently NOT spawning an agent. Integration tests that
called resetState between cases all failed with empty queues.
Fix: when targetTabId is supplied, reset that one tab's state; when
called without a tab (session-new, full kill), reset ALL tab states.
Matches the semantic boundary already used for the cancel-file write.
### Bug 2: sidebar-integration tests drifted from current event format
`agent events appear in /sidebar-chat` posted the raw Claude streaming
format (`{type: 'assistant', message: {content: [...]}}`) but
`processAgentEvent` in server.ts only handles the simplified types
that sidebar-agent.ts pre-processes into (text, text_delta, tool_use,
result, agent_error, security_event). The architecture moved
pre-processing into sidebar-agent.ts at some point and this test
never got updated. Fixed by sending the pre-processed `{type:
'text', text: '...'}` format — which is actually what the server sees
in production.
Also removed the `entry.prompt` URL-containment check in the
queue-write test. The URL is carried on entry.pageUrl (metadata) by
design: the system prompt tells Claude to run `browse url` to fetch
the actual page rather than trust any URL in the prompt body. That's
the URL-based prompt-injection defense. The prompt SHOULD NOT
contain the URL, so the test assertion was wrong for the current
security posture.
### Verification
- `bun test browse/test/sidebar-integration.test.ts` → 13/13 pass
(was 6/13 on both main and branch before this commit)
- Full `bun run test` → exit 0, zero fail markers
- No behavior change for production sidebar flows: killAgent was
already supposed to return the agent to idle; it just wasn't fully
doing so. Per-tab reset now matches the documented semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: gus <gustavoraularagon@gmail.com>
Co-authored-by: Mohammed Qazi <10266060+theqazi@users.noreply.github.com>
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