mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user