From d2f248cb8b405c2ea35d3efe211525db310f0b7f Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 18 Jul 2026 16:45:54 -0500 Subject: [PATCH] feat(access): exempt direct loopback peers from the mobile access-token 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 --- server.mjs | 25 ++++++++++++++++-- server.ts | 54 ++++++++++++++++++++++++++++++++++++-- src/middleware.test.ts | 20 ++++++++++++++ src/proxy-behavior.test.ts | 28 ++++++++++++++++++++ src/proxy-helpers.ts | 30 ++++++++++++++++++--- src/proxy.ts | 20 +++++++++++++- src/server-pty-ws.test.ts | 33 ++++++++++++++++++++++- 7 files changed, 200 insertions(+), 10 deletions(-) diff --git a/server.mjs b/server.mjs index 798445721..761e12fc8 100644 --- a/server.mjs +++ b/server.mjs @@ -1,4 +1,4 @@ -import { createHmac } from "node:crypto"; +import { createHmac, randomUUID } from "node:crypto"; import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; import { createServer } from "node:http"; import { createRequire } from "node:module"; @@ -40,6 +40,16 @@ function accessToken() { return process.env.COVEN_CAVE_ACCESS_TOKEN ?? ""; } const SIDECAR_TOKEN = process.env.COVEN_CAVE_AUTH_TOKEN ?? ""; +const LOCAL_PEER_HEADER = "x-coven-cave-local-peer"; +const LOCAL_PEER_SECRET = randomUUID(); +process.env.COVEN_CAVE_LOCAL_PEER_SECRET = LOCAL_PEER_SECRET; +const FORWARDING_HEADERS = [ + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "via" +]; const ACCESS_COOKIE = "coven_cave_access"; const LEGACY_ACCESS_COOKIE = "coven_access_token"; const ACCESS_QUERY_PARAM = "coven_access_token"; @@ -115,6 +125,13 @@ function isLoopbackAddress(value) { if (value.startsWith("::ffff:")) return value.slice("::ffff:".length) === "127.0.0.1"; return false; } +function isDirectLoopbackRequest(req) { + if (!isLoopbackAddress(req.socket.remoteAddress)) return false; + for (const header of FORWARDING_HEADERS) { + if (req.headers[header] !== void 0) return false; + } + return isLoopbackHost(req.headers.host); +} function sameOrigin(value, expectedOrigin) { if (!value) return true; try { @@ -385,6 +402,10 @@ const wss = new WebSocketServer({ noServer: true }); await app.prepare(); const nextUpgradeHandler = app.getUpgradeHandler(); const server = createServer((req, res) => { + delete req.headers[LOCAL_PEER_HEADER]; + if (isDirectLoopbackRequest(req)) { + req.headers[LOCAL_PEER_HEADER] = LOCAL_PEER_SECRET; + } void handle(req, res); }); server.on("upgrade", (req, socket, head) => { @@ -410,7 +431,7 @@ server.on("upgrade", (req, socket, head) => { socket.destroy(); return; } - if (isPtyAuthRequired() && !tokenAuthenticated) { + if (isPtyAuthRequired() && !tokenAuthenticated && !isDirectLoopbackRequest(req)) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; diff --git a/server.ts b/server.ts index 83716656b..246c8e094 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,4 @@ -import { createHmac } from "node:crypto"; +import { createHmac, randomUUID } from "node:crypto"; import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs"; import { createServer, type IncomingMessage } from "node:http"; import { createRequire } from "node:module"; @@ -71,6 +71,28 @@ function accessToken(): string { return process.env.COVEN_CAVE_ACCESS_TOKEN ?? ""; } const SIDECAR_TOKEN = process.env.COVEN_CAVE_AUTH_TOKEN ?? ""; + +// Local-peer stamp (cave-vn2r): only this file sees the raw TCP socket, so +// only it can prove a request truly came from this machine. Requests whose +// peer is loopback AND that carry no forwarding markers get LOCAL_PEER_HEADER +// stamped with a per-boot random secret; proxy.ts exempts exact matches from +// the mobile access-token gate. The secret is minted fresh every boot and +// deliberately OVERWRITES any inherited env value, so nothing outside this +// process can pre-arrange a passing stamp. Mirrors LOCAL_PEER_HEADER in +// src/proxy-helpers.ts (this file cannot import from src/). +const LOCAL_PEER_HEADER = "x-coven-cave-local-peer"; +const LOCAL_PEER_SECRET = randomUUID(); +process.env.COVEN_CAVE_LOCAL_PEER_SECRET = LOCAL_PEER_SECRET; +// `tailscale serve` (the paired-phone path) forwards over loopback but always +// adds forwarding headers — their presence means the true peer is remote. +const FORWARDING_HEADERS = [ + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "via", +] as const; + const ACCESS_COOKIE = "coven_cave_access"; const LEGACY_ACCESS_COOKIE = "coven_access_token"; const ACCESS_QUERY_PARAM = "coven_access_token"; @@ -194,6 +216,24 @@ function isLoopbackAddress(value: string | undefined): boolean { return false; } +/** + * A request that provably originated on this machine: the TCP peer is + * loopback (non-spoofable — read off the socket), no proxy forwarded it + * (Tailscale Serve delivers remote phones over loopback but always adds + * forwarding headers the remote client cannot strip), and the Host is a + * loopback authority (a Serve route that forwards the ts.net Host fails this + * even if its forwarding headers were ever absent). Deliberately redundant: + * both the forwarding-marker and Host checks must fail for a forwarded + * request to be misclassified as local. + */ +function isDirectLoopbackRequest(req: IncomingMessage): boolean { + if (!isLoopbackAddress(req.socket.remoteAddress)) return false; + for (const header of FORWARDING_HEADERS) { + if (req.headers[header] !== undefined) return false; + } + return isLoopbackHost(req.headers.host); +} + function sameOrigin(value: string | undefined, expectedOrigin: string): boolean { if (!value) return true; try { @@ -592,6 +632,12 @@ await app.prepare(); const nextUpgradeHandler = app.getUpgradeHandler(); const server = createServer((req, res) => { + // 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; + } void handle(req, res); }); @@ -635,7 +681,11 @@ server.on("upgrade", (req, socket, head) => { // server-pty-ws.test.ts warns about). Native mobile mode configures only // COVEN_CAVE_AUTH_TOKEN; require that sidecar token here too so // Tailscale-forwarded PTY upgrades cannot become credential-less shells. - if (isPtyAuthRequired() && !tokenAuthenticated) { + // A direct loopback peer (verified off the socket, never forwarded — see + // isDirectLoopbackRequest) is the local app or a local browser and is + // exempt, mirroring proxy.ts's local-peer exemption on REST (cave-vn2r); + // Tailscale-forwarded upgrades carry forwarding headers and stay gated. + if (isPtyAuthRequired() && !tokenAuthenticated && !isDirectLoopbackRequest(req)) { socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; diff --git a/src/middleware.test.ts b/src/middleware.test.ts index be19f05c6..6832508f4 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -135,6 +135,26 @@ assert.match(source, /if \(queryVerification\.ok\)/, "invalid query tokens shoul assert.match(source, /maxAge/, "signed mobile cookie lifetime should track token expiry"); assert.match(source, /req\.method === "GET" \|\| req\.method === "HEAD"/, "mobile token bootstrap should avoid redirects for mutating requests"); +// ── Direct-loopback exemption from the mobile gate (cave-vn2r) ──────────── +// The exemption must be keyed to the per-boot secret server.ts stamps after +// verifying the actual TCP peer — never to a bare header value or a Host, +// both of which any client can send. +assert.match( + source, + /isTrustedLocalPeer\(\s*req\.headers\.get\(LOCAL_PEER_HEADER\),\s*process\.env\.COVEN_CAVE_LOCAL_PEER_SECRET,?\s*\)/, + "the local-peer exemption must verify the server-stamped per-boot secret", +); +assert.doesNotMatch( + source, + /LOCAL_PEER_HEADER\)\s*===\s*"1"/, + "a bare marker value must never satisfy the local-peer exemption (spoofable without server.ts)", +); +assert.match( + source, + /shouldRequireMobileAccessCredential\(\s*req\.headers\.get\("host"\),\s*suppliedTokens\.length > 0,\s*trustedLocalPeer,?\s*\)/, + "the mobile gate must consult the verified local-peer stamp", +); + // ── HTML access gate for unauthenticated browser navigations ────────────── // Same 401 fail-closed posture; only the body differs by client. The page's // form re-enters the query-token exchange above — no new auth logic. diff --git a/src/proxy-behavior.test.ts b/src/proxy-behavior.test.ts index 9d9c91066..f04b8568e 100644 --- a/src/proxy-behavior.test.ts +++ b/src/proxy-behavior.test.ts @@ -25,6 +25,7 @@ import { bearerFromReferer, bearerFromRefererAny, shouldRequireMobileAccessCredential, + isTrustedLocalPeer, timingSafeEqualString, isHtmlNavigationRequest, accessGatePage, @@ -362,6 +363,33 @@ assert.equal( true, "supplied credentials should still be verified and redirected on loopback", ); +assert.equal( + shouldRequireMobileAccessCredential("localhost:3000", false, true), + false, + "a server-verified direct loopback peer is exempt from the mobile gate (cave-vn2r)", +); +assert.equal( + shouldRequireMobileAccessCredential("cave.tailnet.example.ts.net", false, false), + true, + "without the server's local-peer stamp the gate stays armed for every host", +); + +// ─── isTrustedLocalPeer ─────────────────────────────────────────────────── +// The exemption above is keyed to server.ts's per-boot secret; only an exact +// match counts, and a missing secret (Next without server.ts) fails closed. +assert.equal(isTrustedLocalPeer("per-boot-secret", "per-boot-secret"), true); +assert.equal( + isTrustedLocalPeer("guessed-value", "per-boot-secret"), + false, + "a client-guessed header value must not pass", +); +assert.equal(isTrustedLocalPeer(null, "per-boot-secret"), false, "absent header never passes"); +assert.equal( + isTrustedLocalPeer("anything", undefined), + false, + "no per-boot secret configured (Next running without server.ts) fails closed", +); +assert.equal(isTrustedLocalPeer("", ""), false, "empty header and secret never match"); // ─── bearerFromReferer ───────────────────────────────────────────────────── assert.equal( diff --git a/src/proxy-helpers.ts b/src/proxy-helpers.ts index ddd70fa41..7564eb2cf 100644 --- a/src/proxy-helpers.ts +++ b/src/proxy-helpers.ts @@ -136,12 +136,30 @@ export function isAllowedRequestSourceAny(value: string | null, expectedOrigins: export function shouldRequireMobileAccessCredential( _host: string | null, _hasSuppliedCredential: boolean, + trustedLocalPeer = false, ) { // The Host header is client-controlled, so it cannot prove that the actual - // TCP peer is loopback. When COVEN_CAVE_ACCESS_TOKEN is configured, require - // a valid mobile credential for every request unless a future caller can pass - // a non-spoofable remote socket address into this decision. - return true; + // TCP peer is loopback — a host value alone never exempts a request. The + // one thing that CAN prove it is server.ts, which sees the raw socket: it + // stamps LOCAL_PEER_HEADER with the per-boot COVEN_CAVE_LOCAL_PEER_SECRET + // only when the TCP peer is loopback, the request carries no forwarding + // markers (a Tailscale-Serve-forwarded phone arrives over loopback too, but + // always with x-forwarded-* headers), and the Host is loopback. Callers + // verify that stamp with isTrustedLocalPeer and pass the result here. + return !trustedLocalPeer; +} + +/** + * True when the request's LOCAL_PEER_HEADER value equals the per-boot + * local-peer secret minted by server.ts. The custom server deletes any + * client-supplied copy of the header before stamping its own, and the secret + * never leaves the process, so a match proves server.ts classified this + * request as a direct (unforwarded) loopback connection. An unset secret — + * Next running without server.ts in front — fails closed. + */ +export function isTrustedLocalPeer(headerValue: string | null, secret: string | undefined) { + if (!headerValue || !secret) return false; + return timingSafeEqualString(headerValue, secret); } export function bearerFromReferer(value: string | null, expectedOrigin: string) { @@ -275,6 +293,10 @@ export function accessGatePage({ invalidToken = false }: { invalidToken?: boolea } export const ACCESS_TOKEN_COOKIE = "coven_cave_access"; +// Stamped by server.ts (with the per-boot COVEN_CAVE_LOCAL_PEER_SECRET) on +// requests whose TCP peer it verified as direct loopback. Mirrored in +// server.ts, which cannot import from src/. +export const LOCAL_PEER_HEADER = "x-coven-cave-local-peer"; export const ACCESS_TOKEN_QUERY_PARAM = "coven_access_token"; export const TOKEN_PARAM = "covenCaveToken"; export const TOKEN_HEADER = "x-coven-cave-token"; diff --git a/src/proxy.ts b/src/proxy.ts index c9203de4e..8f27f4d94 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -6,6 +6,7 @@ import { TOKEN_PARAM, TOKEN_HEADER, MOBILE_ACCESS_HEADER, + LOCAL_PEER_HEADER, SAFE_CONTENT_TYPES, timingSafeEqualString, isLoopbackHost, @@ -17,6 +18,7 @@ import { bearerFromReferer, bearerFromRefererAny, shouldRequireMobileAccessCredential, + isTrustedLocalPeer, isHtmlNavigationRequest, accessGatePage, } from "./proxy-helpers"; @@ -79,7 +81,23 @@ async function mobileAccessGate(req: NextRequest) { if (!expected) return null; const suppliedTokens = mobileAccessSuppliedTokens(req); - if (!shouldRequireMobileAccessCredential(req.headers.get("host"), suppliedTokens.length > 0)) { + // A direct loopback connection (server.ts verified the TCP peer and the + // absence of forwarding headers, then stamped the per-boot secret) is the + // local desktop app or a local browser — it needs no mobile invite even + // while the pairing secret is armed. Tailscale-Serve-forwarded phones also + // arrive over loopback but always carry x-forwarded-* headers, so they are + // never stamped and stay token-gated. + const trustedLocalPeer = isTrustedLocalPeer( + req.headers.get(LOCAL_PEER_HEADER), + process.env.COVEN_CAVE_LOCAL_PEER_SECRET, + ); + if ( + !shouldRequireMobileAccessCredential( + req.headers.get("host"), + suppliedTokens.length > 0, + trustedLocalPeer, + ) + ) { return null; } diff --git a/src/server-pty-ws.test.ts b/src/server-pty-ws.test.ts index cf26be9a2..40ac3634a 100644 --- a/src/server-pty-ws.test.ts +++ b/src/server-pty-ws.test.ts @@ -57,7 +57,38 @@ assert.match( // that guard and 401'd every local terminal. Native mobile mode configures // only COVEN_CAVE_AUTH_TOKEN, so it must also trigger auth. assert.match(src, /function isPtyAuthRequired\(\): boolean \{\s*return Boolean\(accessToken\(\) \|\| SIDECAR_TOKEN\);\s*\}/, "PTY auth is required when either the mobile access token or sidecar token is configured"); -assert.match(src, /if \(isPtyAuthRequired\(\) && !tokenAuthenticated\)/, "PTY upgrade 401s on missing credentials when any PTY auth token is configured (credential-less loopback is the local app)"); +assert.match(src, /if \(isPtyAuthRequired\(\) && !tokenAuthenticated && !isDirectLoopbackRequest\(req\)\)/, "PTY upgrade 401s on missing credentials when any PTY auth token is configured, except for a socket-verified direct loopback peer (cave-vn2r)"); +// Direct-loopback classification (cave-vn2r): trusted only because ALL three +// hold — the socket peer is loopback, no forwarding markers are present +// (tailscale serve delivers remote phones over loopback WITH x-forwarded-*), +// and the Host is loopback. The stamp forwarded to proxy.ts must be the +// per-boot secret, minted fresh (never inherited from ambient env), and any +// client-supplied copy of the header must die before Next sees the request. +assert.match( + src, + /function isDirectLoopbackRequest\(req: IncomingMessage\): boolean \{\s*if \(!isLoopbackAddress\(req\.socket\.remoteAddress\)\) return false;/, + "direct-loopback classification starts from the non-spoofable socket peer address", +); +assert.match( + src, + /FORWARDING_HEADERS = \[\s*"forwarded",\s*"x-forwarded-for",\s*"x-forwarded-host",\s*"x-forwarded-proto",\s*"via",?\s*\]/, + "forwarded requests (tailscale serve arrives over loopback) must never classify as direct loopback", +); +assert.match( + src, + /return isLoopbackHost\(req\.headers\.host\);\s*\}/, + "direct-loopback classification also requires a loopback Host authority", +); +assert.match( + src, + /const LOCAL_PEER_SECRET = randomUUID\(\);\s*process\.env\.COVEN_CAVE_LOCAL_PEER_SECRET = LOCAL_PEER_SECRET;/, + "the local-peer secret is minted per boot and overwrites any inherited env value", +); +assert.match( + src, + /delete req\.headers\[LOCAL_PEER_HEADER\];\s*if \(isDirectLoopbackRequest\(req\)\) \{\s*req\.headers\[LOCAL_PEER_HEADER\] = LOCAL_PEER_SECRET;/, + "client-supplied local-peer headers are stripped before the server stamps its own", +); assert.match(src, /SIDECAR_QUERY_PARAM = "covenCaveToken"/, "PTY WebSocket auth accepts the sidecar token query param used by native WebSockets"); // Credentials are verified BEFORE the source gate: a paired device over // `tailscale serve` arrives with a non-loopback `.ts.net` Host, so a