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>
This commit is contained in:
Garry Tan
2026-04-21 21:11:55 -07:00
parent c7583f7f02
commit 3bff673671
3 changed files with 56 additions and 13 deletions
+5 -1
View File
@@ -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 <token>`. 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 <token>`. 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=<ROOT>` query-param auth is no longer supported.
### Console, network, and dialog capture
+13
View File
@@ -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 |
+38 -12
View File
@@ -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=<ROOT>` 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=<random>`. 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