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
19 changes: 15 additions & 4 deletions docs/1.guide/5.options.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,23 @@ serve({

Whether to trust `X-Forwarded-*` headers (`X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-For`, and the HTTP/2 `:scheme`) when deriving `request.url` and `request.ip`. Defaults to `false`.

Any client can send `X-Forwarded-Proto: https`, `X-Forwarded-Host` or `X-Forwarded-For`, so trusting them lets a request masquerade as `https:`, forge its host, or spoof its client IP. Only enable this when a proxy you control sits in front and overwrites the headers.
Any client can send `X-Forwarded-Proto: https`, `X-Forwarded-Host` or `X-Forwarded-For`, so trusting them lets a request masquerade as `https:`, forge its host, or spoof its client IP. Only enable this when a proxy you control sits in front.

- `false` (default): ignore the headers; use the real connection protocol, the on-the-wire `Host` header and the socket peer address.
- `true`: always trust the headers.
- `"loopback"`: trust them only when the proxy connects from a loopback address (`127.0.0.0/8` or `::1`).
- `string[]`: trust them only when the proxy's address is in the list.
- `true`: trust every hop (the leftmost `X-Forwarded-For` entry is the client).
- `"loopback"`: trust hops on a loopback address (`127.0.0.0/8` or `::1`), i.e. a proxy on the same host.
- `string[]`: trust hops whose address is in the allowlist.

#### Hop-aware resolution

Real proxies **append** to `X-Forwarded-*`, so the chain grows left-to-right (outermost client first, nearest proxy last). srvx resolves it **hop-aware, right-to-left**, matching Express (`trust proxy`), nginx (`$proxy_add_x_forwarded_for`) and AWS ALB:

- Starting from the immediate peer, each address in the trusted set is treated as a proxy you control.
- **The client is the rightmost address _not_ in the trusted set.**
- If every address in the chain is trusted, the leftmost entry is the client.
- If the immediate peer is not trusted, the headers are ignored entirely.

This is why a spoofed prefix cannot poison `request.ip`: with `trustProxy: "loopback"` and a request carrying `X-Forwarded-For: 9.9.9.9`, the trusted proxy appends the real client address, so the forged `9.9.9.9` is skipped rather than returned. The same model gates the forwarded protocol and host: only the value contributed by the trusted proxy is honored, so an attacker cannot poison `request.url` (e.g. cache keys or password-reset links).

**Example:**

Expand Down
149 changes: 122 additions & 27 deletions src/_trust-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import type { ServerPlugin, ServerRequest } from "./types.ts";
* Controls whether `X-Forwarded-*` headers (proto, host, for, and the HTTP/2
* `:scheme` pseudo-header) are trusted when deriving request metadata.
*
* These headers are set by the client on the wire, so they can only be trusted
* when a proxy you control sits in front and overwrites them. See
* {@link ServerOptions.trustProxy}.
* These headers are appended by every proxy on the wire, so trusting them is
* hop-aware: starting from the immediate peer and walking the forwarded chain
* right-to-left, each address in the trusted set is treated as a proxy we
* control. The first address *not* in the set is the real client (see
* {@link ServerOptions.trustProxy}).
*
* - `false` (default): never trust forwarded headers; derive protocol, host
* and client IP from the real transport only.
* - `true`: always trust forwarded headers.
* - `"loopback"`: trust only when the immediate peer is a loopback address
* (`127.0.0.0/8` or `::1`), i.e. a proxy running on the same host.
* - `string[]`: trust only when the immediate peer address is in the allowlist.
* - `true`: always trust forwarded headers (every hop is trusted, so the
* leftmost `X-Forwarded-For` entry is the client).
* - `"loopback"`: trust only hops on a loopback address (`127.0.0.0/8` or
* `::1`), i.e. a proxy running on the same host.
* - `string[]`: trust only hops whose address is in the allowlist.
*/
export type TrustProxyOption = boolean | "loopback" | string[];

Expand Down Expand Up @@ -79,19 +82,108 @@ function forwardedHostHasPort(host: string): boolean {
}

/**
* Leftmost/first entry of a comma-separated `X-Forwarded-*` header value. With a
* chain of proxies the header is a comma-separated list; the leftmost entry is
* the value seen by the outermost proxy. Node exposes repeated headers as a
* `string[]`, so the array form is normalized to its first element.
* Split a comma-separated `X-Forwarded-*` header value into trimmed, non-empty
* entries in header order (left to right, i.e. outermost/original client first,
* nearest proxy last). Node exposes repeated headers as a `string[]`, which is
* joined before splitting.
*/
export function firstForwardedValue(
export function forwardedList(value: string | string[] | null | undefined): string[] {
if (!value) {
return [];
}
const raw = Array.isArray(value) ? value.join(",") : value;
const out: string[] = [];
for (const part of raw.split(",")) {
const entry = part.trim();
if (entry) {
out.push(entry);
}
}
return out;
}

/**
* Resolve the real client IP hop-aware, per {@link TrustProxyOption}.
*
* Conceptually the hop chain (nearest first) is `[peer, ...reversed(forwarded)]`:
* the socket peer added no entry of its own, and each proxy appended the address
* it saw. Walking right-to-left, every address in the trusted set is a proxy we
* control; the first untrusted address is the client. If every hop is trusted
* the client is the leftmost forwarded entry (matching Express `trust proxy`),
* falling back to the peer when no `X-Forwarded-For` is present. If the peer
* itself is untrusted the header is ignored entirely and the peer is the client.
*/
export function resolveClientIP(
trustProxy: TrustProxyOption | undefined,
peer: string | undefined,
forwardedFor: string | string[] | null | undefined,
): string | undefined {
if (!isTrustedProxy(trustProxy, peer)) {
return peer;
}
const list = forwardedList(forwardedFor);
for (let i = list.length - 1; i >= 0; i--) {
if (!isTrustedProxy(trustProxy, list[i])) {
return list[i];
}
}
return list.length > 0 ? list[0] : peer;
}

/**
* Number of trusted hops in front of the server, counting the immediate peer and
* every trusted `X-Forwarded-For` entry walking right-to-left. `0` means the peer
* is untrusted, so no forwarded value may be honored. This count also selects the
* trusted entry of the parallel `X-Forwarded-Proto`/`-Host` lists (see
* {@link forwardedHopValue}).
*
* When the whole visible chain is trusted (the peer and every `X-Forwarded-For`
* entry — e.g. `trustProxy: true`, or the real client sits upstream of all seen
* proxies), the number of trusted hops is effectively unbounded, so `Infinity` is
* returned. A proto/host list may then legitimately be longer than the seen
* `X-Forwarded-For` chain, and its leftmost (original-client) entry is honored.
*/
export function trustedHops(
trustProxy: TrustProxyOption | undefined,
peer: string | undefined,
forwardedFor: string | string[] | null | undefined,
): number {
if (!isTrustedProxy(trustProxy, peer)) {
return 0;
}
const list = forwardedList(forwardedFor);
let hops = 1; // the peer
for (let i = list.length - 1; i >= 0; i--) {
if (!isTrustedProxy(trustProxy, list[i])) {
return hops;
}
hops++;
}
// No untrusted boundary found in the visible chain: treat as unbounded.
return Number.POSITIVE_INFINITY;
}

/**
* Pick the entry of a forwarded `X-Forwarded-Proto`/`-Host` list contributed by
* the outermost trusted proxy, given the {@link trustedHops} count. Proxies
* append these in lockstep with `X-Forwarded-For`, so with `hops` trusted hops we
* may trust the `hops` rightmost entries; the outermost trusted one is at index
* `length - hops` (clamped, so it degrades to the leftmost value when every hop
* is trusted or the list is shorter than the chain). Returns `undefined` when the
* peer is untrusted (`hops <= 0`) or the list is empty.
*/
export function forwardedHopValue(
value: string | string[] | null | undefined,
hops: number,
): string | undefined {
if (!value) {
if (hops <= 0) {
return undefined;
}
const list = forwardedList(value);
if (list.length === 0) {
return undefined;
}
const first = (Array.isArray(value) ? value[0] : value).split(",")[0].trim();
return first || undefined;
return list[Math.max(0, list.length - hops)];
}

/**
Expand All @@ -115,17 +207,20 @@ export const trustProxyPlugin: ServerPlugin = (server) => {
};

function applyTrustedProxy(request: ServerRequest, trustProxy: TrustProxyOption): void {
// The socket peer address stays authoritative for the trust decision, so read
// it (via the adapter's native getter) before any override below.
if (!isTrustedProxy(trustProxy, request.ip)) {
// The socket peer address (via the adapter's native getter) is the nearest hop
// and stays authoritative for the trust decision. `trustedHops` walks the
// `X-Forwarded-For` chain right-to-left from it; `0` means the peer is
// untrusted, so every forwarded header is ignored.
const peer = request.ip;
const headers = request.headers;
const hops = trustedHops(trustProxy, peer, headers.get("x-forwarded-for"));
if (hops === 0) {
return;
}

const headers = request.headers;

// request.url <- X-Forwarded-Proto / X-Forwarded-Host
const forwardedProto = firstForwardedValue(headers.get("x-forwarded-proto"));
const forwardedHost = firstForwardedValue(headers.get("x-forwarded-host"));
// request.url <- X-Forwarded-Proto / X-Forwarded-Host (trusted hop entry)
const forwardedProto = forwardedHopValue(headers.get("x-forwarded-proto"), hops);
const forwardedHost = forwardedHopValue(headers.get("x-forwarded-host"), hops);
if (forwardedProto || forwardedHost) {
const url = new URL(request.url);
if (forwardedProto === "https" || forwardedProto === "http") {
Expand All @@ -151,11 +246,11 @@ function applyTrustedProxy(request: ServerRequest, trustProxy: TrustProxyOption)
});
}

// request.ip <- X-Forwarded-For (leftmost = original client)
const forwardedFor = firstForwardedValue(headers.get("x-forwarded-for"));
if (forwardedFor) {
// request.ip <- X-Forwarded-For (first untrusted address, hop-aware)
const client = resolveClientIP(trustProxy, peer, headers.get("x-forwarded-for"));
if (client && client !== peer) {
Object.defineProperty(request, "ip", {
value: forwardedFor,
value: client,
enumerable: true,
configurable: true,
});
Expand Down
59 changes: 24 additions & 35 deletions src/adapters/_aws/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ServerRequest } from "../../types.ts";
import type { TrustProxyOption } from "../../_trust-proxy.ts";
import { isTrustedProxy, firstForwardedValue } from "../../_trust-proxy.ts";
import { resolveClientIP, trustedHops, forwardedHopValue } from "../../_trust-proxy.ts";
import type {
APIGatewayProxyEvent,
Context as AWSContext,
Expand Down Expand Up @@ -34,12 +34,14 @@ export function awsRequest(
context: AWSContext,
trustProxy?: TrustProxyOption,
): ServerRequest {
// Resolve the immediate-peer address and trust decision once and pass them
// down; both the URL and the client IP derivation need them.
// The gateway-verified `sourceIp` is the nearest hop. Resolve the trusted hop
// count once (walking `X-Forwarded-For` right-to-left from it) and pass it
// down; both the URL and the client IP derivation are hop-aware.
const sourceIp = awsEventIP(event);
const trusted = isTrustedProxy(trustProxy, sourceIp);
const forwardedFor = awsForwardedFor(event);
const hops = trustedHops(trustProxy, sourceIp, forwardedFor);

const req = new Request(awsEventURL(event, trusted), {
const req = new Request(awsEventURL(event, hops), {
method: awsEventMethod(event),
headers: awsEventHeaders(event),
body: awsEventBody(event),
Expand All @@ -50,11 +52,15 @@ export function awsRequest(
awsLambda: { event, context },
};

req.ip = awsEventClientIP(event, sourceIp, trusted);
req.ip = resolveClientIP(trustProxy, sourceIp, forwardedFor);

return req;
}

function awsForwardedFor(event: APIGatewayProxyEvent | APIGatewayProxyEventV2): string | undefined {
return event.headers["X-Forwarded-For"] || event.headers["x-forwarded-for"];
}

function awsEventMethod(event: APIGatewayProxyEvent | APIGatewayProxyEventV2): string {
return (
(event as APIGatewayProxyEvent).httpMethod ||
Expand All @@ -70,36 +76,18 @@ function awsEventIP(event: APIGatewayProxyEvent | APIGatewayProxyEventV2): strin
);
}

/**
* Resolve the client IP, preferring the leftmost `X-Forwarded-For` entry when
* the immediate peer (the gateway `sourceIp`) is a trusted proxy.
*/
function awsEventClientIP(
event: APIGatewayProxyEvent | APIGatewayProxyEventV2,
sourceIp: string | undefined,
trusted: boolean,
): string | undefined {
if (trusted) {
const forwarded = firstForwardedValue(
event.headers["X-Forwarded-For"] || event.headers["x-forwarded-for"],
);
if (forwarded) {
return forwarded;
}
}
return sourceIp;
}

function awsEventURL(event: APIGatewayProxyEvent | APIGatewayProxyEventV2, trusted: boolean): URL {
function awsEventURL(event: APIGatewayProxyEvent | APIGatewayProxyEventV2, hops: number): URL {
const path = (event as APIGatewayProxyEvent).path || (event as APIGatewayProxyEventV2).rawPath;

const query = awsEventQuery(event);

// Only honor client-supplied `X-Forwarded-*` headers when the proxy is
// trusted; otherwise any client could spoof the host or protocol.
const forwardedHost = trusted
? firstForwardedValue(event.headers["X-Forwarded-Host"] || event.headers["x-forwarded-host"])
: undefined;
// Only honor client-supplied `X-Forwarded-*` headers when the peer is trusted
// (`hops > 0`); otherwise any client could spoof the host or protocol. `hops`
// selects the outermost trusted proxy's entry from a comma-joined chain.
const forwardedHost = forwardedHopValue(
event.headers["X-Forwarded-Host"] || event.headers["x-forwarded-host"],
hops,
);
const hostname =
forwardedHost ||
event.headers.host ||
Expand All @@ -108,9 +96,10 @@ function awsEventURL(event: APIGatewayProxyEvent | APIGatewayProxyEventV2, trust
".";

// Assume `https` when untrusted (Lambda is always TLS-terminated at the gateway).
const forwardedProto = trusted
? firstForwardedValue(event.headers["X-Forwarded-Proto"] || event.headers["x-forwarded-proto"])
: undefined;
const forwardedProto = forwardedHopValue(
event.headers["X-Forwarded-Proto"] || event.headers["x-forwarded-proto"],
hops,
);
const protocol = forwardedProto === "http" ? "http" : "https";

return new URL(`${path}${query ? `?${query}` : ""}`, `${protocol}://${hostname}`);
Expand Down
Loading
Loading