mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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:
53
scripts/dev/peer-stamp.mjs
Normal file
53
scripts/dev/peer-stamp.mjs
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
34
src/server/authz/peerStamp.ts
Normal file
34
src/server/authz/peerStamp.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user