diff --git a/BROWSER.md b/BROWSER.md index fa87a416..559a6513 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -197,7 +197,11 @@ POST /batch → [{"command": "text", "tabId": 5}, {"command": "text", "tabId": 6 ### Authentication -Each server session generates a random UUID as a bearer token. The token is written to the state file (`.gstack/browse.json`) with chmod 600. Every HTTP request must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. +Each server session generates a random UUID as a bearer token. The token is written to the state file (`.gstack/browse.json`) with chmod 600. Every HTTP request that mutates browser state must include `Authorization: Bearer `. This prevents other processes on the machine from controlling the browser. + +**Dual-listener mode (v1.6.0.0+).** When `pair-agent` activates an ngrok tunnel, the daemon binds a second HTTP socket that serves only `/connect`, `/command` (scoped tokens + a 17-command browser-driving allowlist), and `/sidebar-chat`. The tunnel listener is the only port ngrok forwards; `/health`, `/cookie-picker`, `/inspector/*`, and `/welcome` stay local-only. Root tokens sent over the tunnel return 403. See [ARCHITECTURE.md](ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) for the full endpoint table. + +SSE endpoints (`/activity/stream`, `/inspector/events`) accept the Bearer token OR the HttpOnly `gstack_sse` session cookie (30-minute stream-scope cookie minted by `POST /sse-session`). The `?token=` query-param auth is no longer supported. ### Console, network, and dialog capture diff --git a/CLAUDE.md b/CLAUDE.md index ad448f3d..d683b907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,19 @@ failure modes. The sidebar spans 5 files across 2 codebases (extension + server) with non-obvious ordering dependencies. The doc exists to prevent the kind of silent failures that come from not understanding the cross-component flow. +**Transport-layer security** (v1.6.0.0+). When `pair-agent` starts an ngrok tunnel, +the daemon binds two HTTP listeners: a local listener (127.0.0.1, full command +surface, never forwarded) and a tunnel listener (locked allowlist: `/connect`, +`/command` with a scoped token + 17-command browser-driving allowlist, +`/sidebar-chat`). ngrok forwards only the tunnel port. Root tokens over the tunnel +return 403. SSE endpoints use a 30-minute HttpOnly `gstack_sse` cookie minted via +`POST /sse-session` (never valid against `/command`). Tunnel-surface rejections go +to `~/.gstack/security/attempts.jsonl` via `tunnel-denial-log.ts`. Before editing +`server.ts`, `sse-session-cookie.ts`, or `tunnel-denial-log.ts`, read +[ARCHITECTURE.md](ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) — +the module boundary (no imports from `token-registry.ts` into `sse-session-cookie.ts`) +is load-bearing for scope isolation. + **Sidebar security stack** (layered defense against prompt injection): | Layer | Module | Lives in | diff --git a/docs/REMOTE_BROWSER_ACCESS.md b/docs/REMOTE_BROWSER_ACCESS.md index c7d22ca1..e7386ffa 100644 --- a/docs/REMOTE_BROWSER_ACCESS.md +++ b/docs/REMOTE_BROWSER_ACCESS.md @@ -14,15 +14,28 @@ Your Machine Remote Agent ───────────── ──────────── GStack Browser Server Any AI agent ├── Chromium (Playwright) (OpenClaw, Hermes, Codex, etc.) - ├── HTTP API on localhost:PORT │ - ├── ngrok tunnel (optional) │ - │ https://xxx.ngrok.dev ─────────────┘ + ├── Local listener 127.0.0.1:LOCAL │ + │ (bootstrap, CLI, sidebar, cookies) │ + ├── Tunnel listener 127.0.0.1:TUNNEL ◄───────┤ + │ (pair-agent only: /connect, /command, │ + │ /sidebar-chat — locked allowlist) │ + ├── ngrok tunnel (forwards tunnel port only) │ + │ https://xxx.ngrok.dev ─────────────────┘ └── Token Registry - ├── Root token (local only) + ├── Root token (local listener only) ├── Setup keys (5 min, one-time) - └── Session tokens (24h, scoped) + ├── Session tokens (24h, scoped) + └── SSE session cookies (30 min, stream-scope) ``` +### Dual-listener architecture (v1.6.0.0) + +The daemon binds two HTTP sockets. The **local listener** serves the full command surface to 127.0.0.1 only and is never forwarded. The **tunnel listener** is bound lazily on `/tunnel/start` (and torn down on `/tunnel/stop`) with a locked path allowlist. ngrok forwards only the tunnel port. + +A caller who stumbles onto your ngrok URL cannot reach `/health`, `/cookie-picker`, `/inspector/*`, or `/welcome` — those paths don't exist on that TCP socket. Root tokens sent over the tunnel get 403. The tunnel listener accepts only `/connect`, `/command` (with a scoped token + the 17-command browser-driving allowlist), and `/sidebar-chat`. + +See [ARCHITECTURE.md](../ARCHITECTURE.md#dual-listener-tunnel-architecture-v1600) for the full endpoint table. + ## Connection Flow 1. **User runs** `$B pair-agent` (or `/pair-agent` in Claude Code) @@ -37,16 +50,20 @@ GStack Browser Server Any AI agent ### Authentication -All endpoints except `/connect` and `/health` require a Bearer token: +All command endpoints require a Bearer token: ``` Authorization: Bearer gsk_sess_... ``` +`/connect` is unauthenticated (rate-limited) — it's how a remote agent exchanges a setup key for a scoped session token. `/health` is unauthenticated on the local listener (bootstrap) but does NOT exist on the tunnel listener (404). + +SSE endpoints (`/activity/stream`, `/inspector/events`) accept either a Bearer token or the HttpOnly `gstack_sse` cookie (minted via `POST /sse-session`, 30-minute TTL, stream-scope only — cannot be used against `/command`). As of v1.6.0.0 the `?token=` query-string auth is no longer accepted. + ### Endpoints #### POST /connect -Exchange a setup key for a session token. No auth required. Rate-limited to 3/minute. +Exchange a setup key for a session token. No auth required. Rate-limited to 300/minute (flood defense — setup keys are 24 random bytes, unbruteforceable). ```json Request: {"setup_key": "gsk_setup_..."} @@ -147,12 +164,21 @@ Each agent owns the tabs it creates. Rules: ## Security Model -- Setup keys expire in 5 minutes and can only be used once -- Session tokens expire in 24 hours (configurable) -- The root token never appears in instruction blocks or connection strings -- Admin scope (JS execution, cookie access) is denied by default +- **Physical port separation.** Local listener and tunnel listener are separate TCP sockets. ngrok only forwards the tunnel port. Tunnel callers cannot reach bootstrap endpoints at all (404, wrong port). +- **Tunnel command allowlist.** `/command` over the tunnel only accepts 17 browser-driving commands (goto, click, fill, snapshot, text, etc.). Server-management commands (tunnel, pair, token, useragent, eval, js) are denied on the tunnel. +- **Root token is tunnel-blocked.** A request bearing the root token over the tunnel listener returns 403 with a pairing hint. Only scoped session tokens work over the tunnel. +- **Setup keys** expire in 5 minutes and can only be used once. +- **Session tokens** expire in 24 hours (configurable). +- The root token never appears in instruction blocks or connection strings. +- **Admin scope** (JS execution, cookie access) is denied by default. - Tokens can be revoked instantly: `$B tunnel revoke agent-name` -- All agent activity is logged with attribution (clientId) +- **SSE auth** uses a 30-minute HttpOnly SameSite=Strict cookie, stream-scope only (never valid against `/command`). +- **Path traversal guarded** on `/welcome` — `GSTACK_SLUG` must match `^[a-z0-9_-]+$` or falls back to the built-in template. +- **SSRF guards** on `goto`, `download`, and scrape paths — validates URL target against a localhost/private-range blocklist. +- **Tunnel surface denial logging.** Every rejection on the tunnel listener (`path_not_on_tunnel`, `root_token_on_tunnel`, `missing_scoped_token`, `disallowed_command:*`) is appended to `~/.gstack/security/attempts.jsonl` with timestamp, source IP, path, method. Rate-capped at 60 writes/min. +- All agent activity is logged with attribution (clientId). + +**Known non-goal (tracked as #1136):** on Windows, the cookie-import-browser path launches Chrome with `--remote-debugging-port=`. With App-Bound Encryption v20, a same-user local process can connect to that port and exfiltrate decrypted v20 cookies — an elevation path relative to reading the SQLite DB directly. Fix direction is `--remote-debugging-pipe` instead of TCP. ## Same-Machine Shortcut