docs(security): design user-bound auth for loopback callers - #4483
Merged
Conversation
The daemon-connectivity audit's final security review found that Cave treats a connection from this MACHINE as a connection from this USER. TCP loopback can only support the first claim. Verified in the current tree rather than inferred. server.ts stamps a per-boot secret onto any request whose socket peer is loopback, carries no forwarding headers, and has a loopback Host. proxy.ts reads that stamp as an authenticated local peer, and the PTY upgrade path skips its 401 on the same test even while a token is configured. The secret is doing real work — it stops a remote client forging the header — but it says nothing about which local user opened the socket, because the kernel does not offer that over TCP. So any process run by any OS user on the machine reaches privileged REST, and on the PTY path obtains an interactive shell running as the Cave process owner. That is a local privilege-escalation primitive, which matters exactly to the degree that something untrusted can run locally — not a remote scenario on a machine that executes agent-authored code. The exemption is load-bearing, which is the whole difficulty: #714 removed it and 401'd every local terminal, reproducing a regression server-pty-ws.test.ts still guards. The requirement is therefore not "add auth" but "add auth without a credential step for the two callers that legitimately have none today" — the Tauri shell and a local browser tab. Recommends a per-boot capability token in a mode-0600 file, since filesystem ownership supplies the identity TCP cannot: read directly by the shell, and bootstrapped once into an HttpOnly loopback-scoped cookie for the browser. Explicit tokenless development survives but becomes deliberate and is refused on forwarded requests. Includes the full REST/PTY decision matrix the tests should pin, and a rollout sequenced so the terminal never breaks again. Design only. No auth path changes here.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds a security design spec describing how to move from “machine-bound” loopback trust to user-bound authentication for local callers, without breaking the existing tokenless Tauri shell / local-browser flows that previously regressed (#714).
Changes:
- Introduces a new spec documenting the defect, threat model, and why the current loopback exemption exists.
- Recommends a per-boot capability token stored in a mode-0600 file, with distinct delivery paths for Tauri vs local browser (bootstrap → HttpOnly cookie).
- Defines a REST/PTY decision matrix and a staged rollout plan intended to avoid terminal lockouts.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…owser bootstrap Owner decision: a local browser tab is not a first-class authenticated caller. Only the Tauri shell holds a credential. That removes the constraint the previous draft was bent around. Every option had been judged on whether a web page could satisfy it, and the browser is the only reason the weaker mechanisms were on the list — a page cannot read a 0600 file or speak a unix socket, so serving it forced a credential through a URL and a cookie exchange. With the browser out of scope the strongest available mechanism also becomes the simplest to adopt. Recommendation is now a unix domain socket whose peer uid must equal the server's, with a user-restricted named pipe on Windows. The kernel supplies the identity on the socket itself, so no secret exists to leak, copy or log. The 0600 token file survives only as an explicitly weaker fallback if moving the shell's transport proves impractical. The cost is stated rather than buried: browser-driven work now depends on the explicit tokenless opt-in, which covers plain `pnpm dev` in a browser and any Playwright run that is not already daemon-less. That makes the opt-in's ergonomics matter more than they did when it was a convenience, and the design says so. Rollout resequenced to land the decision-matrix tests first, open the socket alongside TCP before requiring it, and create the opt-in BEFORE anything starts refusing — so the escape hatch exists by the time someone needs it. Also notes that isDirectLoopbackRequest is read twice for two different purposes, and only the PTY 401 skip is being removed; deleting the helper would break the stamp the Serve-forwarding path depends on.
BunsDev
added a commit
that referenced
this pull request
Aug 11, 2026
#4533) * docs(security): design user-bound auth for loopback callers The daemon-connectivity audit's final security review found that Cave treats a connection from this MACHINE as a connection from this USER. TCP loopback can only support the first claim. Verified in the current tree rather than inferred. server.ts stamps a per-boot secret onto any request whose socket peer is loopback, carries no forwarding headers, and has a loopback Host. proxy.ts reads that stamp as an authenticated local peer, and the PTY upgrade path skips its 401 on the same test even while a token is configured. The secret is doing real work — it stops a remote client forging the header — but it says nothing about which local user opened the socket, because the kernel does not offer that over TCP. So any process run by any OS user on the machine reaches privileged REST, and on the PTY path obtains an interactive shell running as the Cave process owner. That is a local privilege-escalation primitive, which matters exactly to the degree that something untrusted can run locally — not a remote scenario on a machine that executes agent-authored code. The exemption is load-bearing, which is the whole difficulty: #714 removed it and 401'd every local terminal, reproducing a regression server-pty-ws.test.ts still guards. The requirement is therefore not "add auth" but "add auth without a credential step for the two callers that legitimately have none today" — the Tauri shell and a local browser tab. Recommends a per-boot capability token in a mode-0600 file, since filesystem ownership supplies the identity TCP cannot: read directly by the shell, and bootstrapped once into an HttpOnly loopback-scoped cookie for the browser. Explicit tokenless development survives but becomes deliberate and is refused on forwarded requests. Includes the full REST/PTY decision matrix the tests should pin, and a rollout sequenced so the terminal never breaks again. Design only. No auth path changes here. * docs(security): scope loopback auth to the desktop shell, drop the browser bootstrap Owner decision: a local browser tab is not a first-class authenticated caller. Only the Tauri shell holds a credential. That removes the constraint the previous draft was bent around. Every option had been judged on whether a web page could satisfy it, and the browser is the only reason the weaker mechanisms were on the list — a page cannot read a 0600 file or speak a unix socket, so serving it forced a credential through a URL and a cookie exchange. With the browser out of scope the strongest available mechanism also becomes the simplest to adopt. Recommendation is now a unix domain socket whose peer uid must equal the server's, with a user-restricted named pipe on Windows. The kernel supplies the identity on the socket itself, so no secret exists to leak, copy or log. The 0600 token file survives only as an explicitly weaker fallback if moving the shell's transport proves impractical. The cost is stated rather than buried: browser-driven work now depends on the explicit tokenless opt-in, which covers plain `pnpm dev` in a browser and any Playwright run that is not already daemon-less. That makes the opt-in's ergonomics matter more than they did when it was a convenience, and the design says so. Rollout resequenced to land the decision-matrix tests first, open the socket alongside TCP before requiring it, and create the opt-in BEFORE anything starts refusing — so the escape hatch exists by the time someone needs it. Also notes that isDirectLoopbackRequest is read twice for two different purposes, and only the PTY 401 skip is being removed; deleting the helper would break the stamp the Serve-forwarding path depends on. * feat(security): require user-bound auth for access-token-only loopback Access-token-only deployments admitted verified direct-loopback REST and PTY requests with no credential. TCP loopback does not distinguish OS users, so any local user could reach privileged workspace APIs, or spawn and adopt a PTY as the Cave process owner — a local privilege-escalation primitive, not merely an over-broad read. Implements the design merged in #4483, with the owner's scoping applied: desktop shell only, and the browser bootstrap dropped. - When access auth is armed, a direct loopback browser uses the same access gate as a remote one. The per-boot local-peer stamp still separates direct from forwarded traffic, but it is no longer treated as identity. - The Tauri shell bypasses only with its per-launch sidecar credential. - A local valid access token passes final auth without being marked remote. - PTY requires cryptographic auth even on loopback. - Forged forwarding or tailnet identity no longer authenticates REST or PTY: a direct loopback client can set x-forwarded-* itself, so tailnet context is consumed only AFTER bearer authentication, for passkey-presence binding. - Explicit tokenless development is unchanged. PROVENANCE: the implementation was written in an earlier session in this worktree and left uncommitted for 18.5 hours. I verified rather than authored it — tsc --noEmit clean, and proxy-behavior, middleware, tailnet-identity and server-pty-ws suites all pass against current main — and am carrying it forward so a P1 security fix stops sitting in a working tree. The exclusions it relies on are re-checked in the following merge, which resolves three files that moved on main underneath it. * test(e2e): authenticate the preferences chain against the access gate The preferences-* projects PATCH /api/preferences, and they are the only e2e chain that runs with COVEN_CAVE_ACCESS_TOKEN armed. Once loopback stopped counting as identity, the local-peer stamp no longer authorizes that write — it marks a request as direct rather than forwarded, which is routing information, not a credential. CI failed with: preference PATCH failed: {"ok":false,"error":"unauthorized"} So the harness now presents the access credential the same way any other access-gated client does. That is the contract this branch establishes, not a concession to make CI green. Scoped to that chain deliberately. Putting the bearer in the top-level `use` would hand a valid credential to every spec — including the ones asserting UNAUTHORIZED behaviour at this exact boundary, which would then pass for the wrong reason. The local-peer stamp is repeated in the project headers because Playwright's project `use` REPLACES a top-level key rather than merging into it; omitting it would quietly change what is under test. Verified: preferences-desktop passes (45.3s clean). An earlier run of the same project was flaky — first attempt failed, retry passed — which is the cold-compile flake this suite already retries for, not auth: an unauthorized failure reproduces on retry, as it did in CI, because each attempt builds a fresh request context with the same headers. * test(e2e): give every project the access credential, not just the mutating one Correcting the previous commit, which scoped the bearer to the preferences-* chain on the theory that only the mutating chain needed it. That was wrong and failed loudly. COVEN_CAVE_ACCESS_TOKEN is armed on the webServer, so it is armed for EVERY project. Once loopback stopped counting as identity, every e2e client became an access-gated client — not just the one that PATCHes. Scoping the credential narrowly left every other project's page loads sitting on the gate for their full 45s timeout: page.goto("/?mode=journal") -> waiting for locator('.journal-list') -> 45000ms The job did not fail, it ran out of time: created 22:18, cancelled 23:22, ~63 minutes of specs each waiting out the gate. The objection that sent me down that path — that a top-level credential would hand one to specs asserting UNAUTHORIZED behaviour — was already handled. The paired-mobile boundary spec overrides `extraHTTPHeaders` wholesale rather than inheriting, so it still owns its own ingress. Verified: tests/journal.spec.ts, the spec that hung, passes 4/4 in 25s and the 45s wait signature is gone.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Addresses the design half of cave-ruw4z, raised by the final security review of the daemon-connectivity audit (cave-58eoq).
Design only — no auth path changes in this PR. It needs sign-off before anyone touches the gate, because the last attempt at this locked out every local terminal (see below).
The defect, verified in the current tree
Cave treats a connection from this machine as a connection from this user. TCP loopback can only support the first.
server.ts→isDirectLoopbackRequest(req)checks the socket peer is loopback, no forwarding headers are present, andHostis loopback. When all three hold it stampsx-coven-cave-local-peerwith a per-boot secret.proxy.tsreads that stamp astrustedLocalPeerand admits the request with no credential.server.tsrepeats the exemption on/api/pty-ws: the 401 is skipped entirely whenisDirectLoopbackRequest(req)holds, even while a token is configured.The per-boot secret is doing real work — it stops a remote client forging the header, since the server deletes any client-supplied copy before Next sees it. It says nothing about which local user opened the socket, because the kernel does not offer that over TCP.
Consequence
Any process, run by any OS user on the machine, that connects to
127.0.0.1:<port>with a loopbackHostand no forwarding headers is authenticated./api/**The PTY row is a local privilege-escalation primitive: a second account, a compromised service account, or any sandboxed helper that can open a loopback socket obtains a shell with the Cave user's full authority — credentials, project checkouts, and the ability to adopt existing sessions.
This is not a remote-exploit finding. It matters exactly to the degree that something untrusted can run locally, which on a machine that routinely executes agent-authored code is not a remote scenario.
Why this is harder than "add auth"
The exemption is load-bearing and removing it naively has already broken the product once.
server.ts's own comments record it: #714 dropped this exemption and 401'd every local terminal, reproducing the v0.0.72 "Terminal connection failed" regression thatserver-pty-ws.test.tsstill guards.So the requirement is: add auth without introducing a credential step for the two callers that legitimately have none today — the Tauri shell and a local browser tab.
Recommendation
A per-boot capability token in a mode-0600 file under the user's own state directory. Another OS user cannot read it, so possession is evidence of being this user — the property TCP loopback cannot supply.
Delivery differs by caller, which is the entire compatibility story:
HttpOnly,SameSite=Strictcookie. The token never reaches page script.A Unix domain socket is stronger for the PTY specifically and is kept as a later step — the token path is what keeps the browser working, and the two compose.
The doc includes the full REST/PTY decision matrix the tests should pin, covering every caller/transport/credential combination, plus the tokenless-opt-in rows.
Rollout
Sequenced so the terminal never breaks, because it already has once: mint and accept the token before requiring it, ship the decision-matrix tests against current behavior so each cell's change is visible in a diff, add the browser bootstrap, then flip PTY, then REST. Only the last two can lock anyone out, and both must be verified in the native Tauri shell — headless checks did not catch #714.
What is deliberately unchanged
The forwarding-header and
Hostchecks stay exactly as they are; they are what keeps a Serve-forwarded phone from reading as local, and this design adds to them rather than replacing them. The tailnet-node path is untouched — a WireGuard-backed device identity is already stronger evidence than a shared bearer token. Nothing here changes what any surface may do once authenticated; this is admission, not authorization.