Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,13 @@ NEXT_PUBLIC_CLOUD_URL=
# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers).
# OMNIROUTE_TRUST_PROXY=

# Proxy addresses whose X-Forwarded-For / X-Real-IP the IP allow/deny list may believe, on top
# of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed
# only when the reverse proxy in front of OmniRoute has any other (public) address; without
# it that proxy's own address is what the filter judges. Comma-separated IPs or CIDR ranges.
# Used by: scripts/dev/peer-stamp.mjs.
# OMNIROUTE_TRUSTED_PROXIES=198.51.100.7,203.0.113.0/24

# Public callback URL for asynchronous image/audio jobs (kie.ai, etc.).
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/ip-filter-forged-forwarding-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(authz):** the IP allow/deny list judges the address of the connection unless a loopback, private-network or Cloudflare proxy fronts the request, and behind one it reads the client from what that proxy added rather than from what the client sent; a proxy on any other public address is named in the new `OMNIROUTE_TRUSTED_PROXIES`
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ These **must** be set before the first run. Without them, the application will e
| `SOURCE_VERSION` | No | _(unset)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Second in the chain — set by PaaS builders (e.g. Heroku-style) as the deployed commit. |
| `NEXT_PUBLIC_SW_BUILD_ID` | No | _(derived)_ | `src/shared/components/PwaRegister.tsx` | Build-time public value the client uses to register `/sw.js?v=…`; derived from the two above, then the git SHA. |
| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. |
| `OMNIROUTE_TRUSTED_PROXIES` | No | _(unset)_ | `scripts/dev/peer-stamp.mjs` | Comma-separated IPs or CIDR ranges of reverse proxies whose `X-Forwarded-For` / `X-Real-IP` the IP allow/deny list may believe, on top of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed only for a proxy on any other public address; otherwise that proxy's own address is what the filter judges. |

### Generation Commands

Expand Down
153 changes: 139 additions & 14 deletions scripts/dev/peer-stamp.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isIPv4, isIPv6 } from "node:net";
import { isIP, isIPv4, isIPv6 } from "node:net";
import { randomUUID } from "node:crypto";

/**
Expand All @@ -23,19 +23,33 @@ 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`) or arrived from
* a Cloudflare edge IP with `cf-connecting-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).
* came from a peer that may be a reverse proxy (this host, a private-network
* address, a Cloudflare edge or an address named in OMNIROUTE_TRUSTED_PROXIES)
* and carried forwarding headers (`x-forwarded-for` / `x-real-ip`), or arrived
* from a Cloudflare edge IP with `cf-connecting-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). A peer on any other address can
* write forwarding headers itself, so from it they do not set the marker.
*
* 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";

/**
* The address the IP allow/deny list should judge, as `<token>|<ip>`: the TCP
* peer itself, or, when the peer is a proxy that may be trusted, the client it
* reports (see resolveClientIp). Derived here, where the socket is known, so the
* middleware never has to choose between forwarding headers a client can write.
*
* Keep CLIENT_IP_HEADER in sync with CLIENT_IP_HEADER in
* src/server/authz/headers.ts (the TS side cannot import this .mjs).
*/
export const CLIENT_IP_HEADER = "x-omniroute-client-ip";

/**
* Cloudflare IPv4 ranges used to authenticate the `cf-connecting-ip` header.
*
Expand Down Expand Up @@ -171,16 +185,120 @@ export function isCloudflareIP(ip) {
return false;
}

/** 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
// Same ranges as PRIVATE_LAN_PATTERNS in src/server/authz/routeGuard.ts
// (tests/unit/authz/peer-stamp.test.ts checks they agree).
const PRIVATE_LAN_PATTERNS = [
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^100\.(6[4-9]|[78]\d|9\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
/^f[cd][0-9a-f]{2}:/i,
/^fe80:/i,
];

/**
* A plain IP address from a header value, or null. Accepts the forms proxies write:
* `::ffff:a.b.c.d`, `a.b.c.d:port` and `[v6]:port`.
*/
function normalizeIp(value) {
if (typeof value !== "string") return null;
let candidate = value.trim().replace(/^::ffff:/i, "");
if (isIP(candidate)) return candidate;
const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(candidate);
if (bracketed) candidate = bracketed[1].replace(/^::ffff:/i, "");
else {
const withPort = /^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/.exec(candidate);
if (withPort) candidate = withPort[1];
}
return isIP(candidate) ? candidate : null;
}

let configuredProxiesSource;
let configuredProxies = [];

/** Addresses and CIDR ranges the operator names in OMNIROUTE_TRUSTED_PROXIES. */
function getConfiguredProxies() {
const source = process.env.OMNIROUTE_TRUSTED_PROXIES || "";
if (source === configuredProxiesSource) return configuredProxies;
configuredProxiesSource = source;
configuredProxies = [];
for (const part of source.split(",")) {
const [address, bits] = part.trim().split("/");
const ip = normalizeIp(address);
if (!ip) continue;
const max = isIPv4(ip) ? 32 : 128;
const prefix = bits === undefined ? max : Number(bits);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > max) continue;
configuredProxies.push({ ip, cidr: `${ip}/${prefix}` });
}
return configuredProxies;
}

function isConfiguredProxy(ip) {
return getConfiguredProxies().some((entry) =>
isIPv4(ip) && isIPv4(entry.ip)
? matchesIPv4Cidr(ip, entry.cidr)
: isIPv6(ip) && isIPv6(entry.ip) && matchesIPv6Cidr(ip, entry.cidr)
);
}

/**
* True when a forwarding header from this TCP peer may be believed: the peer is this host, a
* private-network proxy, a Cloudflare edge, or an address the operator lists in
* OMNIROUTE_TRUSTED_PROXIES (a proxy on any other public address has to be named there). A
* client on any other address can write those headers itself, so they say nothing about who
* it is.
*/
export function isTrustedProxyPeer(ip) {
const normalized = normalizeIp(ip);
if (!normalized) return false;
if (normalized === "::1" || normalized.startsWith("127.")) return true;
if (PRIVATE_LAN_PATTERNS.some((re) => re.test(normalized))) return true;
return isCloudflareIP(normalized) || isConfiguredProxy(normalized);
}

/**
* The client address to judge for a request from `peerIp`. A peer that is not a trusted proxy
* is judged as itself. Behind a trusted proxy the client is what that proxy reported, read the
* way a proxy chain is built: a Cloudflare edge's `cf-connecting-ip`, else the right-most
* `x-forwarded-for` entry that is not itself a trusted proxy (the left-most entries are
* whatever the client sent), else `x-real-ip`. When nothing usable was reported the peer is
* judged.
*/
export function resolveClientIp(headers, peerIp) {
const peer = normalizeIp(peerIp);
if (!peer) return null;
if (!isTrustedProxyPeer(peer)) return peer;

if (isCloudflareIP(peer)) {
const edgeClient = normalizeIp(String(headers["cf-connecting-ip"] || "").split(",")[0]);
if (edgeClient) return edgeClient;
}

const chain = String(headers["x-forwarded-for"] || "").split(",");
let innermostProxy = null;
for (let index = chain.length - 1; index >= 0; index -= 1) {
const hop = normalizeIp(chain[index]);
if (!hop) break;
if (!isTrustedProxyPeer(hop)) return hop;
innermostProxy = hop;
}
if (innermostProxy) return innermostProxy;

return normalizeIp(String(headers["x-real-ip"] || "").split(",")[0]) || peer;
}

/** Strip any client-supplied PEER_IP_HEADER, VIA_PROXY_HEADER and CLIENT_IP_HEADER and stamp
* the real TCP peer IP, a token-protected via-proxy marker and the client address to judge.
* 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];
delete req.headers[CLIENT_IP_HEADER];
const ip = req.socket && req.socket.remoteAddress;
if (ip) {
const token = ensurePeerStampToken();
Expand All @@ -190,14 +308,21 @@ export function stampPeerIp(req) {
// trusted as local. Token-prefix the marker so a remote caller cannot
// forge it (or its absence) on a non-proxied request.
//
// x-forwarded-for / x-real-ip only count from a peer that may be a proxy: from
// any other peer they are just client-supplied text, and must not flip the marker
// and make the IP filter judge the header instead of the peer.
//
// `cf-connecting-ip` is Cloudflare-specific and trivially forged by a
// direct client. Only treat it as a proxy marker when the TCP peer itself
// is a Cloudflare edge IP; otherwise a direct forger could flip the
// via-proxy bit and force the middleware to ignore the real peer IP.
const hasGenericProxyHeaders = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
const hasCloudflareHeader = !!(req.headers["cf-connecting-ip"] && isCloudflareIP(ip));
const viaProxy = hasGenericProxyHeaders || hasCloudflareHeader;
const hasForwardingHeaders = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]);
const viaProxy =
(hasForwardingHeaders && isTrustedProxyPeer(ip)) ||
(!!req.headers["cf-connecting-ip"] && isCloudflareIP(ip));
req.headers[VIA_PROXY_HEADER] = `${token}|${viaProxy ? "1" : "0"}`;
const clientIp = viaProxy ? resolveClientIp(req.headers, ip) : normalizeIp(ip);
if (clientIp) req.headers[CLIENT_IP_HEADER] = `${token}|${clientIp}`;
}
} catch {
/* never block a request on peer stamping */
Expand Down
15 changes: 13 additions & 2 deletions src/server/authz/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ export const PEER_IP_HEADER = "x-omniroute-peer-ip";
/**
* Trusted "request arrived via a reverse proxy" marker stamped by the custom
* Node server alongside PEER_IP_HEADER, formatted as `<token>|1` when the
* inbound TCP request carried forwarding headers (`x-forwarded-for` /
* `x-real-ip`) and `<token>|0` otherwise. The middleware combines this with
* inbound TCP request came from a peer that may be a proxy (this host, a
* private-network address, a Cloudflare edge or an address named in
* OMNIROUTE_TRUSTED_PROXIES) and carried forwarding headers (`x-forwarded-for` /
* `x-real-ip`), or came from a Cloudflare edge with `cf-connecting-ip`, and
* `<token>|0` otherwise. The middleware combines this with
* the stamped peer IP so a loopback / private-LAN socket that is actually the
* proxy hop (e.g. OmniRoute behind nginx / Caddy / Cloudflare Tunnel) is NOT
* trusted as local — closing the upstream da667836 vulnerability that would
Expand All @@ -63,6 +66,14 @@ export const PEER_IP_HEADER = "x-omniroute-peer-ip";
*/
export const VIA_PROXY_HEADER = "x-omniroute-via-proxy";

/**
* The address the IP allow/deny list judges, stamped by the custom Node server as
* `<token>|<ip>`: the TCP peer, or the client a trusted proxy reported for it. Token-validated
* like PEER_IP_HEADER and stripped from forwarded headers in pipeline.ts.
* Keep in sync with CLIENT_IP_HEADER in scripts/dev/peer-stamp.mjs.
*/
export const CLIENT_IP_HEADER = "x-omniroute-client-ip";

/**
* Trusted locality verdict ("loopback" | "lan" | "remote") that the pipeline
* computes from the stamped real peer IP and forwards to route handlers. Route
Expand Down
3 changes: 2 additions & 1 deletion src/server/authz/peerStamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function resolveStampedPeer(
* Resolve the trusted "request arrived via a reverse proxy" marker stamped by
* the custom Node server (`scripts/dev/peer-stamp.mjs::stampPeerIp`). The stamp
* is `<token>|1` when forwarding headers (`x-forwarded-for` / `x-real-ip`) were
* present on the inbound TCP request, and `<token>|0` otherwise.
* present on an inbound TCP request from a peer that may be a proxy (loopback, private
* network, Cloudflare edge or a configured trusted proxy), and `<token>|0` otherwise.
*
* Returns true ONLY when the token constant-time-matches this process's stamp
* token AND the payload is exactly "1". Any other value — no stamp, forged
Expand Down
10 changes: 9 additions & 1 deletion src/server/authz/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
CLI_TOKEN_HEADER,
PEER_IP_HEADER,
VIA_PROXY_HEADER,
CLIENT_IP_HEADER,
} from "./headers";
import type { AuthSubject, RouteClass, RouteClassification } from "./types";
import type { AuthOutcome, RoutePolicy } from "./context";
Expand Down Expand Up @@ -315,6 +316,7 @@ export async function runAuthzPipeline(
// per-process token never reaches route handlers or upstream providers.
requestHeaders.delete(PEER_IP_HEADER);
requestHeaders.delete(VIA_PROXY_HEADER);
requestHeaders.delete(CLIENT_IP_HEADER);

requestHeaders.set(AUTHZ_HEADER_ROUTE_CLASS, classification.routeClass);
requestHeaders.set(AUTHZ_HEADER_REQUEST_ID, requestId);
Expand Down Expand Up @@ -385,7 +387,13 @@ export async function runAuthzPipeline(
request.headers.get(VIA_PROXY_HEADER),
process.env.OMNIROUTE_PEER_STAMP_TOKEN
);
const ipVerdict = checkRequestIP(request, viaProxy ? null : trustedPeerIp);
// The server stamps the client address to judge (the peer, or what a trusted proxy
// reported for it). Requests without that stamp keep the earlier rule.
const stampedClientIp = resolveStampedPeer(
request.headers.get(CLIENT_IP_HEADER),
process.env.OMNIROUTE_PEER_STAMP_TOKEN
);
const ipVerdict = checkRequestIP(request, stampedClientIp ?? (viaProxy ? null : trustedPeerIp));
if (!ipVerdict.allowed) {
const blocked = NextResponse.json(
{ error: ipVerdict.reason || "Access denied" },
Expand Down
Loading
Loading