diff --git a/scripts/dev/peer-stamp.mjs b/scripts/dev/peer-stamp.mjs new file mode 100644 index 0000000000..b4e18d213a --- /dev/null +++ b/scripts/dev/peer-stamp.mjs @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; + +/** + * Trusted peer-IP stamping for the custom Node HTTP servers. + * + * The Next.js middleware runtime (proxy.ts → runAuthzPipeline) exposes NO socket + * or peer IP — only request headers, ALL of which are client-controlled. The + * LOCAL_ONLY route guard (spawn-capable routes) must decide locality from the + * real TCP peer, never from the spoofable Host header. + * + * Our custom servers DO have the real `req.socket.remoteAddress`. They stamp it + * into PEER_IP_HEADER as `|`, where is a per-process secret + * (OMNIROUTE_PEER_STAMP_TOKEN). Any client-supplied value of PEER_IP_HEADER is + * deleted first, so a remote caller cannot pre-populate it. The middleware + * (src/server/authz/policies/management.ts → resolveStampedPeer) trusts the IP + * ONLY when the token matches this process's secret; otherwise it fails closed. + * + * Keep PEER_IP_HEADER in sync with PEER_IP_HEADER in + * src/server/authz/headers.ts (the TS side cannot import this .mjs). + */ +export const PEER_IP_HEADER = "x-omniroute-peer-ip"; + +/** Generate (once) and return the per-process stamp token, persisting it in env + * so the middleware running in the same process reads the identical value. */ +export function ensurePeerStampToken() { + process.env.OMNIROUTE_PEER_STAMP_TOKEN ||= randomUUID(); + return process.env.OMNIROUTE_PEER_STAMP_TOKEN; +} + +/** Strip any client-supplied PEER_IP_HEADER and stamp the real TCP peer IP, + * token-prefixed. Never throws — a stamping failure must not block a request + * (it degrades to "locality unknown" → fail closed in the middleware). */ +export function stampPeerIp(req) { + try { + if (!req || !req.headers) return; + // Node lowercases incoming header names; delete kills any client value. + delete req.headers[PEER_IP_HEADER]; + const ip = req.socket && req.socket.remoteAddress; + if (ip) { + req.headers[PEER_IP_HEADER] = `${ensurePeerStampToken()}|${ip}`; + } + } catch { + /* never block a request on peer stamping */ + } +} + +/** Wrap a Node request listener so every request is peer-stamped first. */ +export function wrapRequestListenerWithPeerStamp(listener) { + return function peerStampingRequestHandler(req, res) { + stampPeerIp(req); + return listener.call(this, req, res); + }; +} diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 61e490311a..7a956d93a9 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -8,6 +8,7 @@ import { bootstrapEnv } from "../build/bootstrap-env.mjs"; import { resolveRuntimePorts, withRuntimePortEnv } from "../build/runtime-env.mjs"; import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; +import { ensurePeerStampToken, stampPeerIp } from "./peer-stamp.mjs"; import { randomUUID } from "node:crypto"; // Pre-read DATA_DIR from local .env before bootstrap resolves paths @@ -49,6 +50,9 @@ const { dashboardPort } = runtimePorts; const hostname = process.env.HOST || "0.0.0.0"; const useTurbopack = dev && mergedEnv.OMNIROUTE_USE_TURBOPACK === "1"; process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); +// Per-process secret used to prove the trusted peer-IP stamp came from this +// server (read by the authz middleware in the same process). See peer-stamp.mjs. +ensurePeerStampToken(); const nextApp = next({ dev, @@ -71,7 +75,12 @@ async function start() { baseUrl: `http://127.0.0.1:${dashboardPort}`, }); - const server = http.createServer((req, res) => requestHandler(req, res)); + const server = http.createServer((req, res) => { + // Stamp the real TCP peer IP before Next sees the request, so the authz + // middleware can decide LOCAL_ONLY locality without trusting the Host header. + stampPeerIp(req); + return requestHandler(req, res); + }); server.on("upgrade", async (req, socket, head) => { try { const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head); diff --git a/scripts/dev/standalone-server-ws.mjs b/scripts/dev/standalone-server-ws.mjs index 12a20bf9eb..e7d4fb7672 100644 --- a/scripts/dev/standalone-server-ws.mjs +++ b/scripts/dev/standalone-server-ws.mjs @@ -1,11 +1,14 @@ import http from "node:http"; import { randomUUID } from "node:crypto"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; +import { ensurePeerStampToken, wrapRequestListenerWithPeerStamp } from "./peer-stamp.mjs"; const originalCreateServer = http.createServer.bind(http); const proxiesByPort = new Map(); process.env.OMNIROUTE_WS_BRIDGE_SECRET ||= randomUUID(); +// Per-process secret proving the trusted peer-IP stamp came from this server. +ensurePeerStampToken(); function getPort(server) { const address = server.address?.(); @@ -46,6 +49,13 @@ function wrapUpgradeListener(server, listener) { } http.createServer = function createServerWithResponsesWs(...args) { + // Next's standalone server.js may pass its request listener directly to + // createServer; wrap it so the real TCP peer IP is stamped before Next runs. + const lastFnIdx = args.map((a) => typeof a === "function").lastIndexOf(true); + if (lastFnIdx >= 0) { + args[lastFnIdx] = wrapRequestListenerWithPeerStamp(args[lastFnIdx]); + } + const server = originalCreateServer(...args); const originalOn = server.on.bind(server); const originalAddListener = server.addListener.bind(server); @@ -54,6 +64,10 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "upgrade" && typeof listener === "function") { return originalOn(eventName, wrapUpgradeListener(server, listener)); } + // …or it may attach the handler via server.on("request"): wrap that too. + if (eventName === "request" && typeof listener === "function") { + return originalOn(eventName, wrapRequestListenerWithPeerStamp(listener)); + } return originalOn(eventName, listener); }; @@ -61,6 +75,9 @@ http.createServer = function createServerWithResponsesWs(...args) { if (eventName === "upgrade" && typeof listener === "function") { return originalAddListener(eventName, wrapUpgradeListener(server, listener)); } + if (eventName === "request" && typeof listener === "function") { + return originalAddListener(eventName, wrapRequestListenerWithPeerStamp(listener)); + } return originalAddListener(eventName, listener); }; diff --git a/src/server/authz/headers.ts b/src/server/authz/headers.ts index 32d9cb3323..dc5f4f548d 100644 --- a/src/server/authz/headers.ts +++ b/src/server/authz/headers.ts @@ -24,6 +24,19 @@ export const AUTHZ_HEADER_AUTH_SCOPES = "x-omniroute-auth-scopes"; /** CLI sends this header so the local process can call management APIs without login. */ export const CLI_TOKEN_HEADER = "x-omniroute-cli-token"; +/** + * The real TCP peer IP, stamped by the custom Node server BEFORE Next runs + * (scripts/dev/peer-stamp.mjs), formatted as `|`. The middleware has + * no socket, so this is the only trustworthy locality signal — but ONLY when the + * token matches this process's OMNIROUTE_PEER_STAMP_TOKEN (see + * policies/management.ts → resolveStampedPeer). Any client-supplied value is + * deleted by the server before stamping, and this header is stripped from the + * forwarded request in pipeline.ts so it never reaches route handlers/upstream. + * NEVER decide locality from the Host header — it is fully client-controlled. + * Keep in sync with PEER_IP_HEADER in scripts/dev/peer-stamp.mjs. + */ +export const PEER_IP_HEADER = "x-omniroute-peer-ip"; + /** * Headers the pipeline must NEVER trust on incoming requests. They are * stripped before route classification to prevent header-spoofing attacks. diff --git a/src/server/authz/peerStamp.ts b/src/server/authz/peerStamp.ts new file mode 100644 index 0000000000..df099aa94c --- /dev/null +++ b/src/server/authz/peerStamp.ts @@ -0,0 +1,34 @@ +import { timingSafeEqual } from "node:crypto"; + +/** + * Resolve the real peer IP from the trusted `|` stamp that the custom + * Node server writes into PEER_IP_HEADER (see scripts/dev/peer-stamp.mjs). Returns + * the IP ONLY when the token constant-time-matches this process's stamp token; + * any other value (no stamp, wrong/forged token, missing separator, empty IP) + * returns null. + * + * Pure + dependency-free so the auth boundary is directly unit-testable. + * + * SECURITY: this is the ONLY trustworthy locality signal in the Next middleware + * runtime (which has no socket). Never derive locality from the Host header — it + * is fully client-controlled, so `Host: 127.0.0.1` from a remote attacker would + * otherwise bypass the LOCAL_ONLY gate guarding spawn-capable routes. + */ +export function resolveStampedPeer( + headerValue: string | null, + token: string | undefined +): string | null { + if (!headerValue || !token) return null; + const sep = headerValue.indexOf("|"); + if (sep <= 0) return null; + const provided = headerValue.slice(0, sep); + const ip = headerValue.slice(sep + 1); + if (!ip) return null; + if (provided.length !== token.length) return null; + try { + if (!timingSafeEqual(Buffer.from(provided), Buffer.from(token))) return null; + } catch { + return null; + } + return ip; +} diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 36f0a96759..4504c3db9e 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -17,6 +17,7 @@ import { AUTHZ_HEADER_REQUEST_ID, AUTHZ_HEADER_ROUTE_CLASS, AUTHZ_TRUSTED_HEADERS, + PEER_IP_HEADER, } from "./headers"; import type { AuthSubject, RouteClass, RouteClassification } from "./types"; import type { AuthOutcome, RoutePolicy } from "./context"; @@ -228,6 +229,10 @@ export async function runAuthzPipeline( for (const trusted of AUTHZ_TRUSTED_HEADERS) { requestHeaders.delete(trusted); } + // The trusted peer-IP stamp is read by the policy from the ORIGINAL request + // (above); strip it from the forwarded headers so the per-process token never + // reaches route handlers or upstream providers. + requestHeaders.delete(PEER_IP_HEADER); requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass); requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId); diff --git a/src/server/authz/policies/management.ts b/src/server/authz/policies/management.ts index e4832d078a..8fbc15d6b5 100644 --- a/src/server/authz/policies/management.ts +++ b/src/server/authz/policies/management.ts @@ -7,7 +7,8 @@ import { allow, reject } from "../context"; import { extractApiKey, isValidApiKey } from "../../../sse/services/auth"; import { getApiKeyMetadata } from "../../../lib/db/apiKeys"; import { hasManageScope } from "../../../lib/api/requireManagementAuth"; -import { CLI_TOKEN_HEADER } from "../headers"; +import { CLI_TOKEN_HEADER, PEER_IP_HEADER } from "../headers"; +import { resolveStampedPeer } from "../peerStamp"; import { isAlwaysProtectedPath, isLocalOnlyBypassableByManageScope, @@ -19,16 +20,18 @@ import { const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/; function requestPeerAddress(ctx: PolicyContext): string | null { - // In the Next middleware runtime (proxy.ts → runAuthzPipeline), ctx.request is - // a NextRequest with no socket/.ip, so the only locality signal is the Host - // header — which is exactly what isLoopbackHost/isPrivateLanHost parse (they - // strip :port). This both fixes the loopback gate (previously the null socket - // made every LOCAL_ONLY request 403, even from localhost) and enables the - // owner-authorized private-LAN carve-out. Fall back to .ip/.socket for any - // non-middleware caller that provides them. Spawn-capable endpoints still - // require manage-scope auth after this gate. - const hostHeader = ctx.request.headers?.get?.("host") ?? null; - return hostHeader || ctx.request.ip || ctx.request.socket?.remoteAddress || null; + // The Next middleware runtime exposes no socket/.ip, so the only trustworthy + // locality signal is the token-stamped PEER_IP_HEADER our custom server writes + // from the real TCP peer (scripts/dev/peer-stamp.mjs). We NEVER read the Host + // header here — it is client-controlled and spoofable. Absent/forged stamp → + // null → isLoopbackRequest/isPrivateLanRequest return false → fail closed. + const stamped = resolveStampedPeer( + ctx.request.headers?.get?.(PEER_IP_HEADER) ?? null, + process.env.OMNIROUTE_PEER_STAMP_TOKEN + ); + if (stamped) return stamped; + // Non-middleware callers (tests / direct Node) may carry a real socket peer. + return ctx.request.ip ?? ctx.request.socket?.remoteAddress ?? null; } function isLoopbackRequest(ctx: PolicyContext): boolean { diff --git a/tests/unit/route-guard-private-lan.test.ts b/tests/unit/route-guard-private-lan.test.ts index 9117af0b75..c8e7c9df06 100644 --- a/tests/unit/route-guard-private-lan.test.ts +++ b/tests/unit/route-guard-private-lan.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts"; +import { resolveStampedPeer } from "../../src/server/authz/peerStamp.ts"; test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => { for (const h of [ @@ -48,10 +49,46 @@ test("services + traffic-inspector remain LOCAL_ONLY paths", () => { assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true); }); -test("management policy derives locality from the Host header (middleware socket is null)", () => { +test("management policy must NOT derive locality from the spoofable Host header", () => { const src = readFileSync( join(import.meta.dirname, "../../src/server/authz/policies/management.ts"), "utf8" ); - assert.ok(src.includes('headers?.get?.("host")'), "requestPeerAddress must read the Host header"); + // Regression guard: a prior fix read the client-controlled Host header for the + // LOCAL_ONLY decision, letting `Host: 127.0.0.1` bypass the gate. Locality must + // come from the token-stamped peer IP instead. + assert.ok( + !src.includes('get?.("host")') && !src.includes('get("host")'), + "requestPeerAddress must NOT read the Host header" + ); + assert.ok( + src.includes("resolveStampedPeer") && src.includes("PEER_IP_HEADER"), + "requestPeerAddress must resolve the trusted token-stamped peer IP" + ); +}); + +// ── resolveStampedPeer: the auth boundary that replaces Host-header trust ── +const TOK = "process-secret-token-abc"; + +test("resolveStampedPeer: returns the IP only for a correctly-tokened stamp", () => { + assert.equal(resolveStampedPeer(`${TOK}|127.0.0.1`, TOK), "127.0.0.1"); + assert.equal(resolveStampedPeer(`${TOK}|192.168.0.15`, TOK), "192.168.0.15"); + assert.equal(resolveStampedPeer(`${TOK}|::1`, TOK), "::1"); + assert.equal(resolveStampedPeer(`${TOK}|::ffff:192.168.1.20`, TOK), "::ffff:192.168.1.20"); +}); + +test("resolveStampedPeer: rejects forged token, missing token, no separator, empty ip", () => { + assert.equal(resolveStampedPeer("wrong-token|127.0.0.1", TOK), null, "forged token"); + assert.equal(resolveStampedPeer(`${TOK}|127.0.0.1`, undefined), null, "no process token"); + assert.equal(resolveStampedPeer("127.0.0.1", TOK), null, "no separator (raw client value)"); + assert.equal(resolveStampedPeer(`${TOK}|`, TOK), null, "empty ip"); + assert.equal(resolveStampedPeer("|127.0.0.1", TOK), null, "empty token segment"); + assert.equal(resolveStampedPeer(null, TOK), null, "absent header"); + assert.equal(resolveStampedPeer("", TOK), null, "empty header"); +}); + +test("resolveStampedPeer: a spoofed Host-style value cannot pass without the token", () => { + // Simulates a remote attacker who knows the header name but not the secret. + assert.equal(resolveStampedPeer("127.0.0.1|127.0.0.1", TOK), null); + assert.equal(resolveStampedPeer("anything|127.0.0.1", TOK), null); });