Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions server.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down
54 changes: 52 additions & 2 deletions server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}
Comment on lines +635 to +640
void handle(req, res);
});

Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions src/proxy-behavior.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
bearerFromReferer,
bearerFromRefererAny,
shouldRequireMobileAccessCredential,
isTrustedLocalPeer,
timingSafeEqualString,
isHtmlNavigationRequest,
accessGatePage,
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 26 additions & 4 deletions src/proxy-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
Expand Down
20 changes: 19 additions & 1 deletion src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TOKEN_PARAM,
TOKEN_HEADER,
MOBILE_ACCESS_HEADER,
LOCAL_PEER_HEADER,
SAFE_CONTENT_TYPES,
timingSafeEqualString,
isLoopbackHost,
Expand All @@ -17,6 +18,7 @@ import {
bearerFromReferer,
bearerFromRefererAny,
shouldRequireMobileAccessCredential,
isTrustedLocalPeer,
isHtmlNavigationRequest,
accessGatePage,
} from "./proxy-helpers";
Expand Down Expand Up @@ -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;
}

Expand Down
33 changes: 32 additions & 1 deletion src/server-pty-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<host>.ts.net` Host, so a
Expand Down
Loading