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: