feat(access): exempt direct loopback peers from the mobile access-token gate#3453
Merged
Conversation
…en gate Local requests (the desktop app, local browsers, curl on this machine) no longer need a mobile invite while COVEN_CAVE_ACCESS_TOKEN is armed. Only server.ts sees the raw TCP socket, so it now classifies each request: loopback peer + no forwarding headers + loopback Host = direct loopback. Those requests get x-coven-cave-local-peer stamped with a per-boot random secret (any client-supplied copy is deleted first), and proxy.ts exempts exact matches from mobileAccessGate. The PTY upgrade gate honors the same classification straight off the socket. Tailscale-Serve-forwarded phones also arrive over loopback but always carry x-forwarded-* headers and a ts.net Host, so they are never stamped and stay token-gated; their signed-invite and cookie-exchange flows are unchanged. If Next ever runs without server.ts in front, the secret env is unset and the exemption fails closed. CSRF origin/referer gates are untouched. Verified live: direct loopback page/API pass tokenless; forwarded, spoofed- header, and foreign-Host requests still 401; forwarded + valid token still completes the 307 cookie exchange. Closes cave-vn2r. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces a “direct loopback” exemption so local desktop-app and local-browser requests are not blocked by the mobile access-token gate when COVEN_CAVE_ACCESS_TOKEN is configured. It does this by having the custom Node server (the only place that can see the non-spoofable TCP peer address) stamp a per-boot secret onto verified direct-loopback requests, and having the proxy/middleware trust only that stamp.
Changes:
- Add per-boot
COVEN_CAVE_LOCAL_PEER_SECRETminting plus direct-loopback classification and request stamping inserver.ts/server.mjs. - Update
src/proxy.ts+src/proxy-helpers.tsto verify the stamp (isTrustedLocalPeer) and bypass the mobile access gate only for stamped direct-loopback peers. - Extend tests to pin the direct-loopback classification, “strip-before-stamp” behavior, and fail-closed semantics when the secret is absent.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
server.ts |
Mints a per-boot local-peer secret, classifies direct loopback from the raw socket + forwarding markers + Host, stamps a trusted header, and exempts direct-loopback PTY upgrades. |
server.mjs |
Regenerated JS output mirroring the new per-boot secret + loopback stamping + PTY upgrade exemption behavior. |
src/proxy.ts |
Verifies the stamped header against the per-boot secret and passes trustedLocalPeer into the mobile access gate decision. |
src/proxy-helpers.ts |
Adds LOCAL_PEER_HEADER constant and isTrustedLocalPeer() helper; updates shouldRequireMobileAccessCredential() to accept trustedLocalPeer. |
src/server-pty-ws.test.ts |
Pins the new PTY upgrade exemption and the precise direct-loopback classification + strip-before-stamp behavior in server.ts. |
src/proxy-behavior.test.ts |
Adds behavior coverage for shouldRequireMobileAccessCredential(..., trustedLocalPeer) and isTrustedLocalPeer() fail-closed cases. |
src/middleware.test.ts |
Pins that the mobile gate exemption is keyed to isTrustedLocalPeer(..., process.env.COVEN_CAVE_LOCAL_PEER_SECRET) (not a spoofable bare marker). |
Comments suppressed due to low confidence (1)
server.mjs:414
- The HTTP handler strips
x-coven-cave-local-peerbefore forwarding to Next, but the websocketupgradepath that forwards tonextUpgradeHandlerdoes not. Deleting the header here too avoids accidentally trusting a spoofable client header on non-PTY websocket upgrades and keeps behavior consistent with the HTTP path.
server.on("upgrade", (req, socket, head) => {
let pathname;
let query;
try {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+635
to
+640
| // The local-peer stamp is trustworthy only because any client-supplied copy | ||
| // dies here, before Next (and proxy.ts) ever see the request. | ||
| delete req.headers[LOCAL_PEER_HEADER]; | ||
| if (isDirectLoopbackRequest(req)) { | ||
| req.headers[LOCAL_PEER_HEADER] = LOCAL_PEER_SECRET; | ||
| } |
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.
What
Local requests no longer need a mobile invite token while
COVEN_CAVE_ACCESS_TOKENis armed. Today, provisioning a phone pairing secret (Settings · Phone /scripts/mobile-tailscale.sh) locks out every local browser and even the desktop app's own surfaces behind the access gate — the boot re-arm inserver.tskeeps the gate armed on every subsequent dev boot.How
shouldRequireMobileAccessCredentialalways returnedtruebecause the middleware only sees client-controlled headers ("unless a future caller can pass a non-spoofable remote socket address into this decision"). This PR is that future caller:server.ts(the only code that sees the raw TCP socket) classifies each request: loopback socket peer and no forwarding headers (forwarded,x-forwarded-*,via) and loopback Host → direct loopback. It deletes any client-suppliedx-coven-cave-local-peerheader, then stamps the header with a per-boot random secret on direct-loopback requests only. The secret overwrites any inherited env value and never leaves the process.src/proxy.tsexempts requests whose stamp exactly matchesCOVEN_CAVE_LOCAL_PEER_SECRETfrommobileAccessGate. If Next ever runs withoutserver.tsin front, the env is unset and the exemption fails closed.Why Tailscale Serve stays gated
Serve delivers remote phones over loopback, but always adds
x-forwarded-*headers (which the remote client cannot strip) and forwards a non-loopback authority — either signal alone prevents the stamp. Signed-invite verification, the 307 cookie exchange, and the CSRF origin/referer gates are all unchanged.Verification
Live-booted with a token armed and probed:
X-Forwarded-Forpresent → 401 ✅x-coven-cave-local-peer→ 401 ✅/api→ normal JSON, no token ✅Full suite: 789 test files pass;
tsc --noEmitclean;server.mjsregenerated. New pins inmiddleware.test.ts(exemption keyed to the per-boot secret, never a bare marker),server-pty-ws.test.ts(strip-before-stamp, forwarding-header list, per-boot mint),proxy-behavior.test.ts(isTrustedLocalPeerfail-closed cases).Closes cave-vn2r.
🤖 Generated with Claude Code