docs: web-tab egress guard, capability revocation and referrer policy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
This commit is contained in:
Codeman maintainer
2026-09-04 15:21:15 +02:00
parent 2ab21c1b32
commit eeb5f9d0b2
5 changed files with 33 additions and 10 deletions
+4
View File
@@ -247,6 +247,10 @@ Invariants:
**The capability, and why the auth exemption is safe.** A sandboxed iframe (no `allow-same-origin`) is OPAQUE-ORIGIN, so every request it makes is cross-site: the `SameSite=lax` `codeman_session` cookie is never attached, and writes and WS upgrades arrive with `Origin: null`, which `isAllowedRequestOrigin` rejects by design. Cookie auth therefore cannot work. `src/webview-capabilities.ts` mints a 192-bit `randomBytes` token (memory-only, so a restart invalidates every outstanding one; rolling TTL; bound to the minting user; revoked on edit/delete) which `middleware/auth.ts` recognizes via `hasValidWebviewCapability()` to skip the cookie and Origin checks. ⚠️ The **Host allowlist is never bypassed**, so DNS-rebinding protection is intact. ⚠️ There is a second, `Referer`-keyed form of the exemption for root-absolute assets that `<base href>` cannot rewrite (`fetch('/api/data')`, `url(/img.png)` in a stylesheet); it is the only exemption decided by a request-supplied header, so it is fenced to **safe methods on paths that resolve to NO registered Codeman route** (`matchesRegisteredRoute`), plus a blanket refusal of `/ws/` and `/q/`. Without that fence a page could present a webview Referer and skip auth on a real API route. ⚠️ The fence uses `findRoute()`, NOT `hasRoute()`: `hasRoute` matches the registered PATTERN literally, so `/api/sessions/abc` reports false against `/api/sessions/:id` and would hand out an exemption on a live route. It also has to treat `@fastify/static`'s root catch-all (mounted at `/`, matches everything) as "no real route", which is detectable because a root catch-all is the only route whose `*` param equals the whole request path. `/api` used to be refused by prefix instead, which permanently broke dashboards serving their own assets from an `/api/...` namespace. `test/webview-auth-exemption.test.ts` pins every edge.
**Egress guard (2026-09-04).** The proxy's reach is a documented property, but `169.254.169.254` sat inside it: the URL schema accepted any http(s) host, the proxy relayed arbitrary request headers and PUT/POST, and a `curl` PoC pulled an IMDSv2-shaped request through to a loopback echo server with no cookie. `src/web/webview-egress-policy.ts` (pure) refuses link-local and the fixed cloud-metadata addresses, and ONLY those: loopback and RFC1918 stay allowed because a `localhost` Grafana is the feature (`test/webview-proxy.test.ts` pins `127.0.0.1:4000` as valid). ⚠️ It is applied at three stages and each is load-bearing: the Zod schema (a clear refusal at save time), a synchronous hostname check on every connect (Node's `net.connect` skips DNS for an IP literal, so a lookup hook never sees one), and a `lookup` hook (`src/web/webview-egress.ts`) on the undici `Agent` behind `webviewFetch()` and on the `ws` client, which judges the RESOLVED addresses of a name and refuses when ANY of them is blocked (`autoSelectFamily` races the whole list). The hook is what closes rebinding: a hostname-string check alone, like the push-endpoint guard's, is bypassed by an attacker's own DNS. ⚠️ The proxy uses the `undici` PACKAGE's own `fetch` with that package's own `Agent`, never Node's global fetch with a foreign dispatcher: Node bundles its own undici, and a dispatch-protocol mismatch between package and bundle fails in ways no unit test here would see. Tests: `test/webview-egress-policy.test.ts`, `test/webview-egress.test.ts` (a real Agent against a real local server with an injected resolver), the egress block in `test/routes/webview-routes.test.ts` (schema refusal, probe refusal, and a record written straight to the store to prove the proxy re-judges).
**Capabilities die with the login.** `revokeOwner()` shipped for two releases with a docstring claiming logout called it and NO caller: the rolling TTL is refreshed on every use, so a leaked proxy URL stayed valid for as long as anything polled it. It is now called from `POST /api/logout` (own identity; `undefined` in single-user mode, i.e. everything), the admin forced-logout route and user deletion, pinned by `test/webview-capability-revocation.test.ts`. Proxied responses additionally carry `Referrer-Policy: same-origin` (the upstream's own policy is dropped): every URL inside the frame carries the capability, and a dashboard on `no-referrer-when-downgrade` or `unsafe-url` handed it to any third-party host it linked. `same-origin` keeps the Referer the 404 fallback and `refererPath` depend on, since both compare URL origins; a `<meta name="referrer">` inside the document can still override it, which is the dashboard author's call about their own page.
**Sandbox default.** The iframe carries `allow-scripts allow-forms allow-popups allow-downloads allow-modals` and gains `allow-same-origin` ONLY when the dashboard is explicitly `trusted`. A proxied page is served from Codeman's own origin, so granting it would let the dashboard read the Codeman document and drive the agent-spawning API. ⚠️ In **both** modes, `Authorization` and the `codeman_session` cookie are stripped before the upstream request (`buildUpstreamRequestHeaders`), because a trusted (same-origin) frame makes the browser attach Codeman's own Basic-auth header to every proxied request; forwarding it would hand `CODEMAN_PASSWORD` to the dashboard.
**Two things a sandboxed frame breaks that are invisible to `curl`.** Both were found only by driving a real dashboard in a real browser, and both present identically as the dashboard's own "Failed to fetch" while the page itself renders fine:
+1 -1
View File
@@ -518,7 +518,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
---
+15 -4
View File
@@ -161,10 +161,21 @@ then every API call fails, which looks like the dashboard being broken.
then streams the body without any time bound; a header timeout is logged
server-side and answered as a 502 that names the limit. WebSocket handshakes use
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
reach. That is not an escalation for someone who already commands
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
non-admin user's dashboard is fetched from the server's network position.
- **Not a security boundary, with one carve-out.** The proxy reaches whatever the
Codeman server can reach (a `localhost` dashboard is the point), so it is not an
escalation for someone who already commands `--dangerously-skip-permissions`
agents, but in multi-user mode it does mean a non-admin user's dashboard is
fetched from the server's network position. The carve-out: link-local and
cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`,
Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the
`metadata.google.internal` alias) are refused at save time AND at connect
time, judged on the address a name actually resolves to. Nothing anyone embeds
as a dashboard lives there; an instance's IAM credentials do.
- **The proxy URL is a bearer credential.** `/webview/<cap>/...` needs no cookie,
so treat it like a password. It is revoked when you log out, when an admin logs
you out, and when your account is deleted, and it expires after 12 hours
without use. Proxied responses carry `Referrer-Policy: same-origin`, so a
dashboard that links to third-party sites does not hand them the URL.
## Where the code lives