fix(authz): trust real TCP peer IP stamp over spoofable Host header for LOCAL_ONLY gate

The middleware runtime exposes no socket, so a prior fix derived LOCAL_ONLY
locality from the Host header — letting a remote caller send Host: 127.0.0.1
and reach spawn-capable routes (RCE class). The custom Node servers now stamp
the real socket.remoteAddress into a token-signed internal header; the policy
trusts only a stamp whose token matches this process's secret, and fails closed
otherwise. Preserves the owner-authorized loopback + private-LAN access without
trusting any client-controlled header.
This commit is contained in:
diegosouzapw
2026-05-31 00:43:48 -03:00
parent b93cde7507
commit 0a09fa5a11
8 changed files with 185 additions and 14 deletions

View File

@@ -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 `<token>|<ip>`, where <token> 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);
};
}

View File

@@ -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);

View File

@@ -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);
};

View File

@@ -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 `<token>|<ip>`. 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.

View File

@@ -0,0 +1,34 @@
import { timingSafeEqual } from "node:crypto";
/**
* Resolve the real peer IP from the trusted `<token>|<ip>` 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;
}

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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);
});