Files
OmniRoute/scripts/dev/peer-stamp.mjs
Diego Rodrigues de Sa e Souza 0afd0d3dae fix(security): don't trust loopback socket as local when behind reverse proxy
When OmniRoute itself runs behind an external reverse proxy (nginx, Caddy,
Cloudflare Tunnel), `req.socket.remoteAddress` on the inbound connection is
the proxy hop -- usually 127.0.0.1 -- not the real end-user. The custom Node
server stamped that loopback IP as the trusted PEER_IP_HEADER, so
`classifyHostLocality()` returned "loopback" for every remote caller arriving
via the proxy. The LOCAL_ONLY route guard (which gates spawn-capable routes:
/api/mcp/, /api/cli-tools/runtime/, /api/services/, /api/plugins/,
/api/system/version, /api/tools/agent-bridge/, ...) was therefore
effectively bypassed: a leaked JWT over the public tunnel could trigger
arbitrary child-process spawning (Hard Rules #15 + #17).

Fix mirrors decolua/9router commit da667836:

  1. scripts/dev/peer-stamp.mjs stamps a companion VIA_PROXY_HEADER as
     `<token>|1` whenever the inbound request carries forwarding headers
     (`x-forwarded-for` / `x-real-ip`), token-protected with the same
     per-process secret as PEER_IP_HEADER so a remote caller cannot forge
     either its presence or its absence.

  2. src/server/authz/peerStamp.ts exposes a new
     `classifyStampedPeerLocality(peer, viaProxy, token)` that combines the
     two stamps: when the via-proxy marker is present, a loopback /
     private-LAN socket is downgraded to "remote" so the LOCAL_ONLY tier is
     not bypassed.

  3. src/server/authz/pipeline.ts switches the AUTHZ_HEADER_PEER_LOCALITY
     stamp to the new helper and strips VIA_PROXY_HEADER from the forwarded
     headers alongside PEER_IP_HEADER.

  4. src/server/authz/policies/management.ts gates `isLoopbackRequest()` and
     `isPrivateLanRequest()` on the same via-proxy check so the management
     policy carve-outs (CLI-token gate, LAN-allowed LOCAL_ONLY surface) also
     refuse to honour a proxy-hop socket.

TDD: tests/unit/route-guard-loopback-via-proxy.test.ts proves a request with
`socket.remoteAddress=127.0.0.1` + a via-proxy stamp is classified "remote".
RED first, then GREEN. The local-CLI happy path (loopback socket, no proxy
stamp) is unchanged. Untokened via-proxy hints from a remote attacker are
ignored. Existing tests (route-guard-private-lan, authz/routeGuard,
authz/pipeline, authz/management-policy) all stay green.

Inspired-by: decolua/9router@da667836
Co-authored-by: decolua <decoluadt@example.com>
2026-06-23 21:56:34 -03:00

77 lines
3.5 KiB
JavaScript

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";
/**
* Companion header to PEER_IP_HEADER: `<token>|1` when the inbound TCP request
* carried forwarding headers (`x-forwarded-for` / `x-real-ip`), `<token>|0`
* otherwise. Required so the middleware can tell that a loopback socket is the
* reverse-proxy hop (nginx / Caddy / Cloudflare Tunnel) and NOT trust it as
* local — without this, a leaked JWT over a public tunnel would reach the
* LOCAL_ONLY routes that spawn child processes (Hard Rules #15 + #17;
* port of upstream decolua/9router commit da667836).
*
* Keep VIA_PROXY_HEADER in sync with VIA_PROXY_HEADER in
* src/server/authz/headers.ts (the TS side cannot import this .mjs).
*/
export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";
/** 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 + VIA_PROXY_HEADER and stamp the
* real TCP peer IP plus a token-protected via-proxy marker. 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];
delete req.headers[VIA_PROXY_HEADER];
const ip = req.socket && req.socket.remoteAddress;
if (ip) {
const token = ensurePeerStampToken();
req.headers[PEER_IP_HEADER] = `${token}|${ip}`;
// Forwarding headers present = request arrived via a reverse proxy; the
// loopback socket is the proxy hop, not the end-user, so it must not be
// trusted as local. Token-prefix the marker so a remote caller cannot
// forge it (or its absence) on a non-proxied request.
const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
req.headers[VIA_PROXY_HEADER] = `${token}|${viaProxy ? "1" : "0"}`;
}
} 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);
};
}