mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
docs(webview): record the lost-frame page as the third unauthenticated 200, and the inline-style limit
The lost-frame recovery page is answered ahead of the credential checks in both auth hooks, which makes it the third unauthenticated 200 beside the two hook routes, and the only one decided by request headers alone. CLAUDE.md's security table listed exactly two, and docs/web-tabs.md is not where anyone auditing that looks, so it now has a row in the table and a fourth property in docs/security-architecture.md section 10b, including the `/` carve-out and its credential-free condition. Both state the property that comes with it: a non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table, accepted because the routes are public in docs/api-reference.md. docs/web-tabs.md gains the landing-page case in layer 6 and a Known limits entry: masking trades away the Referer safety net, only HTML is rewritten server-side, and a root-absolute url() inside an inline <style> block has the masked document as its Referer, so it 404s where the Referer fallback used to rescue it. External stylesheets are unaffected. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -364,6 +364,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
| **Sessions** | 24h cookie (`codeman_session`), auto-extend, device context audit |
|
||||||
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
|
| **Rate limit** | 10 failed auth/IP → 429 (15min decay). QR and hook-secret have separate buckets, so neither can lock out login |
|
||||||
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
||||||
|
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
|
||||||
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
||||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) |
|
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) |
|
||||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ loopback bind matters. The auth pipeline (`src/web/middleware/auth.ts`,
|
|||||||
`onRequest` hook) runs in this order:
|
`onRequest` hook) runs in this order:
|
||||||
|
|
||||||
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
1. **Localhost‑only exemptions** (always first): `POST /api/hook-event` and the QR
|
||||||
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). While the
|
`/q/` short‑code path are exempt when `req.ip` is loopback (see §3). The three
|
||||||
|
web‑tab exemptions (§10b: the capability in the path, the `Referer` form, and
|
||||||
|
the lost‑frame recovery page) sit in this same slot, ahead of the credential checks. While the
|
||||||
**managed tunnel is running**, the hook‑event exemption additionally requires
|
**managed tunnel is running**, the hook‑event exemption additionally requires
|
||||||
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
|
the per‑instance `X-Codeman-Hook-Secret` header (COD‑54); failed presentations
|
||||||
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
|
are rate‑limited in a **dedicated bucket** (separate from Basic‑Auth failures)
|
||||||
@@ -514,9 +516,10 @@ Full feature guide: [`docker-cases.md`](docker-cases.md).
|
|||||||
|
|
||||||
## 10b. Web tabs (dashboard proxy)
|
## 10b. Web tabs (dashboard proxy)
|
||||||
|
|
||||||
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Three properties carry the security weight:
|
A saved dashboard URL renders as a tab, served through Codeman's own origin at `/webview/<capability>/`. User guide: [`web-tabs.md`](web-tabs.md). Four properties carry the security weight:
|
||||||
|
|
||||||
- **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`.
|
- **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`.
|
||||||
|
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
|
||||||
- **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.
|
- **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). 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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -173,7 +173,10 @@ layers cooperate so a dashboard talking to its own backend just works:
|
|||||||
path it does not serve) and answers a static page that does nothing but tell
|
path it does not serve) and answers a static page that does nothing but tell
|
||||||
the owning tab which path was lost; the tab remounts the frame inside the
|
the owning tab which path was lost; the tab remounts the frame inside the
|
||||||
prefix at that path. It never counts as a failed login, so a dev server that
|
prefix at that path. It never counts as a failed login, so a dev server that
|
||||||
reloads on every save cannot rate-limit its user out of Codeman.
|
reloads on every save cannot rate-limit its user out of Codeman. The landing
|
||||||
|
page is the one served path that gets the same answer: it masks to exactly
|
||||||
|
`/`, and a reload there is admitted as long as the request carries no Codeman
|
||||||
|
credentials, which a sandboxed frame never does.
|
||||||
|
|
||||||
On top of that, the proxy answers those requests with CORS headers. That sounds
|
On top of that, the proxy answers those requests with CORS headers. That sounds
|
||||||
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
wrong for same-host requests, but a sandboxed iframe has an *opaque* origin, so the
|
||||||
@@ -187,6 +190,16 @@ then every API call fails, which looks like the dashboard being broken.
|
|||||||
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
EventSource, normal markup, the DOM sinks a page uses to build markup at runtime,
|
||||||
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
and `url()` inside stylesheets. Something that constructs requests by an unusual
|
||||||
route can still slip through. Symptom: the page renders but a panel stays empty.
|
route can still slip through. Symptom: the page renders but a panel stays empty.
|
||||||
|
- **A root-absolute `url()` inside an inline `<style>` is not rescued.** Masking the
|
||||||
|
page's URL (layer 5) trades away the `Referer` safety net of layer 4 for
|
||||||
|
requests the shim cannot see, and only HTML is rewritten server-side. An
|
||||||
|
external stylesheet is fine: a `url()` it references is fetched with the
|
||||||
|
stylesheet's own URL as `Referer`, which is still inside the prefix. A
|
||||||
|
root-absolute `url(/img.png)` written directly into a `<style>` block in the
|
||||||
|
document has the masked document as its `Referer`, so it 404s where the
|
||||||
|
fallback used to rescue it. Symptom: one background image missing while
|
||||||
|
everything else renders. Narrow, and a `url()` the page sets from script is
|
||||||
|
still covered by layer 3.
|
||||||
- **Root-absolute `location` navigation is recovered, not prevented.** `Location`
|
- **Root-absolute `location` navigation is recovered, not prevented.** `Location`
|
||||||
is unforgeable, so `location.href = '/login'` or `location.reload()` really does
|
is unforgeable, so `location.href = '/login'` or `location.reload()` really does
|
||||||
leave the prefix; the frame comes back through the recovery hop in layer 6 above,
|
leave the prefix; the frame comes back through the recovery hop in layer 6 above,
|
||||||
|
|||||||
Reference in New Issue
Block a user