diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 25c232f1..1cbd5289 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -83,13 +83,48 @@ The build writes `git rev-parse HEAD` to `browse/dist/.version`. On each CLI inv ### Localhost only -The HTTP server binds to `localhost`, not `0.0.0.0`. It's not reachable from the network. +The HTTP server binds to `127.0.0.1`, not `0.0.0.0`. It's not reachable from the network. + +### Dual-listener tunnel architecture (v1.6.0.0) + +When a user runs `pair-agent --client`, the daemon starts an ngrok tunnel so a remote paired agent can drive the browser. Exposing the full daemon surface to the internet (even behind a random ngrok subdomain) meant `/health` leaked the root token on any Origin spoof, and `/cookie-picker` embedded the token into HTML that any caller could fetch. + +The fix is **two HTTP listeners**, not one: + +- **Local listener** (`127.0.0.1:LOCAL_PORT`) — always bound. Serves bootstrap (`/health` with token delivery), `/cookie-picker`, `/inspector/*`, `/welcome`, `/refs`, the sidebar-agent API, and the full command surface. Never forwarded. +- **Tunnel listener** (`127.0.0.1:TUNNEL_PORT`) — bound lazily on `/tunnel/start`, torn down on `/tunnel/stop`. Serves a locked allowlist: `/connect` (pairing ceremony, unauth + rate-limited), `/command` (scoped tokens only, further restricted to a browser-driving command allowlist), and `/sidebar-chat`. Everything else 404s. + +ngrok forwards only the tunnel port. The security property comes from **physical port separation**: a tunnel caller cannot reach `/health` or `/cookie-picker` because those paths don't exist on that TCP socket. Header inference (check `x-forwarded-for`, check origin) is unreliable (ngrok header behavior changes; local proxies can add these headers); socket separation isn't. + +| Endpoint | Local listener | Tunnel listener | Notes | +|---|---|---|---| +| `GET /health` | public (no token unless headed/extension) | 404 | Token bootstrap for extension happens locally only | +| `GET /connect` | public (`{alive:true}`) | public (`{alive:true}`) | Probe path for tunnel liveness | +| `POST /connect` | public (rate-limited 300/min) | public (rate-limited) | Setup-key exchange for pair-agent | +| `POST /command` | auth (Bearer root OR scoped) | auth (scoped only, allowlisted commands) | Root token on tunnel = 403 | +| `POST /sidebar-chat` | auth | auth | Lets remote agent post into local sidebar | +| `POST /pair` | root-only | 404 | Pairing mint — local operator action | +| `POST /tunnel/{start,stop}` | root-only | 404 | Daemon configuration | +| `POST /token`, `DELETE /token/:id` | root-only | 404 | Scoped token mint/revoke | +| `GET /cookie-picker`, `GET /cookie-picker/*` | public UI, auth API | 404 | Local-only — reads local browser DBs | +| `GET /inspector`, `/inspector/events`, etc. | auth | 404 | Extension callback, local-only | +| `GET /welcome` | public | 404 | GStack Browser landing page, local-only | +| `GET /refs` | auth | 404 | Ref map — internal state | +| `GET /activity/stream` | Bearer OR HttpOnly `gstack_sse` cookie | 404 | SSE. ?token= query param no longer accepted | +| `GET /inspector/events` | Bearer OR HttpOnly `gstack_sse` cookie | 404 | SSE. Same cookie as /activity/stream | +| `POST /sse-session` | auth (Bearer) | 404 | Mints the view-only 30-min SSE session cookie | + +**Tunnel surface denial logs.** Every rejection on the tunnel listener (`path_not_on_tunnel`, `root_token_on_tunnel`, `missing_scoped_token`, `disallowed_command:*`) is recorded asynchronously to `~/.gstack/security/attempts.jsonl` with timestamp, source IP (from `x-forwarded-for`), path, and method. Rate-capped at 60 writes/min globally to prevent log-flood DoS. Shares the attempt log with the prompt-injection scanner. + +**SSE session cookies.** EventSource can't send Authorization headers, so the extension POSTs `/sse-session` once at bootstrap with the root Bearer and receives a 30-minute view-only cookie (`gstack_sse`, HttpOnly, SameSite=Strict). The cookie is valid ONLY for `/activity/stream` and `/inspector/events` — it is NOT a scoped token and cannot be used on `/command`. Scope isolation is enforced by the module boundary: `sse-session-cookie.ts` has no imports from `token-registry.ts`. + +**Non-goal in this wave** (tracked as #1136): the cookie-import-browser path launches Chrome with `--remote-debugging-port=`. On Windows 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 (which can't decrypt v20 without DPAPI context). Fix direction is `--remote-debugging-pipe` instead of TCP; requires restructuring the CDP client. ### Bearer token auth -Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Every HTTP request must include `Authorization: Bearer `. If the token doesn't match, the server returns 401. +Every server session generates a random UUID token, written to the state file with mode 0o600 (owner-only read). Every HTTP request that mutates browser state must include `Authorization: Bearer `. If the token doesn't match, the server returns 401. -This prevents other processes on the same machine from talking to your browse server. The cookie picker UI (`/cookie-picker`) and health check (`/health`) are exempt — they're localhost-only and don't execute commands. +This prevents other processes on the same machine from talking to your browse server. The cookie picker UI (`/cookie-picker`) and health check (`/health`) are exempt on the local listener — they're 127.0.0.1-bound and don't execute commands. On the tunnel listener nothing is exempt except `/connect`. ### Cookie security