Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/autoplan/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/benchmark-models/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/benchmark/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/browse/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/canary/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/careful/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/codex/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/connect-chrome/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/context-restore/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/context-save/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/cso/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/design-consultation/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/design-html/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/design-review/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/design-shotgun/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/devex-review/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/document-release/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/freeze/SKILL.md
2 changes: 1 addition & 1 deletion .claude/skills/gstack-upgrade/SKILL.md
41 changes: 38 additions & 3 deletions .claude/skills/gstack/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<random>`. 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 <token>`. 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 <token>`. 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

Expand Down
6 changes: 5 additions & 1 deletion .claude/skills/gstack/BROWSER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading