Skip to content

docs(security): design user-bound auth for loopback callers - #4483

Merged
BunsDev merged 2 commits into
mainfrom
docs/cave-ruw4z-loopback-auth-design
Aug 10, 2026
Merged

docs(security): design user-bound auth for loopback callers#4483
BunsDev merged 2 commits into
mainfrom
docs/cave-ruw4z-loopback-auth-design

Conversation

@BunsDev

@BunsDev BunsDev commented Aug 10, 2026

Copy link
Copy Markdown
Member

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.tsisDirectLoopbackRequest(req) checks the socket peer is loopback, no forwarding headers are present, and Host is loopback. When all three hold it stamps x-coven-cave-local-peer with a per-boot secret.
  • proxy.ts reads that stamp as trustedLocalPeer and admits the request with no credential.
  • server.ts repeats the exemption on /api/pty-ws: the 401 is skipped entirely when isDirectLoopbackRequest(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 loopback Host and no forwarding headers is authenticated.

Surface What an unprivileged local user gets
REST /api/** Privileged workspace APIs as the Cave user
PTY upgrade An interactive shell as the Cave process owner

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 that server-pty-ws.test.ts still 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:

  • Tauri shell — reads the file directly and attaches the token. No user-visible change.
  • Local browser — cannot read a 0600 file, so it is bootstrapped once via a loopback URL carrying the token (the Jupyter / VS Code tunnels model), exchanged immediately for an HttpOnly, SameSite=Strict cookie. The token never reaches page script.
  • Explicit tokenless development survives, but becomes deliberate rather than the silent default, and is refused on forwarded requests so the escape hatch can never widen remote access.

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 Host checks 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.

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.
Copilot AI lite review requested due to automatic review settings August 10, 2026 01:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/superpowers/specs/2026-08-10-loopback-user-bound-auth-design.md Outdated
Comment thread docs/superpowers/specs/2026-08-10-loopback-user-bound-auth-design.md Outdated
…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
BunsDev merged commit 4483056 into main Aug 10, 2026
20 checks passed
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 02:38
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 02:38
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 11:00
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 11:04
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 11:43
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 11:48
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 13:10
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 13:31
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 16:41
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 17:19
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 17:33
@BunsDev
BunsDev restored the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 17:35
@BunsDev
BunsDev deleted the docs/cave-ruw4z-loopback-auth-design branch August 10, 2026 20:08
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants