From 171060ae50229db6b6465841f7072b3c84666f13 Mon Sep 17 00:00:00 2001 From: "dev.maya" Date: Tue, 29 Sep 2026 19:03:37 +0100 Subject: [PATCH 1/4] security: Derive client IPs from trusted proxy hops only (#1268) --- .env.example | 14 ++++++ FORWARDED_HEADER_POLICY.md | 30 ++++++++++-- src/config/env.ts | 34 ++++++++++++++ src/lib/__tests__/clientIp.test.ts | 48 +++++++++++++++---- src/lib/clientIp.ts | 75 ++++++++++++++++++++++++------ src/middleware/ipAllowlist.ts | 44 ++++++++++++------ 6 files changed, 205 insertions(+), 40 deletions(-) diff --git a/.env.example b/.env.example index a4c5aebf..43bd2594 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,20 @@ BCRYPT_COST_FACTOR=12 # ----------------------------------------------------------------------------- # Proxy / Gateway # ----------------------------------------------------------------------------- +# Number of trusted reverse-proxy hops in front of the app, or a comma-separated +# list of trusted proxy CIDRs. Controls how the client IP is derived from the +# X-Forwarded-For header (Express "trust proxy" semantics). +# +# TRUST_PROXY_HOPS=0 (default) — ignore X-Forwarded-For entirely and use the +# socket address. Safe when the app is directly +# exposed to clients. +# TRUST_PROXY_HOPS=1 — one trusted proxy; the client IP is taken one entry +# from the right of X-Forwarded-For (the value appended +# by that proxy). Leftmost entries are client-controlled +# and are never trusted. +# TRUST_PROXY_HOPS=2 — two trusted proxies (e.g. CDN + load balancer), etc. +# +# TRUST_PROXY_HOPS=0 UPSTREAM_URL=http://localhost:4000 PROXY_TIMEOUT_MS=30000 # REST API rate limiting (per-user with IP fallback for unauthenticated requests) diff --git a/FORWARDED_HEADER_POLICY.md b/FORWARDED_HEADER_POLICY.md index 7aee328b..a19f1ad2 100644 --- a/FORWARDED_HEADER_POLICY.md +++ b/FORWARDED_HEADER_POLICY.md @@ -4,6 +4,29 @@ This document outlines the Callora Backend proxy's header forwarding policy to ensure security and proper request routing while preventing sensitive information leakage. +## Client IP Resolution + +When a request arrives at the service, the client IP is resolved by +`src/lib/clientIp.ts`. The behaviour is governed by the `TRUST_PROXY_HEADERS` +environment variable, which accepts: + +- `false` (default) — no proxy hop is trusted. The direct socket + address (req.ip / req.socket.remoteAddress) is used and all + forwarded headers are ignored. +- `true` — alias for a single trusted proxy hop. +- a non-negative integer N — the number of trusted reverse proxy + hops between the client and the service. + +When N > 0, the client IP is taken from the entry N positions from the +right of the `X-Forwarded-For` chain (matching Express `trust proxy` +semantics). The leftmost entry is client-controlled and must never be +trusted when any proxy hop is configured. If the chain is shorter than N, +or the selected entry is not a valid IP, resolution falls back to the +socket address. + +For example, with one trusted hop and `X-Forwarded-For: 1.1.1.1, 2.2.2.2`, +the resolved client IP will be `2.2.2.2`. + ## Security Headers (Stripped Before Forwarding) The following headers are **never** forwarded to upstream services for security and privacy reasons: @@ -30,7 +53,7 @@ The following headers are **never** forwarded to upstream services for security The proxy adds the following headers to all upstream requests: -- `x-request-id` - Unique UUID v4 identifier for request tracing and correlation +- `x-request-id` - Unique UUIT v4 identifier for request tracing and correlation ## Safe Headers (Forwarded) @@ -40,7 +63,7 @@ All other headers not in the strip list are forwarded to upstream services, incl - `content-length` - Length of the request body - `accept` - Preferred response media types - `user-agent` - Client software identification -- `accept-encoding` - Preferred content encodings +- `accept-encoding` - Preferred response encodings - `accept-language` - Preferred response languages - Custom application headers (e.g., `x-custom-*`) @@ -71,7 +94,7 @@ Header stripping is performed case-insensitively. All header name variations (e. - Cookie headers are stripped to prevent session hijacking ### Request Tracing -- Unique `x-request-id` headers enable end-to-end request tracing +- Unique `x-request-id` Headers enable end-to-end request tracing - Request IDs are included in error responses for debugging - UUID v4 format ensures global uniqueness @@ -92,6 +115,7 @@ const DEFAULT_STRIP_HEADERS = [ 'proxy-authorization', 'proxy-connection', ]; + ``` Headers are processed case-insensitively using lowercase comparison: diff --git a/src/config/env.ts b/src/config/env.ts index c9625876..aade772e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -87,6 +87,40 @@ export const envSchema = z FORWARDED_USER_ID_SECRET: z.string().optional(), INTERNAL_GATEWAY_SECRET: z.string().optional(), + /** + * TRUST_PROXY_HOPS — number of trusted reverse-proxy hops in front of the + * application. Mirrors Express `trust proxy` numeric semantics: the client + * IP is taken that many positions from the right of the X-Forwarded-For + * list, so leftmost (client-controlled) entries cannot spoof the source. + * + * 0 (default) → ignore X-Forwarded-For entirely; use the socket address. + * 1 → one trusted proxy; take the rightmost XFF entry. + * N → N trusted proxies; take the Nth entry from the right. + * + * Values are clamped to a non-negative integer. When the header contains + * fewer entries than the configured hop count, callers fall back to the + * socket address (see src/lib/clientIp.ts). + */ + TRUST_PROXY_HOPS: z.coerce + .number() + .int() + .nonnegative() + .default(0), + + /** + * TRUST_PROXY_HEADERS — legacy boolean flag retained for backwards + * compatibility. When set to "true" and TRUST_PROXY_HOPS is left at its + * default, it is treated as a single trusted hop. Prefer TRUST_PROXY_HOPS + * for new deployments; the boolean cannot express multi-hop topologies and + * is retained only so existing environments do not silently lose proxy + * awareness. + */ + TRUST_PROXY_HEADERS: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + // Proxy / Gateway UPSTREAM_URL: z.string().url().default("http://localhost:4000"), UPSTREAM_HOST_ALLOWLIST: z.string().optional(), diff --git a/src/lib/__tests__/clientIp.test.ts b/src/lib/__tests__/clientIp.test.ts index eab579cd..9c28f18f 100644 --- a/src/lib/__tests__/clientIp.test.ts +++ b/src/lib/__tests__/clientIp.test.ts @@ -45,11 +45,35 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, false), '1.2.3.4'); }); - test('uses x-forwarded-for leftmost IP when trustProxy is true', () => { + test('with one trusted hop, selects the rightmost entry of x-forwarded-for', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, + socket: { remoteAddress: '10.0.0.1' } as never, + }); + assert.equal(getClientIp(req, 1), '2.2.2.2'); + }); + + test('with two trusted hops, selects the appropriate entry from the right', () => { const req = makeReq({ headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, }); - assert.equal(getClientIp(req, true), '5.5.5.5'); + assert.equal(getClientIp(req, 2), '10.0.0.1'); + }); + + test('true is treated as a single trusted hop', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, + }); + assert.equal(getClientIp(req, true), '2.2.2.2'); + }); + + test('spoofed leftmost entry cannot be selected with a trusted hop', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '9.9.9.9, 2.2.2.2' }, + socket: { remoteAddress: '10.0.0.1' } as never, + }); + assert.notEqual(getClientIp(req, 1), '9.9.9.9'); + assert.equal(getClientIp(req, 1), '2.2.2.2'); }); test('falls back to socket when proxy header is invalid', () => { @@ -57,7 +81,15 @@ describe('getClientIp', () => { headers: { 'x-forwarded-for': 'not-an-ip' }, socket: { remoteAddress: '1.2.3.4' } as never, }); - assert.equal(getClientIp(req, true), '1.2.3.4'); + assert.equal(getClientIp(req, 1), '1.2.3.4'); + }); + + test('falls back to socket when the trusted entry is invalid', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '1.1.1.1, not-an-ip' }, + socket: { remoteAddress: '10.0.0.1' } as never, + }); + assert.equal(getClientIp(req, 1), '10.0.0.1'); }); test('falls back to req.ip when socket is absent', () => { @@ -75,16 +107,16 @@ describe('getClientIp', () => { const reqBoth = makeReq({ headers: { 'x-forwarded-for': '5.5.5.5', 'x-real-ip': '6.6.6.6' }, }); - assert.equal(getClientIp(reqBoth, true), '5.5.5.5'); + assert.equal(getClientIp(reqBoth, 1), '5.5.5.5'); // Only x-real-ip present - const reqReal = makeReq({ headers: { 'x-real-ip': '6.6.6.6' } }); - assert.equal(getClientIp(reqReal, true), '6.6.6.6'); + const reqReal = makeReq( { headers: { 'x-real-ip': '6.6.6.6' } }); + assert.equal(getClientIp(reqReal, 1), '6.6.6.6'); }); test('accepts custom proxy header list', () => { - const req = makeReq({ headers: { 'x-custom-ip': '7.7.7.7' } }); - assert.equal(getClientIp(req, true, ['x-custom-ip']), '7.7.7.7'); + const req = makeReeq( { headers: { 'x-custom-ip': '7.7.7.7' } }); + assert.equal(getClientIp(req, 1, ['x-custom-ip']), '7.7.7.7'); }); test('DEFAULT_PROXY_HEADERS includes x-forwarded-for', () => { diff --git a/src/lib/clientIp.ts b/src/lib/clientIp.ts index 45d26f61..eb651bce 100644 --- a/src/lib/clientIp.ts +++ b/src/lib/clientIp.ts @@ -8,8 +8,8 @@ import type { Request } from 'express'; export const DEFAULT_PROXY_HEADERS = [ 'x-forwarded-for', // Standard – RFC 7239 'x-real-ip', // Nginx - 'x-client-ip', // Apache - 'x-forwarded', // Non-standard but widely used + 'x-client-ip', // Apache + 'x-forwarded', // Non-standard but widely used 'x-cluster-client-ip', // Load balancers 'cf-connecting-ip', // Cloudflare 'x-aws-client-ip', // AWS ALB @@ -18,37 +18,82 @@ export const DEFAULT_PROXY_HEADERS = [ /** Returns true for a plausible IPv4 or IPv6 address string. */ export function isValidIp(ip: string): boolean { const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + const ipv6 = /^([0-9a-fA-F]{0,4}:){2}{2,7}[0-9a-fA-F]{0,4}$/; return ipv4.test(ip) || ipv6.test(ip) || ip.includes(':'); } +/** + * Resolves the number of trusted proxy hops from the configured value. + * + * Accepts a number of hops or a boolean for backward compatibility: + * - `false` (default) -> 0 hops (never trust forwarded headers) + * - `true` -> 1 hop (trust the rightmost forwarded entry) + * - `number` -> that many trusted hops + */ +export function resolveTrustedHops(trustProxy: number | boolean | undefined): number { + if (trustProxy === true) return 1; + if (trustProxy === false || trustProxy === undefined) return 0; + if (!Number.isFinite(trustProxy) || trustProxy < 0) return 0; + return Math.floor(trustProxy); +} + +/** + * Selects the client IP from a forwarded chain using Express 'trust proxy' + * semantics: the address is picked `trustedHops`+1 positions from the + * right of the chain. The leftmost entries are client-controlled and must not + * be trusted. + */ +export function selectClientIpWithTrust( + chain: string, + trustedHops: number, +): string | undedefined { + if (trustedHops < 1) return undefined; + + const parts = chain + .split(',') + .map((part) => part.trim()) + .filter((part) => part.length > 0); + + if (parts.length === 0) return undefined; + + // Express picks the address `trustedHops` positions from the right. + // With one trusted hop, '1.1.1.1, 2.2.2.2' yields '2.2.2.2'. + const index = parts.length - trustedHops; + if (index < 0 || index >= parts.length) return undefined; + + const candidate = parts[index]; + return isValidIp(candidate) ? candidate : undefined; +} + /** * Extracts the real client IP from an Express request. * - * When `trustProxy` is false (the default) the direct socket address is - * returned, making IP spoofing via headers impossible. + * When the trusted hop count is 0 the direct socket address is returned, + * making IP spoofing via headers impossible. * - * When `trustProxy` is true the proxy headers listed in `proxyHeaders` are - * consulted in order; the first valid IP wins. For `x-forwarded-for` only - * the leftmost entry is used because that is the original client address — - * subsequent entries are added by intermediary proxies and must not be trusted - * as the client origin. + * When trusted hops are configured the proxy headers listed in `proxyHeaders` + * are consulted in order. For `X-Forwarded-For` the entry `trustedHops` + * positions from the right is used, matching Express 'trust proxy' semantics. + * Spoofed leftmost entries are ignored. Other headers are treated as a + * single-value hint and only trusted when at least one hop is trusted. * * @param req Express request object - * @param trustProxy Whether to honour proxy forwarding headers + * @param trustProxy Number of trusted proxy hops (boolean supported for backwards compat) * @param proxyHeaders Ordered list of headers to inspect (defaults to {@link DEFAULT_PROXY_HEADERS}) */ export function getClientIp( req: Request, - trustProxy = false, + trustProxy: number | boolean = false, proxyHeaders: readonly string[] = DEFAULT_PROXY_HEADERS, ): string { - if (trustProxy) { + const trustedHops = resolveTrustedHops(trustProxy); + + if (trustedHops > 0) { for (const header of proxyHeaders) { const value = req.headers[header.toLowerCase()]; if (typeof value === 'string' && value.trim()) { - const firstIp = value.split(',')[0].trim(); - if (isValidIp(firstIp)) return firstIp; + const candidate = selectClientIpWithTrust(value, trustedHops); + if (candidate) return candidate; } } } diff --git a/src/middleware/ipAllowlist.ts b/src/middleware/ipAllowlist.ts index 45cab559..973e1f96 100644 --- a/src/middleware/ipAllowlist.ts +++ b/src/middleware/ipAllowlist.ts @@ -1,7 +1,6 @@ import type { Request, Response, NextFunction } from 'express'; import ipRangeCheck from 'ip-range-check'; -import { logger } from './logging.js'; -import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS } from '../lib/clientIp.js'; +import { logger } from './logging.js';import { getClientIp, isValidId, DEFAULT_PROXY_HEADERS, type TrustProxy } from '../lib/clientIp.js'; /** * Configuration for IP allowlist middleware @@ -10,14 +9,16 @@ export interface IpAllowlistConfig { /** List of allowed IP ranges in CIDR notation */ allowedRanges: string[]; /** - * Whether to trust proxy headers for IP resolution. + * Number of trusted proxy hops to skip when resolving the client IP. * - * Security note: set this to `true` only when the service sits behind a - * trusted reverse proxy that you control. When `false` (the default) the - * direct socket address is used, making header-spoofing impossible. + * Security note: set this to a positive number only when the service + * sits behind that many trusted reverse proxies that you control. + * When 0 (the default) the direct socket address is used, making + * header-spoofing impossible. `true` is accepted as an alias for + * a single trusted hop. * See FORWARDED_HEADER_POLICY.md for the full trust-boundary policy. */ - trustProxy?: boolean; + trustProxy?: TrustProxy; /** Custom proxy headers to check (in order of priority) */ proxyHeaders?: string[]; /** Whether to enable the allowlist (defaults to true) */ @@ -28,9 +29,9 @@ export interface IpAllowlistConfig { * Creates IP allowlist middleware for protecting sensitive endpoints. * * IP resolution follows the trust-boundary policy in FORWARDED_HEADER_POLICY.md: - * - When trustProxy is false, the direct socket address is used (spoof-proof). - * - When trustProxy is true, only the leftmost entry of X-Forwarded-For is - * used, as subsequent entries are added by intermediary proxies. + * - When trustProxy is 0, the direct socket address is used (spoof-proof). + * - When trustProxy is N, the entry N positions from the right of + * X-Forwarded-For is used, matching Express' `trust proxy` semantics. */ export function createIpAllowlist(config: IpAllowlistConfig) { const { @@ -60,8 +61,8 @@ export function createIpAllowlist(config: IpAllowlistConfig) { return; } - // Resolve client IP per trust-boundary policy: when trustProxy is false - // getClientIp returns req.ip (socket address), ignoring all forwarded headers. + // Resolve client IP per trust-boundary policy: when trustProxy is 0, getClientIp + // returns req.ip (socket address), ignoring all forwarded headers. const clientIp = getClientIp(req, trustProxy, proxyHeaders); if (!isValidIp(clientIp)) { @@ -111,13 +112,28 @@ export function createIpAllowlist(config: IpAllowlistConfig) { }; } +/** + * Parses the TRUST_PROXY_HEADERS environment variable into a trust + * configuration. Accepts `true`/`false` as well as a non-negative integer + * hop count. Unrecognized values fall back to no trust. + */ +function parseTrustProxyEnv(value: string | undefined): TrustProxy { + if (value === undefined || value.trim() === '') return false; + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + const parsed = Number(normalized); + if (Number.isInteger(parsed) && parsed >= 0) return parsed; + return false; +} + /** * Pre-configured IP allowlist for admin endpoints. * Uses environment variables for configuration. */ export function createAdminIpAllowlist() { const allowedRanges = process.env.ADMIN_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = process.env.TRUST_PROXY_HEADERS === 'true'; + const trustProxy = parseTrustProxyEnv(process.env.TRUST_PROXY_HEADERS); const enabled = process.env.ADMIN_IP_ALLOWLIST_ENABLED !== 'false'; if (allowedRanges.length === 0) { @@ -134,7 +150,7 @@ export function createAdminIpAllowlist() { */ export function createGatewayIpAllowlist() { const allowedRanges = process.env.GATEWAY_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = process.env.TRUST_PROXY_HEADERS === 'true'; + const trustProxy = parseTrustProxyEnv(process.env.TRUST_PROXY_HEADERS); const enabled = process.env.GATEWAY_IP_ALLOWLIST_ENABLED !== 'false'; if (allowedRanges.length === 0) { From 6e148cd239a151dddfa749483e52c4c4dc9bd749 Mon Sep 17 00:00:00 2001 From: "dev.maya" Date: Tue, 29 Sep 2026 19:05:58 +0100 Subject: [PATCH 2/4] security: Derive client IPs from trusted proxy hops only (#1268) --- FORWARDED_HEADER_POLICY.md | 65 +++++++++--------- src/config/env.ts | 39 +++-------- src/lib/__tests__/clientIp.test.ts | 41 ++++++------ src/lib/clientIp.ts | 104 ++++++++++++----------------- src/middleware/ipAllowlist.ts | 66 ++++++++++-------- 5 files changed, 142 insertions(+), 173 deletions(-) diff --git a/FORWARDED_HEADER_POLICY.md b/FORWARDED_HEADER_POLICY.md index a19f1ad2..ffe9037a 100644 --- a/FORWARDED_HEADER_POLICY.md +++ b/FORWARDED_HEADER_POLICY.md @@ -4,29 +4,6 @@ This document outlines the Callora Backend proxy's header forwarding policy to ensure security and proper request routing while preventing sensitive information leakage. -## Client IP Resolution - -When a request arrives at the service, the client IP is resolved by -`src/lib/clientIp.ts`. The behaviour is governed by the `TRUST_PROXY_HEADERS` -environment variable, which accepts: - -- `false` (default) — no proxy hop is trusted. The direct socket - address (req.ip / req.socket.remoteAddress) is used and all - forwarded headers are ignored. -- `true` — alias for a single trusted proxy hop. -- a non-negative integer N — the number of trusted reverse proxy - hops between the client and the service. - -When N > 0, the client IP is taken from the entry N positions from the -right of the `X-Forwarded-For` chain (matching Express `trust proxy` -semantics). The leftmost entry is client-controlled and must never be -trusted when any proxy hop is configured. If the chain is shorter than N, -or the selected entry is not a valid IP, resolution falls back to the -socket address. - -For example, with one trusted hop and `X-Forwarded-For: 1.1.1.1, 2.2.2.2`, -the resolved client IP will be `2.2.2.2`. - ## Security Headers (Stripped Before Forwarding) The following headers are **never** forwarded to upstream services for security and privacy reasons: @@ -53,19 +30,19 @@ The following headers are **never** forwarded to upstream services for security The proxy adds the following headers to all upstream requests: -- `x-request-id` - Unique UUIT v4 identifier for request tracing and correlation +- `x-request-id` - Unique UUID v4 identifier for request tracing and correlation ## Safe Headers (Forwarded) All other headers not in the strip list are forwarded to upstream services, including but not limited to: -- `content-type` - Media type of the request body -- `content-length` - Length of the request body -- `accept` - Preferred response media types -- `user-agent` - Client software identification -- `accept-encoding` - Preferred response encodings -- `accept-language` - Preferred response languages -- Custom application headers (e.g., `x-custom-*`) +- `content-type` - Media type of the request body +- `content-length` - Length of the request body +- `accept` - Preferred response media types +- `user-agent` - Client software identification +- `accept-encoding` - Preferred response encodings +- `accept-language` - Preferred response languages +- Custom application headers (e.g., `x-custom-*`) ## Response Header Handling @@ -80,7 +57,7 @@ All upstream response headers are forwarded to the client **except** hop-by-hop - `upgrade` ### Headers Overridden by Proxy -- `x-request-id` - Always set to the proxy's request ID for correlation +- x-request-id` - Always set to the proxy's request ID for correlation ## Case Sensitivity @@ -94,10 +71,24 @@ Header stripping is performed case-insensitively. All header name variations (e. - Cookie headers are stripped to prevent session hijacking ### Request Tracing -- Unique `x-request-id` Headers enable end-to-end request tracing +- Unique `x-request-id` headers enable end-to-end request tracing - Request IDs are included in error responses for debugging - UUID v4 format ensures global uniqueness +## Client IP Resolution (Trust Boundary) + +The client IP used by the IP-allowlist middleware and the request logger is resolved in `src/lib/clientIp.ts` and follows Express' `trust proxy` semantics: + +- **No trust (default)** — all forwarded headers are ignored and the direct socket address (`req.ip` / `req.socket.remoteAddress`) is used. This is spoof-proof. +- **Trusted hop count** — the client address is the entry that many positions from the right of the `x-forwarded-for` chain. The leftmost entries are client-controlled and must not be trusted. For example, with one trusted hop, `X-Forwarded-For: 1.1.1.1, 2.2.2.2` yields `2.2.2.2`. +- If the chain is shorter than the configured hop count, or the selected entry is not a valid IP, the resolver falls back to the socket address. + +The hop count is configured via the `TRUST_PROXY_HEADERS` environment variable: + +- `false` or unset — no trusted proxy hops (socket address) +- `true` — equivalent to a hop count of 1 +- a non-negative integer (e.g. `2`) — the number of trusted proxy hops in front of the app + ## Implementation Details The header policy is implemented in `src/routes/proxyRoutes.ts`: @@ -115,7 +106,6 @@ const DEFAULT_STRIP_HEADERS = [ 'proxy-authorization', 'proxy-connection', ]; - ``` Headers are processed case-insensitively using lowercase comparison: @@ -137,5 +127,10 @@ Comprehensive tests verify: - Case-insensitive header stripping works - Response headers are filtered appropriately - Request ID correlation is maintained +- Client IP resolution honours the trusted hop count and falls back to the socket address + +### Client IP Tests + +See `src/lib/__tests__/clientIp.test.ts` for detailed coverage of the trust-boundary logic. -See `src/__tests__/proxy.integration.test.ts` for detailed test coverage. +See `src/__tests__/proxy.integration.test.ts` for detailed test coverage of header forwarding. diff --git a/src/config/env.ts b/src/config/env.ts index aade772e..ed8e4b9b 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -89,37 +89,20 @@ export const envSchema = z /** * TRUST_PROXY_HOPS — number of trusted reverse-proxy hops in front of the - * application. Mirrors Express `trust proxy` numeric semantics: the client - * IP is taken that many positions from the right of the X-Forwarded-For - * list, so leftmost (client-controlled) entries cannot spoof the source. + * application. Used by {@link getClientIp} (src/lib/clientIp.ts) to select + * the correct entry from the right-hand side of `X-Forwarded-For`, matching + * Express `trust proxy` semantics. * - * 0 (default) → ignore X-Forwarded-For entirely; use the socket address. - * 1 → one trusted proxy; take the rightmost XFF entry. - * N → N trusted proxies; take the Nth entry from the right. + * - 0 (default): no proxy is trusted; the socket address is always used. + * Client-supplied `X-Forwarded-For` headers are ignored. + * - N > 0: the Nth entry from the right of `X-Forwarded-For` is treated as + * the client IP. Entries further left are considered attacker-controlled + * and are never used for allowlisting or rate limiting. * - * Values are clamped to a non-negative integer. When the header contains - * fewer entries than the configured hop count, callers fall back to the - * socket address (see src/lib/clientIp.ts). + * When the header contains fewer than N entries, the helper falls back to + * the socket address rather than trusting a partially-populated header. */ - TRUST_PROXY_HOPS: z.coerce - .number() - .int() - .nonnegative() - .default(0), - - /** - * TRUST_PROXY_HEADERS — legacy boolean flag retained for backwards - * compatibility. When set to "true" and TRUST_PROXY_HOPS is left at its - * default, it is treated as a single trusted hop. Prefer TRUST_PROXY_HOPS - * for new deployments; the boolean cannot express multi-hop topologies and - * is retained only so existing environments do not silently lose proxy - * awareness. - */ - TRUST_PROXY_HEADERS: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), + TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0), // Proxy / Gateway UPSTREAM_URL: z.string().url().default("http://localhost:4000"), diff --git a/src/lib/__tests__/clientIp.test.ts b/src/lib/__tests__/clientIp.test.ts index 9c28f18f..fcacde87 100644 --- a/src/lib/__tests__/clientIp.test.ts +++ b/src/lib/__tests__/clientIp.test.ts @@ -45,51 +45,50 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, false), '1.2.3.4'); }); - test('with one trusted hop, selects the rightmost entry of x-forwarded-for', () => { + test('with one trusted hop, x-forwarded-for yields the rightmost entry', () => { const req = makeReq({ headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, - socket: { remoteAddress: '10.0.0.1' } as never, }); assert.equal(getClientIp(req, 1), '2.2.2.2'); }); - test('with two trusted hops, selects the appropriate entry from the right', () => { + test('treats true as a single trusted hop', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, + headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, }); - assert.equal(getClientIp(req, 2), '10.0.0.1'); + assert.equal(getClientIp(req, true), '2.2.2.2'); }); - test('true is treated as a single trusted hop', () => { + test('selects the entry that many positions from the right for multiple hops', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, + headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, }); - assert.equal(getClientIp(req, true), '2.2.2.2'); + assert.equal(getClientIp(req, 2), '10.0.0.1'); + assert.equal(getClientIp(req, 3), '5.5.5.5'); }); - test('spoofed leftmost entry cannot be selected with a trusted hop', () => { + test('spoofed leftmost entries cannot satisfy the resolved IP', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '9.9.9.9, 2.2.2.2' }, - socket: { remoteAddress: '10.0.0.1' } as never, + headers: { 'x-forwarded-for': '10.0.0.1, 1.1.1.1, 2.2.2.2' }, }); - assert.notEqual(getClientIp(req, 1), '9.9.9.9'); + // With one trusted hop the attacker-controlled leftmost entries are ignored. assert.equal(getClientIp(req, 1), '2.2.2.2'); }); - test('falls back to socket when proxy header is invalid', () => { + test('falls back to socket when the chain is shorter than the hop count', () => { const req = makeReq({ - headers: { 'x-forwarded-for': 'not-an-ip' }, + headers: { 'x-forwarded-for': '1.1.1.1' }, socket: { remoteAddress: '1.2.3.4' } as never, }); - assert.equal(getClientIp(req, 1), '1.2.3.4'); + assert.equal(getClientIp(req, 2), '1.2.3.4'); }); - test('falls back to socket when the trusted entry is invalid', () => { + test('falls back to socket when proxy header is invalid', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '1.1.1.1, not-an-ip' }, - socket: { remoteAddress: '10.0.0.1' } as never, + headers: { 'x-forwarded-for': 'not-an-ip' }, + socket: { remoteAddress: '1.2.3.4' } as never, }); - assert.equal(getClientIp(req, 1), '10.0.0.1'); + assert.equal(getClientIp(req, 1), '1.2.3.4'); }); test('falls back to req.ip when socket is absent', () => { @@ -110,12 +109,12 @@ describe('getClientIp', () => { assert.equal(getClientIp(reqBoth, 1), '5.5.5.5'); // Only x-real-ip present - const reqReal = makeReq( { headers: { 'x-real-ip': '6.6.6.6' } }); + const reqReal = makeReq({ headers: { 'x-real-ip': '6.6.6.6' } }); assert.equal(getClientIp(reqReal, 1), '6.6.6.6'); }); test('accepts custom proxy header list', () => { - const req = makeReeq( { headers: { 'x-custom-ip': '7.7.7.7' } }); + const req = makeReq({ headers: { 'x-custom-ip': '7.7.7.7' } }); assert.equal(getClientIp(req, 1, ['x-custom-ip']), '7.7.7.7'); }); diff --git a/src/lib/clientIp.ts b/src/lib/clientIp.ts index eb651bce..98230017 100644 --- a/src/lib/clientIp.ts +++ b/src/lib/clientIp.ts @@ -1,5 +1,7 @@ import type { Request } from 'express'; +export type TrustProxyOption = boolean | number; + /** * Proxy headers checked when trustProxy is enabled, ordered by reliability. * The same list is used by the IP-allowlist middleware and the request logger @@ -8,8 +10,8 @@ import type { Request } from 'express'; export const DEFAULT_PROXY_HEADERS = [ 'x-forwarded-for', // Standard – RFC 7239 'x-real-ip', // Nginx - 'x-client-ip', // Apache - 'x-forwarded', // Non-standard but widely used + 'x-client-ip', // Apache + 'x-forwarded', // Non-standard but widely used 'x-cluster-client-ip', // Load balancers 'cf-connecting-ip', // Cloudflare 'x-aws-client-ip', // AWS ALB @@ -22,81 +24,63 @@ export function isValidIp(ip: string): boolean { return ipv4.test(ip) || ipv6.test(ip) || ip.includes(':'); } -/** - * Resolves the number of trusted proxy hops from the configured value. - * - * Accepts a number of hops or a boolean for backward compatibility: - * - `false` (default) -> 0 hops (never trust forwarded headers) - * - `true` -> 1 hop (trust the rightmost forwarded entry) - * - `number` -> that many trusted hops - */ -export function resolveTrustedHops(trustProxy: number | boolean | undefined): number { - if (trustProxy === true) return 1; - if (trustProxy === false || trustProxy === undefined) return 0; - if (!Number.isFinite(trustProxy) || trustProxy < 0) return 0; - return Math.floor(trustProxy); -} - -/** - * Selects the client IP from a forwarded chain using Express 'trust proxy' - * semantics: the address is picked `trustedHops`+1 positions from the - * right of the chain. The leftmost entries are client-controlled and must not - * be trusted. - */ -export function selectClientIpWithTrust( - chain: string, - trustedHops: number, -): string | undedefined { - if (trustedHops < 1) return undefined; - - const parts = chain - .split(',') - .map((part) => part.trim()) - .filter((part) => part.length > 0); - - if (parts.length === 0) return undefined; - - // Express picks the address `trustedHops` positions from the right. - // With one trusted hop, '1.1.1.1, 2.2.2.2' yields '2.2.2.2'. - const index = parts.length - trustedHops; - if (index < 0 || index >= parts.length) return undefined; - - const candidate = parts[index]; - return isValidIp(candidate) ? candidate : undefined; -} - /** * Extracts the real client IP from an Express request. * - * When the trusted hop count is 0 the direct socket address is returned, - * making IP spoofing via headers impossible. + * Trust semantics follow Express' `trust proxy` model: + * - `false` (default): all forwarded headers are ignored and the direct + * socket address is returned, making header spoofing impossible. + * - `true`: treats the immediate peer as a trusted proxy (equivalent to + * a hop count of 1). + * - `number`: the number of trusted proxy hops in front of the app. * - * When trusted hops are configured the proxy headers listed in `proxyHeaders` - * are consulted in order. For `X-Forwarded-For` the entry `trustedHops` - * positions from the right is used, matching Express 'trust proxy' semantics. - * Spoofed leftmost entries are ignored. Other headers are treated as a - * single-value hint and only trusted when at least one hop is trusted. + * For the `x-forwarded-for` chain the client IP selected is the entry that + * many positions from the right (the leftmost entries are client-controlled + * and must not be trusted). Other single-value proxy headers are only + * consulted when the chain is exhausted, and the socket address is the + * final fallback. * * @param req Express request object - * @param trustProxy Number of trusted proxy hops (boolean supported for backwards compat) + * @param trustProxy Whether to honour proxy forwarding headers, or how + * many trusted proxy hops to skip from the right * @param proxyHeaders Ordered list of headers to inspect (defaults to {@link DEFAULT_PROXY_HEADERS}) */ export function getClientIp( req: Request, - trustProxy: number | boolean = false, + trustProxy: TrustProxyOption = false, proxyHeaders: readonly string[] = DEFAULT_PROXY_HEADERS, ): string { - const trustedHops = resolveTrustedHops(trustProxy); + const hops = normalizeTrustProxy(trustProxy); - if (trustedHops > 0) { + if (hops > 0) { for (const header of proxyHeaders) { const value = req.headers[header.toLowerCase()]; - if (typeof value === 'string' && value.trim()) { - const candidate = selectClientIpWithTrust(value, trustedHops); - if (candidate) return candidate; - } + if (typeof value !== 'string' || !value.trim()) continue; + + const entries = value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + // The client is `trustedHops` positions from the right of the chain. + // When the chain is shorter than the configured hop count the + // client address is unknown, so we move on to the next source. + const index = entries.length - hops; + if (index < 0) continue; + + const candidate = entries[index]; + if (isValidIp(candidate)) return candidate; } } return req.ip ?? req.socket?.remoteAddress ?? ''; } + +/** Normalizes the trust-proxy option into a non-negative hop count. */ +function normalizeTrustProxy(trustProxy: TrustProxyOption): number { + if (trustProxy === true) return 1; + if (typeof trustProxy === 'number' && Number.isFinite(trustProxy)) { + return Math.max(0, Math.floor(trustProxy)); + } + return 0; +} diff --git a/src/middleware/ipAllowlist.ts b/src/middleware/ipAllowlist.ts index 973e1f96..3f5b4761 100644 --- a/src/middleware/ipAllowlist.ts +++ b/src/middleware/ipAllowlist.ts @@ -1,6 +1,7 @@ import type { Request, Response, NextFunction } from 'express'; import ipRangeCheck from 'ip-range-check'; -import { logger } from './logging.js';import { getClientIp, isValidId, DEFAULT_PROXY_HEADERS, type TrustProxy } from '../lib/clientIp.js'; +import { logger } from './logging.js'; +import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS, TrustProxyOption } from '../lib/clientIp.js'; /** * Configuration for IP allowlist middleware @@ -9,16 +10,17 @@ export interface IpAllowlistConfig { /** List of allowed IP ranges in CIDR notation */ allowedRanges: string[]; /** - * Number of trusted proxy hops to skip when resolving the client IP. + * Whether to trust proxy headers for IP resolution, and how many hops to + * trust. * - * Security note: set this to a positive number only when the service - * sits behind that many trusted reverse proxies that you control. - * When 0 (the default) the direct socket address is used, making - * header-spoofing impossible. `true` is accepted as an alias for - * a single trusted hop. + * Security note: set this to a non-zero value only when the service sits + * behind a trusted reverse proxy that you control. When `false` (the + * default) the direct socket address is used, making header-spoofing + * impossible. A number specifies how many trusted proxy hops sit in + * front of the app. * See FORWARDED_HEADER_POLICY.md for the full trust-boundary policy. */ - trustProxy?: TrustProxy; + trustProxy?: TrustProxyOption; /** Custom proxy headers to check (in order of priority) */ proxyHeaders?: string[]; /** Whether to enable the allowlist (defaults to true) */ @@ -29,9 +31,10 @@ export interface IpAllowlistConfig { * Creates IP allowlist middleware for protecting sensitive endpoints. * * IP resolution follows the trust-boundary policy in FORWARDED_HEADER_POLICY.md: - * - When trustProxy is 0, the direct socket address is used (spoof-proof). - * - When trustProxy is N, the entry N positions from the right of - * X-Forwarded-For is used, matching Express' `trust proxy` semantics. + * - When trustProxy is false, the direct socket address is used (spoof-proof). + * - When trustProxy is a hop count, the client address is taken that many + * positions from the right of the forwarded chain, so client-controlled + * leftmost entries cannot spoof the resolved IP. */ export function createIpAllowlist(config: IpAllowlistConfig) { const { @@ -61,14 +64,14 @@ export function createIpAllowlist(config: IpAllowlistConfig) { return; } - // Resolve client IP per trust-boundary policy: when trustProxy is 0, getClientIp - // returns req.ip (socket address), ignoring all forwarded headers. + // Resolve client IP per trust-boundary policy: when trustProxy is false + // getClientIp returns req.ip (socket address), ignoring all forwarded headers. const clientIp = getClientIp(req, trustProxy, proxyHeaders); if (!isValidIp(clientIp)) { logger.warn( { - ip: clientIp, + ip: clientIk, userAgent: req.get('User-Agent'), path: req.path, }, @@ -112,18 +115,23 @@ export function createIpAllowlist(config: IpAllowlistConfig) { }; } -/** - * Parses the TRUST_PROXY_HEADERS environment variable into a trust - * configuration. Accepts `true`/`false` as well as a non-negative integer - * hop count. Unrecognized values fall back to no trust. - */ -function parseTrustProxyEnv(value: string | undefined): TrustProxy { - if (value === undefined || value.trim() === '') return false; - const normalized = value.trim().toLowerCase(); - if (normalized === 'true') return true; - if (normalized === 'false') return false; - const parsed = Number(normalized); - if (Number.isInteger(parsed) && parsed >= 0) return parsed; +/** Parses the TRUST_PROXY_HEADERS env variable into a trust-proxy option. */ +function parseTrustProxyEnv(): TrustProxyOption { + const raw = process.env.TRUST_PROXY_HEADERS; + if (raw === undefined) return false; + + const trimmed = raw.trim(); + if (trimmed === '') return false; + if (trimmed === 'true') return 1; + if (trimmed === 'false') return false; + + const hops = Number(trimmed); + if (Number.isFinite(hops) && hops >= 0) return Math.floor(hops); + + logger.warn( + { value: raw }, + 'Invalid TRUST_PROXY_HEADERS value; defaulting to no trusted proxy hops', + ); return false; } @@ -133,11 +141,11 @@ function parseTrustProxyEnv(value: string | undefined): TrustProxy { */ export function createAdminIpAllowlist() { const allowedRanges = process.env.ADMIN_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = parseTrustProxyEnv(process.env.TRUST_PROXY_HEADERS); + const trustProxy = parseTrustProxyEnv(); const enabled = process.env.ADMIN_IP_ALLOWLIST_ENABLED !== 'false'; if (allowedRanges.length === 0) { - logger.warn('Admin IP allowlist is empty - allowing all IPs'); + logger.warn('tminAdmin IP allowlist is empty - allowing all IPs'); return (_req: Request, _res: Response, next: NextFunction): void => next(); } @@ -150,7 +158,7 @@ export function createAdminIpAllowlist() { */ export function createGatewayIpAllowlist() { const allowedRanges = process.env.GATEWAY_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = parseTrustProxyEnv(process.env.TRUST_PROXY_HEADERS); + const trustProxy = parseTrustProxyEnv(); const enabled = process.env.GATEWAY_IP_ALLOWLIST_ENABLED !== 'false'; if (allowedRanges.length === 0) { From 6cc6c896461ad6d6cb15ef723c2e0194c7dcf395 Mon Sep 17 00:00:00 2001 From: "dev.maya" Date: Tue, 29 Sep 2026 22:47:04 +0100 Subject: [PATCH 3/4] security: Derive client IPs from trusted proxy hops only (#1268) --- FORWARDED_HEADER_POLICY.md | 52 ++++----- src/config/env.ts | 40 ++++--- src/lib/__tests__/clientIp.test.ts | 39 ++++--- src/lib/clientIp.ts | 136 ++++++++++++++++------- src/middleware/ipAllowlist.ts | 171 +---------------------------- 5 files changed, 175 insertions(+), 263 deletions(-) diff --git a/FORWARDED_HEADER_POLICY.md b/FORWARDED_HEADER_POLICY.md index ffe9037a..544a6c52 100644 --- a/FORWARDED_HEADER_POLICY.md +++ b/FORWARDED_HEADER_POLICY.md @@ -36,13 +36,13 @@ The proxy adds the following headers to all upstream requests: All other headers not in the strip list are forwarded to upstream services, including but not limited to: -- `content-type` - Media type of the request body -- `content-length` - Length of the request body -- `accept` - Preferred response media types -- `user-agent` - Client software identification -- `accept-encoding` - Preferred response encodings -- `accept-language` - Preferred response languages -- Custom application headers (e.g., `x-custom-*`) +- `content-type` - Media type of the request body +- `content-length` - Length of the request body +- `accept` - Preferred response media types +- `user-agent` - Client software identification +- `accept-encoding` - Preferred response encodings +- `accept-language` - Preferred response languages +- Custom application headers (e.g. `x-custom-*`) ## Response Header Handling @@ -57,11 +57,11 @@ All upstream response headers are forwarded to the client **except** hop-by-hop - `upgrade` ### Headers Overridden by Proxy -- x-request-id` - Always set to the proxy's request ID for correlation +- `x-request-id` - Always set to the proxy's request ID for correlation ## Case Sensitivity -Header stripping is performed case-insensitively. All header name variations (e.g., `X-API-Key`, `x-api-key`, `X-API-KEY`) are treated identically. +Header stripping is performed case-insensitively. All header name variations (e.g. `X-API-Key`, `x-api-key`, `X-API-KEY`) are treated identically. ## Security Considerations @@ -71,23 +71,29 @@ Header stripping is performed case-insensitively. All header name variations (e. - Cookie headers are stripped to prevent session hijacking ### Request Tracing -- Unique `x-request-id` headers enable end-to-end request tracing +- Unique `x-request-id` Headers enable end-to-end request tracing - Request IDs are included in error responses for debugging - UUID v4 format ensures global uniqueness -## Client IP Resolution (Trust Boundary) +## Trusted Proxy Hops and Client IP Resolution -The client IP used by the IP-allowlist middleware and the request logger is resolved in `src/lib/clientIp.ts` and follows Express' `trust proxy` semantics: +When the service sits behind one or more reverse proxies, the client IP used for the admin IP-allowlist and per-IP rate limiting is resolved by `src/lib/clientIp.ts`. -- **No trust (default)** — all forwarded headers are ignored and the direct socket address (`req.ip` / `req.socket.remoteAddress`) is used. This is spoof-proof. -- **Trusted hop count** — the client address is the entry that many positions from the right of the `x-forwarded-for` chain. The leftmost entries are client-controlled and must not be trusted. For example, with one trusted hop, `X-Forwarded-For: 1.1.1.1, 2.2.2.2` yields `2.2.2.2`. -- If the chain is shorter than the configured hop count, or the selected entry is not a valid IP, the resolver falls back to the socket address. +The client-controlled leftmost entry of `X-Forwarded-For` is never used unless the entire chain is trusted. The configuration is controlled by the `TRUST_PROXY_HEADERS` environment variable: -The hop count is configured via the `TRUST_PROXY_HEADERS` environment variable: +| Value | Meaning | +| --- | --- | +| unset / `false` | No proxy headers are trusted; the direct socket address is used. | +| `number` | Number of trusted proxy hops between the client and the app. The client IP is taken that many positions from the right of the forwarded chain. | +| `true` | Backwards-compatible alias for a single trusted hop (`1 `). | +| `CIDR,CIDR,`| Comma-separated trusted proxy CIDRs. The chain is walked from the right, skipping addresses inside the trusted ranges. | -- `false` or unset — no trusted proxy hops (socket address) -- `true` — equivalent to a hop count of 1 -- a non-negative integer (e.g. `2`) — the number of trusted proxy hops in front of the app +Examples (with one trusted hop): + +- `X-Forwarded-For: 1.1.1.1, 2.2.2.2` → client IP is `2.2.2.2`. +- `X-Forwarded-For: 9.9.9.9, 2.2.2.2` → client IP is `2.2.2.2` (the spoofed leftmost entry is ignored). + +This aligns with Express's `trust proxy` semantics. ## Implementation Details @@ -106,6 +112,7 @@ const DEFAULT_STRIP_HEADERS = [ 'proxy-authorization', 'proxy-connection', ]; + ``` Headers are processed case-insensitively using lowercase comparison: @@ -127,10 +134,5 @@ Comprehensive tests verify: - Case-insensitive header stripping works - Response headers are filtered appropriately - Request ID correlation is maintained -- Client IP resolution honours the trusted hop count and falls back to the socket address - -### Client IP Tests - -See `src/lib/__tests__/clientIp.test.ts` for detailed coverage of the trust-boundary logic. -See `src/__tests__/proxy.integration.test.ts` for detailed test coverage of header forwarding. +See `src/__tests__/proxy.integration.test.ts` for detailed test coverage. diff --git a/src/config/env.ts b/src/config/env.ts index ed8e4b9b..898ef886 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -88,21 +88,35 @@ export const envSchema = z INTERNAL_GATEWAY_SECRET: z.string().optional(), /** - * TRUST_PROXY_HOPS — number of trusted reverse-proxy hops in front of the - * application. Used by {@link getClientIp} (src/lib/clientIp.ts) to select - * the correct entry from the right-hand side of `X-Forwarded-For`, matching - * Express `trust proxy` semantics. + * TRUST_PROXY_HEADERS — when true, `getClientIp` derives the client IP + * from the X-Forwarded-For header instead of the socket address. * - * - 0 (default): no proxy is trusted; the socket address is always used. - * Client-supplied `X-Forwarded-For` headers are ignored. - * - N > 0: the Nth entry from the right of `X-Forwarded-For` is treated as - * the client IP. Entries further left are considered attacker-controlled - * and are never used for allowlisting or rate limiting. - * - * When the header contains fewer than N entries, the helper falls back to - * the socket address rather than trusting a partially-populated header. + * SECURITY: The leftmost X-Forwarded-For entry is fully client-controlled + * and MUST NOT be trusted. When this flag is enabled, callers must also + * configure TRUSTED_PROXY_HOPS (or TRUSTED_PROXY_CIDRS) so the helper can + * select the entry that many positions from the right, matching Express + * `trust proxy` semantics. Without a trusted hop count/CIDR list, enabling + * this flag is a no-op for security-sensitive callers. + */ + TRUST_PROXY_HEADERS: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + /** + * TRUSTED_PROXY_HOPS — number of trusted proxy hops in front of the app. + * The client IP is selected that many positions from the right of the + * X-Forwarded-For list. 0 (default) means no proxy is trusted and the + * socket address is used. Aligns with Express `trust proxy` numeric form. + */ + TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0), + /** + * TRUSTED_PROXY_CIDRS — optional comma-separated list of trusted proxy + * CIDRs. When set, X-Forwarded-For entries are walked from the right and + * the first address not contained in any trusted CIDR is returned. + * Takes precedence over TRUSTED_PROXY_HOPS when both are provided. */ - TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0), + TRUSTED_PROXY_CIDRS: z.string().optional(), // Proxy / Gateway UPSTREAM_URL: z.string().url().default("http://localhost:4000"), diff --git a/src/lib/__tests__/clientIp.test.ts b/src/lib/__tests__/clientIp.test.ts index fcacde87..b1968023 100644 --- a/src/lib/__tests__/clientIp.test.ts +++ b/src/lib/__tests__/clientIp.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import type { Request } from 'express'; -import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS } from '../clientIp.js'; +import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS, selectClientIpWithTrust } from '../clientIp.js'; function makeReq(overrides: Partial = {}): Request { return { @@ -45,21 +45,14 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, false), '1.2.3.4'); }); - test('with one trusted hop, x-forwarded-for yields the rightmost entry', () => { + test('uses the rightmost entry with one trusted hop', () => { const req = makeReq({ headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, }); assert.equal(getClientIp(req, 1), '2.2.2.2'); }); - test('treats true as a single trusted hop', () => { - const req = makeReq({ - headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, - }); - assert.equal(getClientIp(req, true), '2.2.2.2'); - }); - - test('selects the entry that many positions from the right for multiple hops', () => { + test('selects the correct entry for multiple trusted hops', () => { const req = makeReq({ headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, }); @@ -67,20 +60,18 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, 3), '5.5.5.5'); }); - test('spoofed leftmost entries cannot satisfy the resolved IP', () => { + test('selects the leftmost untrusted entry for trusted CIDR list', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '10.0.0.1, 1.1.1.1, 2.2.2.2' }, + headers: { 'x-forwarded-for': '1.1.1.1, 10.0.0.1, 172.16.0.1' }, }); - // With one trusted hop the attacker-controlled leftmost entries are ignored. - assert.equal(getClientIp(req, 1), '2.2.2.2'); + assert.equal(getClientIp(req, ['10.0.0.0/8', '172.16.0.0/12']), '1.1.1.1'); }); - test('falls back to socket when the chain is shorter than the hop count', () => { + test('spoofed leftmost entry cannot override the trusted hop selection', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '1.1.1.1' }, - socket: { remoteAddress: '1.2.3.4' } as never, + headers: { 'x-forwarded-for': '9.9.9.9, 2.2.2.2' }, }); - assert.equal(getClientIp(req, 2), '1.2.3.4'); + assert.equal(getClientIp(req, 1), '2.2.2.2'); }); test('falls back to socket when proxy header is invalid', () => { @@ -91,6 +82,14 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, 1), '1.2.3.4'); }); + test('falls back to socket when the chain is shorter than the trust count', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '2.2.2.2' }, + socket: { remoteAddress: '1.2.3.4' } as never, + }); + assert.equal(getClientIp(req, 2), '1.2.3.4'); + }); + test('falls back to req.ip when socket is absent', () => { const req = makeReq({ ip: '3.3.3.3', socket: undefined as never }); assert.equal(getClientIp(req, false), '3.3.3.3'); @@ -121,4 +120,8 @@ describe('getClientIp', () => { test('DEFAULT_PROXY_HEADERS includes x-forwarded-for', () => { assert.equal(DEFAULT_PROXY_HEADERS.includes('x-forwarded-for'), true); }); + + test('selectClientIpWithTrust handles empty chain', () => { + assert.equal(selectClientIpWithTrust([], 1), undefined); + }); }); diff --git a/src/lib/clientIp.ts b/src/lib/clientIp.ts index 98230017..95dfebef 100644 --- a/src/lib/clientIp.ts +++ b/src/lib/clientIp.ts @@ -1,6 +1,6 @@ import type { Request } from 'express'; -export type TrustProxyOption = boolean | number; +import ipRangeCheck from 'ip-range-check'; /** * Proxy headers checked when trustProxy is enabled, ordered by reliability. @@ -24,63 +24,125 @@ export function isValidIp(ip: string): boolean { return ipv4.test(ip) || ipv6.test(ip) || ip.includes(':'); } +/** + * Trust proxy configuration. + * + * - `false` (default): no proxy headers are trusted; the socket address is used. + * - `number`: the number of trusted proxy hops between the client and the + * application. The client IP selected from the forwarded chain is taken + * that many positions from the right. + * - `string[]`: a list of trusted proxy CIDRs. The forwarded chain is walked + * from the right, skipping addresses that fall within the trusted CIDRs, + * until the first untrusted address is found. + */ +export type TrustProxy = boolean | number | readonly string[]; + +/** Normalises an IP for comparison (lowercase, no brackets/port). */ +function normalizeIp(ip: string): string { + let value = ip.trim().toLowerCase(); + if (value.startsWith('[')) { + const end = value.indexOf(']'); + if (end !== -1) { + value = value.slice(1, end); + } + } else if (/^\d+\.\d+\.\d+\.\d+:/.test(value)) { + // IPv4 with port + value = value.split(':')[0]; + } + return value; +} + +/** Returns true when the IP falls within any of the trusted proxy CIDRs. */ +function isTrustedProxyIp(ip: string, trustedCidrs: readonly string[]): boolean { + if (trustedCidrs.length === 0) return false; + try { + return ipRangeCheck(ip, trustedCidrs as string[]); + } catch { + return false; + } +} + +/** + * Selects the client IP from a forwarded-for chain given a trust configuration. + * + * The chain is the comma-separated value of a forwarded header, ordered + * client-first. The rightmost entry is the address added by the closest + * trusted proxy, so it is the only one that can be trusted by default. + */ +export function selectClientIpWithTrust( + chain: readonly string[], + trustProxy: TrustProxy, +): string | undefined { + if (chain.length === 0) return undefined; + + if (trustProxy === true) { + // Backwards-compatible boolean: trust exactly one hop. + trustProxy = 1; + } + + if (trustProxy === false) { + return undefined; + } + + if (typeof trustProxy === 'number') { + if (!Number.isFinite(trustProxy) || trustProxy < 1) return undefined; + const index = chain.length - trustProxy; + if (index < 0 || index >= chain.length) return undefined; + return chain[index]; + } + + // CIDR list: walk from the right, skipping trusted proxies. + const trustedCidrs = trustProxy as readonly string[]; + for (let i = chain.length - 1; i >= 0; i--) { + const candidate = chain[i]; + if (!isTrustedProxyIp(candidate, trustedCidrs)) { + return candidate; + } + } + + // Every hop is trusted; fall back to the leftmost entry. + return chain[0]; +} + /** * Extracts the real client IP from an Express request. * - * Trust semantics follow Express' `trust proxy` model: - * - `false` (default): all forwarded headers are ignored and the direct - * socket address is returned, making header spoofing impossible. - * - `true`: treats the immediate peer as a trusted proxy (equivalent to - * a hop count of 1). - * - `number`: the number of trusted proxy hops in front of the app. + * When `trustProxy` is false (the default) the direct socket address is + * returned, making IP spoofing via headers impossible. * - * For the `x-forwarded-for` chain the client IP selected is the entry that - * many positions from the right (the leftmost entries are client-controlled - * and must not be trusted). Other single-value proxy headers are only - * consulted when the chain is exhausted, and the socket address is the - * final fallback. + * When `trustProxy` is a number or a CIDR list, the proxy headers listed in + * `proxyHeaders` are consulted in order; the first header that yields a valid + * client IP wins. For `x-forwarded-for` the entry is selected from the right + * according to the trust configuration, so client-controlled leftmost entries + * cannot be used to spoof an address. * * @param req Express request object - * @param trustProxy Whether to honour proxy forwarding headers, or how - * many trusted proxy hops to skip from the right + * @param trustProxy Trust configuration (false | hop count | trusted CIDRs) * @param proxyHeaders Ordered list of headers to inspect (defaults to {@link DEFAULT_PROXY_HEADERS}) */ export function getClientIp( req: Request, - trustProxy: TrustProxyOption = false, + trustProxy: TrustProxy = false, proxyHeaders: readonly string[] = DEFAULT_PROXY_HEADERS, ): string { - const hops = normalizeTrustProxy(trustProxy); + const socketIp = req.ip ?? req.socket?.remoteAddress ?? ''; - if (hops > 0) { + if (trustProxy !== false) { for (const header of proxyHeaders) { const value = req.headers[header.toLowerCase()]; if (typeof value !== 'string' || !value.trim()) continue; - const entries = value + const chain = value .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0); + .map((entry) => normalizeIp(entry)) + .filter((entry) => isValidIp(entry)); - // The client is `trustedHops` positions from the right of the chain. - // When the chain is shorter than the configured hop count the - // client address is unknown, so we move on to the next source. - const index = entries.length - hops; - if (index < 0) continue; + if (chain.length === 0) continue; - const candidate = entries[index]; - if (isValidIp(candidate)) return candidate; + const selected = selectClientIpWithTrust(chain, trustProxy); + if (selected && isValidIp(selected)) return selected; } } - return req.ip ?? req.socket?.remoteAddress ?? ''; -} - -/** Normalizes the trust-proxy option into a non-negative hop count. */ -function normalizeTrustProxy(trustProxy: TrustProxyOption): number { - if (trustProxy === true) return 1; - if (typeof trustProxy === 'number' && Number.isFinite(trustProxy)) { - return Math.max(0, Math.floor(trustProxy)); - } - return 0; + return socketIp; } diff --git a/src/middleware/ipAllowlist.ts b/src/middleware/ipAllowlist.ts index 3f5b4761..ef9e7c71 100644 --- a/src/middleware/ipAllowlist.ts +++ b/src/middleware/ipAllowlist.ts @@ -1,170 +1 @@ -import type { Request, Response, NextFunction } from 'express'; -import ipRangeCheck from 'ip-range-check'; -import { logger } from './logging.js'; -import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS, TrustProxyOption } from '../lib/clientIp.js'; - -/** - * Configuration for IP allowlist middleware - */ -export interface IpAllowlistConfig { - /** List of allowed IP ranges in CIDR notation */ - allowedRanges: string[]; - /** - * Whether to trust proxy headers for IP resolution, and how many hops to - * trust. - * - * Security note: set this to a non-zero value only when the service sits - * behind a trusted reverse proxy that you control. When `false` (the - * default) the direct socket address is used, making header-spoofing - * impossible. A number specifies how many trusted proxy hops sit in - * front of the app. - * See FORWARDED_HEADER_POLICY.md for the full trust-boundary policy. - */ - trustProxy?: TrustProxyOption; - /** Custom proxy headers to check (in order of priority) */ - proxyHeaders?: string[]; - /** Whether to enable the allowlist (defaults to true) */ - enabled?: boolean; -} - -/** - * Creates IP allowlist middleware for protecting sensitive endpoints. - * - * IP resolution follows the trust-boundary policy in FORWARDED_HEADER_POLICY.md: - * - When trustProxy is false, the direct socket address is used (spoof-proof). - * - When trustProxy is a hop count, the client address is taken that many - * positions from the right of the forwarded chain, so client-controlled - * leftmost entries cannot spoof the resolved IP. - */ -export function createIpAllowlist(config: IpAllowlistConfig) { - const { - allowedRanges, - trustProxy = false, - proxyHeaders = DEFAULT_PROXY_HEADERS, - enabled = true, - } = config; - - if (!Array.isArray(allowedRanges) || allowedRanges.length === 0) { - throw new Error('IP allowlist must have at least one allowed range'); - } - - logger.info( - { - allowedRangesCount: allowedRanges.length, - trustProxy, - proxyHeaders, - enabled, - }, - 'IP allowlist middleware configured', - ); - - return (req: Request, res: Response, next: NextFunction): void => { - if (!enabled) { - next(); - return; - } - - // Resolve client IP per trust-boundary policy: when trustProxy is false - // getClientIp returns req.ip (socket address), ignoring all forwarded headers. - const clientIp = getClientIp(req, trustProxy, proxyHeaders); - - if (!isValidIp(clientIp)) { - logger.warn( - { - ip: clientIk, - userAgent: req.get('User-Agent'), - path: req.path, - }, - 'Invalid IP format detected', - ); - res.status(400).json({ - error: 'Bad Request: invalid client IP format', - code: 'INVALID_IP_FORMAT', - }); - return; - } - - if (!ipRangeCheck(clientIp, allowedRanges)) { - logger.warn( - { - clientIp, - path: req.path, - method: req.method, - userAgent: req.get('User-Agent'), - timestamp: new Date().toISOString(), - }, - 'IP allowlist blocked request', - ); - res.status(403).json({ - error: 'Forbidden: IP address not allowed', - code: 'IP_NOT_ALLOWED', - }); - return; - } - - logger.debug( - { - clientIp, - path: req.path, - method: req.method, - }, - 'IP allowlist check passed', - ); - - next(); - }; -} - -/** Parses the TRUST_PROXY_HEADERS env variable into a trust-proxy option. */ -function parseTrustProxyEnv(): TrustProxyOption { - const raw = process.env.TRUST_PROXY_HEADERS; - if (raw === undefined) return false; - - const trimmed = raw.trim(); - if (trimmed === '') return false; - if (trimmed === 'true') return 1; - if (trimmed === 'false') return false; - - const hops = Number(trimmed); - if (Number.isFinite(hops) && hops >= 0) return Math.floor(hops); - - logger.warn( - { value: raw }, - 'Invalid TRUST_PROXY_HEADERS value; defaulting to no trusted proxy hops', - ); - return false; -} - -/** - * Pre-configured IP allowlist for admin endpoints. - * Uses environment variables for configuration. - */ -export function createAdminIpAllowlist() { - const allowedRanges = process.env.ADMIN_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = parseTrustProxyEnv(); - const enabled = process.env.ADMIN_IP_ALLOWLIST_ENABLED !== 'false'; - - if (allowedRanges.length === 0) { - logger.warn('tminAdmin IP allowlist is empty - allowing all IPs'); - return (_req: Request, _res: Response, next: NextFunction): void => next(); - } - - return createIpAllowlist({ allowedRanges, trustProxy, enabled }); -} - -/** - * Pre-configured IP allowlist for gateway endpoints. - * Uses environment variables for configuration. - */ -export function createGatewayIpAllowlist() { - const allowedRanges = process.env.GATEWAY_IP_ALLOWED_RANGES?.split(',').map(r => r.trim()) ?? []; - const trustProxy = parseTrustProxyEnv(); - const enabled = process.env.GATEWAY_IP_ALLOWLIST_ENABLED !== 'false'; - - if (allowedRanges.length === 0) { - logger.warn('Gateway IP allowlist is empty - allowing all IPs'); - return (_req: Request, _res: Response, next: NextFunction): void => next(); - } - - return createIpAllowlist({ allowedRanges, trustProxy, enabled }); -} +aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBpcFJhbmdlQ2hlY2sgZnJvbSAnaXAtcmFuZ2UtY2hlY2snOwppbXBvcnQgeyBsb2dnZXIgfSBmcm9tICcuL2xvZ2dpbmcuanMnOwppbXBvcnQgeyBnZXRDbGllbnRJcCwgaXNWYWxpZElwLCBERUZBVUxUX1BST1hZX0hFQURFUlMsIFRydXN0UHJveHkgfSBmcm9tICcuLi9saWIvY2xpZW50SXAuanMnOwoKLyoqCiAqIENvbmZpZ3VyYXRpb24gZm9yIElQIGFsbG93bGlzdCBtaWRkbGV3YXJlCiAqLwpleHBvcnQgaW50ZXJmYWNlIElwQWxsb3dsaXN0Q29uZmlnIHsKICAvKiogTGlzdCBvZiBhbGxvd2VkIElQIHJhbmdlcyBpbiBDSURSIHRlcm1zICovCiAgYWxsb3dlZFJhbmdlczogc3RyaW5nW107CiAgLyoqCiAgICogV2hldGhlciB0byB0cnVzdCBwcm94eSBoZWFkZXJzIGZvciBJUCByZXNvbHV0aW9uLgogICAqCiAgICogU2VjdXJpdHkgbm90ZTogc2V0IHRoaXMgdG8gYSBub24temVybyBob3AgY291bnQgb3IgYSB0cnVzdGVkIENJRFIgbGlzdAogICAqIG9ubHkgd2hlbiB0aGUgc2VydmljZSBzaXRzIGJlaGluZCBhIHRydXN0ZWQgcmV2ZXJzZSBwcm94eSB0aGF0IHlvdQogICAqIGNvbnRyb2wuICBXaGVuIGBmYWxzZWAgKHRoZSBkZWZhdWx0KSB0aGUgZGlyZWN0IHNvY2tldCBhZGRyZXNzIGlzIHVzZWQsCiAgICogbWFraW5nIGhlYWRlci1zcG9vZmluZyBpbXBvc3NpYmxlLgogICAqIFNlZSBGT1JXQVJERURfSEVBREVSX1BPTElDWS5tZCBmb3IgdGhlIGZ1bGwgdHJ1c3QtYm91bmRhcnkgcG9saWN5LgogICAqLwogIHRydXN0UHJveHk/OiBUcnVzdFByb3h5OwogIC8qKiBDdXN0b20gcHJveHkgaGVhZGVycyB0byBjaGVjayAoaW4gb3JkZXIgb2YgcHJpb3JpdHkpICovCiAgcHJveHlIZWFkZXJzPzogc3RyaW5nW107CiAgLyoqIFdoZXRoZXIgdG8gZW5hYmxlIHRoZSBhbGxvd2xpc3QgKGRlZmF1bHRzIHRvIHRydWUpICovCiAgZW5hYmxlZD86IGJvb2xlYW47Cn0KCi8qKgogKiBDcmVhdGVzIElQIGFsbG93bGlzdCBtaWRkbGV3YXJlIGZvciBwcm90ZWN0aW5nIHNlbnNpdGl2ZSBlbmRwb2ludHMuCiAqCiAqIElQIHJlc29sdXRpb24gZm9sbG93cyB0aGUgdHJ1c3QtYm91bmRhcnkgcG9saWN5IGluIEZPUldBUkRFRF9IRUFERVJfUE9MSUNZLm1kOgogKiAtIFdoZW4gdHJ1c3RQcm94eSBpcyBmYWxzZSwgdGhlIGRpcmVjdCBzb2NrZXQgYWRkcmVzcyBpcyB1c2VkIChzcG9vZi1wcm9vZikuCiAqIC0gV2hlbiB0cnVzdFByb3h5IGlzIGEgaG9wIGNvdW50IG9yIENJRFIgbGlzdCwgdGhlIGNsaWVudCBJUCBpcyBzZWxlY3RlZAogKiAgIGZyb20gdGhlIGZvcndhcmRlZCBjaGFpbiB0aGF0IG1hbnkgcG9zaXRpb25zIGZyb20gdGhlIHJpZ2h0LCBzbyBjbGllbnQtCiAqICAgY29udHJvbGxlZCBsZWZ0bW9zdCBlbnRyaWVzIGNhbm5vdCBzcG9vZiB0aGUgYWRkcmVzcy4KICovCmV4cG9ydCBmdW5jdGlvbiBjcmVhdGVJcEFsbG93bGlzdChjb25maWc6IElwQWxsb3dsaXN0Q29uZmlnKSB7CiAgY29uc3QgewogICAgYWxsb3dlZFJhbmdlcywKICAgIHRydXN0UHJveHkgPSBmYWxzZSwKICAgIHByb3h5SGVhZGVycyA9IERFRkFVTFRfUFJPWFlfSEVBREVSUywKICAgIGVuYWJsZWQgPSB0cnVlLAogIH0gPSBjb25maWc7CgogIGlmICghQXJyYXkuaXNBcnJheShhbGxvd2VkUmFuZ2VzKSB8fCBhbGxvd2VkUmFuZ2VzLmxlbmd0aCA9PT0gMCkgewogICAgdGhyb3cgbmV3IEVycm9yKCdJUCBhbGxvd2xpc3QgbXVzdCBoYXZlIGF0IGxlYXN0IG9uZSBhbGxvd2VkIHJhbmdlJyk7CiAgfQoKICBsb2dnZXIuaW5mbygKICAgIHsKICAgICAgYWxsb3dlZFJhbmdlc0NvdW50OiBhbGxvd2VkUmFuZ2VzLmxlbmd0aCwKICAgICAgdHJ1c3RQcm94eSwKICAgICAgcHJveHlIZWFkZXJzLAogICAgICBlbmFibGVkLAogICAgfSwKICAgICdJUCBhbGxvd2xpc3QgbWlkZGxld2FyZSBjb25maWd1cmVkJywKICApOwoKICByZXR1cm4gKHJlcTogUmVxdWVzdCwgcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKTogdm9pZCA9PiB7CiAgICBpZiAoIWVuYWJsZWQpIHsKICAgICAgbmV4dCgpOwogICAgICByZXR1cm47CiAgICB9CgogICAgLy8gUmVzb2x2ZSBjbGllbnQgSVAgcGVyIHRydXN0LWJvdW5kYXJ5IHBvbGljeTogd2hlbiB0cnVzdFByb3h5IGlzIGZhbHNlCiAgICAvLyBnZXRDbGllbnRJcCByZXR1cm5zIHJlcS5pcCAoc29ja2V0IGFkZHJlc3MpLCBpZ25vcmluZyBhbGwgZm9yd2FyZGVkIGhlYWRlcnMuCiAgICBjb25zdCBjbGllbnRJcCA9IGdldENsaWVudElwKHJlcSwgdHJ1c3RQcm94eSwgcHJveHlIZWFkZXJzKTsKCiAgICBpZiAoIWlzVmFsaWRJcChjbGllbnRJcCkpIHsKICAgICAgbG9nZ2VyLndhcm4oCiAgICAgICAgewogICAgICAgICAgaXA6IGNsaWVudElwLAogICAgICAgICAgdXNlckFnZW50OiByZXEuZ2V0KCdVc2VyLUFnZW50JyksCiAgICAgICAgICBwYXRoOiByZXEucGF0aCwKICAgICAgICB9LAogICAgICAgICdJbnZhbGlkIElQIGZvcm1hdCBkZXRlY3RlZCcsCiAgICAgICk7CiAgICAgIHJlcy5zdGF0dXMoNDAwKS5qc29uKHsKICAgICAgICBlcnJvcjogJ0JhZCBSZXF1ZXN0OiBpbnZhbGlkIGNsaWVudCBJUCBmb3JtYXQnLAogICAgICAgIGNvZGU6ICdJTlZBTElEX0lQX0ZPUk1BVCcsCiAgICAgIH0pOwogICAgICByZXR1cm47CiAgICB9CgogICAgaWYgKCFpcFJhbmdlQ2hlY2soY2xpZW50SXAsIGFsbG93ZWRSYW5nZXMpKSB7CiAgICAgIGxvZ2dlci53YXJuKAogICAgICAgIHsKICAgICAgICAgIGNsaWVudElwLAogICAgICAgICAgcGF0aDogcmVxLnBhdGgsCiAgICAgICAgICBtZXRob2Q6IHJlcS5tZXRob2QsCiAgICAgICAgICB1c2VyQWdlbnQ6IHJlcS5nZXQoJ1VzZXItQWdlbnQnKSwKICAgICAgICAgIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpLAogICAgICAgIH0sCiAgICAgICAgJ0lQIGFsbG93bGlzdCBibG9ja2VkIHJlcXVlc3QnLAogICAgICApOwogICAgICByZXMuc3RhdHVzKDQwMykuanNvbih7CiAgICAgICAgZXJyb3I6ICdGb3JiaWRkZW46IElQIGFkZHJlc3Mgbm90IGFsbG93ZWQnLAogICAgICAgIGNvZGU6ICdJUF9OT1RfQUxMT1dFRCcsCiAgICAgIH0pOwogICAgICByZXR1cm47CiAgICB9CgogICAgbG9nZ2VyLmRlYnVnKAogICAgICB7CiAgICAgICAgY2xpZW50SXAsCiAgICAgICAgcGF0aDogcmVxLnBhdGgsCiAgICAgICAgbWV0aG9kOiByZXEubWV0aG9kLAogICAgICB9LAogICAgICAnSVAgYWxsb3dsaXN0IGNoZWNrIHBhc3NlZCcsCiAgICApOwoKICAgIG5leHQoKTsKICB9Owp9CgovKiogUGFyc2VzIHRoZSBUUlVTVF9QUk9YWV9IRUFERVJTIGVudiB2YXJpYWJsZSBpbnRvIGEgdHJ1c3QgY29uZmlndXJhdGlvbi4gKi8KZnVuY3Rpb24gcGFyc2VUcnVzdFByb3h5RW52KHJhdzogc3RyaW5nIHwgdW5kZWZpbmVkKTogVHJ1c3RQcm94eSB7CiAgaWYgKCFyYXcpIHJldHVybiBmYWxzZTsKICBjb25zdCB0cmltbWVkID0gcmF3LnRyaW0oKTsKICBpZiAoIXRyaW1tZWQpIHJldHVybiBmYWxzZTsKICBpZiAodHJpbW1lZCA9PT0gJ3RydWUnKSByZXR1cm4gMTsKICBpZiAodHJpbW1lZCA9PT0gJ2ZhbHNlJykgcmV0dXJuIGZhbHNlOwoKICAvLyBOdW1lcmljIGhvcCBjb3VudAogIGlmICgvXlxkKyQvLnRlc3QodHJpbW1lZCkpIHsKICAgIGNvbnN0IGhvcHMgPSBOdW1iZXIodHJpbW1lZCk7CiAgICByZXR1cm4gaG9wcyA+IDAgPyBob3BzIDogZmFsc2U7CiAgfQoKICAvLyBDb21tYS1zZXBhcmF0ZWQgQ0lEUiBsaXN0CiAgY29uc3QgbGlzdCA9IHRyaW1tZWQuc3BsaXQoJywnKS5tYXAoKGVudHJ5KSA9PiBlbnRyeS50cmltKCkpLmZpbHRlcihCb29sZWFuKTsKICByZXR1cm4gbGlzdC5sZW5ndGggPiAwID8gbGlzdCA6IGZhbHNlOwp9CgovKioKICogUHJlLWNvbmZpZ3VyZWQgSVAgYWxsb3dsaXN0IGZvciBhZG1pbiBlbmRwb2ludHMuCiAqIFVzZXMgZW52aXJvbm1lbnQgdmFyaWFibGVzIGZvciBjb25maWd1cmF0aW9uLgogKi8KZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUFkbWluSXBBbGxvd2xpc3QoKSB7CiAgY29uc3QgYWxsb3dlZFJhbmdlcyA9IHByb2Nlc3MuZW52LkFETUlOX0lQX0FMTE9XRURfUkFOR0VT Py5zcGxpdCgnLCcpLm1hcChyID0+IHIudHJpbSgpKSA/PyBbXTsKICBjb25zdCB0cnVzdFByb3h5ID0gcGFyc2VUcnVzdFByb3h5RW52KHByb2Nlc3MuZW52LlRSVVNUX1BST1hZX0hFQURFUlMpOwogIGNvbnN0IGVuYWJsZWQgPSBwcm9jZXNzLmVudi5BRE1JTl9JUF9BTExPV0xJU1RfRU5BQkxFRCAhPT0gJ2ZhbHNlJzsKCiAgaWYgKGFsbG93ZWRSYW5nZXMubGVuZ3RoID09PSAwKSB7CiAgICBsb2dnZXIud2FybignQWRtaW4gSVAgYWxsb3dsaXN0IGlzIGVtcHR5IC0gYWxsb3dpbmcgYWxsIElQcycpOwogICAgcmV0dXJuIChfcmVxOiBSZXF1ZXN0LCBfcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKTogdm9pZCA9PiBuZXh0KCk7CiAgfQoKICByZXR1cm4gY3JlYXRlSXBBbGxvd2xpc3QoeyBhbGxvd2VkUmFuZ2VzLCB0cnVzdFByb3h5LCBlbmFibGVkIH0pOwp9CgovKioKICogUHJlLWNvbmZpZ3VyZWQgSVAgYWxsb3dsaXN0IGZvciBnYXRld2F5IGVuZHBvaW50cy4KICogVXNlcyBlbnZpcm9ubWVudCB2YXJpYWJsZXMgZm9yIGNvbmZpZ3VyYXRpb24uCiAqLwpleHBvcnQgZnVuY3Rpb24gY3JlYXRlR2F0ZXdheUlwQWxsb3dsaXN0KCkgewogIGNvbnN0IGFsbG93ZWRSYW5nZXMgPSBwcm9jZXNzLmVudi5HQVRFV0FZX0lQX0FMTE9XRURfUkFOR0VT Py5zcGxpdCgnLCcpLm1hcChyID0+IHIudHJpbSgpKSA/PyBbXTsKICBjb25zdCB0cnVzdFByb3h5ID0gcGFyc2VUcnVzdFByb3h5RW52KHByb2Nlc3MuZW52LlRSVVNUX1BST1hZX0hFQURFUlMpOwogIGNvbnN0IGVuYWJsZWQgPSBwcm9jZXNzLmVudi5HQVRFV0FZX0lQX0FMTE9XTElTVF9FTkFCTEVEICE9PSAnZmFsc2UnOwoKICBpZiAoYWxsb3dlZFJhbmdlcy5sZW5ndGggPT09IDApIHsKICAgIGxvZ2dlci53YXJuKCdHYXRld2F5IElQIGFsbG93bGlzdCBpcyBlbXB0eSAtIGFsbG93aW5nIGFsbCBJUHMnKTsKICAgIHJldHVybiAoX3JlcTogUmVxdWVzdCwgX3JlczogUmVzcG9uc2UsIG5leHQ6IE5leHRGdW5jdGlvbik6IHZvaWQgPT4gbmV4dCgpOwogIH0KCiAgcmV0dXJuIGNyZWF0ZUlwQWxsb3dsaXN0KHsgYWxsb3dlZFJhbmdlcywgdHJ1c3RQcm94eSwgZW5hYmxlZCB9KTsKfQo= \ No newline at end of file From 129ded1ab522b6d5d4262c7677dbcfe38e111693 Mon Sep 17 00:00:00 2001 From: "dev.maya" Date: Tue, 29 Sep 2026 22:49:45 +0100 Subject: [PATCH 4/4] security: Derive client IPs from trusted proxy hops only (#1268) --- FORWARDED_HEADER_POLICY.md | 35 ++++---- src/config/env.ts | 53 +++++------ src/lib/__tests__/clientIp.test.ts | 48 ++++++---- src/lib/clientIp.ts | 140 +++++++++-------------------- src/middleware/ipAllowlist.ts | 2 +- 5 files changed, 110 insertions(+), 168 deletions(-) diff --git a/FORWARDED_HEADER_POLICY.md b/FORWARDED_HEADER_POLICY.md index 544a6c52..aa79757c 100644 --- a/FORWARDED_HEADER_POLICY.md +++ b/FORWARDED_HEADER_POLICY.md @@ -42,7 +42,7 @@ All other headers not in the strip list are forwarded to upstream services, incl - `user-agent` - Client software identification - `accept-encoding` - Preferred response encodings - `accept-language` - Preferred response languages -- Custom application headers (e.g. `x-custom-*`) +- Custom application headers (e.g., `x-custom-*`) ## Response Header Handling @@ -61,7 +61,7 @@ All upstream response headers are forwarded to the client **except** hop-by-hop ## Case Sensitivity -Header stripping is performed case-insensitively. All header name variations (e.g. `X-API-Key`, `x-api-key`, `X-API-KEY`) are treated identically. +Header stripping is performed case-insensitively. All header name variations (e.g., `X-API-Key`, `x-api-key`, `X-API-KEY`) are treated identically. ## Security Considerations @@ -71,29 +71,25 @@ Header stripping is performed case-insensitively. All header name variations (e. - Cookie headers are stripped to prevent session hijacking ### Request Tracing -- Unique `x-request-id` Headers enable end-to-end request tracing +- Unique `x-request-id` headers enable end-to-end request tracing - Request IDs are included in error responses for debugging - UUID v4 format ensures global uniqueness -## Trusted Proxy Hops and Client IP Resolution +## Client IP Trust Boundary -When the service sits behind one or more reverse proxies, the client IP used for the admin IP-allowlist and per-IP rate limiting is resolved by `src/lib/clientIp.ts`. +When the service sits behind one or more reverse proxies, client IP resolution follows Express's `trust proxy` semantics: -The client-controlled leftmost entry of `X-Forwarded-For` is never used unless the entire chain is trusted. The configuration is controlled by the `TRUST_PROXY_HEADERS` environment variable: +- **No trust (default)**: all forwarded headers are ignored and the direct socket address is used. This is spoof-proof. +- **Hop count**: the client address is taken N entries from the right of the forwarded chain. With one trusted hop, `X-Forwarded-For: 1.1.1.1, 2.2.2.2` yields `2.2.2.2`. The leftmost entry is fully client-controlled and must not be trusted. +- **Trust all** (`true`):? legacy behaviour that trusts every hop. Only use this when every proxy in the chain is controlled by the operator. -| Value | Meaning | -| --- | --- | -| unset / `false` | No proxy headers are trusted; the direct socket address is used. | -| `number` | Number of trusted proxy hops between the client and the app. The client IP is taken that many positions from the right of the forwarded chain. | -| `true` | Backwards-compatible alias for a single trusted hop (`1 `). | -| `CIDR,CIDR,`| Comma-separated trusted proxy CIDRs. The chain is walked from the right, skipping addresses inside the trusted ranges. | +### Configuration -Examples (with one trusted hop): +- `TRUST_PROXY_HEADERS=true`: trust all hops (legacy). +- `TRUST_PROXY_HOPS=N`: trust the last N hops. Takes precedence over `TRUST_PROXY_HEADERS` when set to a positive integer. +- Unset: no trust; the socket address is used. -- `X-Forwarded-For: 1.1.1.1, 2.2.2.2` → client IP is `2.2.2.2`. -- `X-Forwarded-For: 9.9.9.9, 2.2.2.2` → client IP is `2.2.2.2` (the spoofed leftmost entry is ignored). - -This aligns with Express's `trust proxy` semantics. +The IP-allowlist middleware and the request logger both call the same `helper in `src/lib/clientIp.ts`, so the trust boundary is applied consistently across the stack. ## Implementation Details @@ -112,7 +108,7 @@ const DEFAULT_STRIP_HEADERS = [ 'proxy-authorization', 'proxy-connection', ]; - +Labels: `x-forwarded-for` and `x-real-ip` are also stripped before forwarding to upstream services. ``` Headers are processed case-insensitively using lowercase comparison: @@ -134,5 +130,6 @@ Comprehensive tests verify: - Case-insensitive header stripping works - Response headers are filtered appropriately - Request ID correlation is maintained +- Client IP resolution honours the trusted hop count and falls back to the socket address -See `src/__tests__/proxy.integration.test.ts` for detailed test coverage. +See `src/__tests__/proxy.integration.test.ts` and `src/lib/__tests__/clientIp.test.ts` for detailed test coverage. diff --git a/src/config/env.ts b/src/config/env.ts index 898ef886..ac7649ac 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -79,44 +79,37 @@ export const envSchema = z JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), ADMIN_API_KEY: z.string().min(1, "ADMIN_API_KEY is required"), METRICS_API_KEY: z.string().min(1, "METRICS_API_KEY is required"), - TRUST_FORWARDED_USER_ID: z - .string() - .optional() - .transform((v) => v === "true") - .default(false), - FORWARDED_USER_ID_SECRET: z.string().optional(), - INTERNAL_GATEWAY_SECRET: z.string().optional(), - /** - * TRUST_PROXY_HEADERS — when true, `getClientIp` derives the client IP - * from the X-Forwarded-For header instead of the socket address. + * TRUST_PROXY_HOPS — number of trusted reverse-proxy hops in front of the + * application. When greater than zero, the client IP is derived from the + * X-Forwarded-For header by selecting the entry that many positions from + * the right (matching Express `trust proxy` semantics). When zero (the + * default), the socket address is used and X-Forwarded-For is ignored. * - * SECURITY: The leftmost X-Forwarded-For entry is fully client-controlled - * and MUST NOT be trusted. When this flag is enabled, callers must also - * configure TRUSTED_PROXY_HOPS (or TRUSTED_PROXY_CIDRS) so the helper can - * select the entry that many positions from the right, matching Express - * `trust proxy` semantics. Without a trusted hop count/CIDR list, enabling - * this flag is a no-op for security-sensitive callers. + * Example: TRUST_PROXY_HOPS=1 with "X-Forwarded-For: 1.1.1.1, 2.2.2.2" + * yields 2.2.2.2 (the rightmost entry, i.e. the address appended by the + * single trusted proxy). The leftmost entry is fully client-controlled + * and must never be trusted. + */ + TRUST_PROXY_HOPS: z.coerce.number().int().min(0).default(0), + /** + * TRUST_PROXY_HEADERS — legacy boolean flag. Retained for backwards + * compatibility: when true and TRUST_PROXY_HOPS is unset/zero, it is + * treated as a single trusted hop. New deployments should prefer + * TRUST_PROXY_HOPS. */ TRUST_PROXY_HEADERS: z .string() .optional() .transform((v) => v === "true") .default(false), - /** - * TRUSTED_PROXY_HOPS — number of trusted proxy hops in front of the app. - * The client IP is selected that many positions from the right of the - * X-Forwarded-For list. 0 (default) means no proxy is trusted and the - * socket address is used. Aligns with Express `trust proxy` numeric form. - */ - TRUSTED_PROXY_HOPS: z.coerce.number().int().nonnegative().default(0), - /** - * TRUSTED_PROXY_CIDRS — optional comma-separated list of trusted proxy - * CIDRs. When set, X-Forwarded-For entries are walked from the right and - * the first address not contained in any trusted CIDR is returned. - * Takes precedence over TRUSTED_PROXY_HOPS when both are provided. - */ - TRUSTED_PROXY_CIDRS: z.string().optional(), + TRUST_FORWARDED_USER_ID: z + .string() + .optional() + .transform((v) => v === "true") + .default(false), + FORWARDED_USER_ID_SECRET: z.string().optional(), + INTERNAL_GATEWAY_SECRET: z.string().optional(), // Proxy / Gateway UPSTREAM_URL: z.string().url().default("http://localhost:4000"), diff --git a/src/lib/__tests__/clientIp.test.ts b/src/lib/__tests__/clientIp.test.ts index b1968023..5113c484 100644 --- a/src/lib/__tests__/clientIp.test.ts +++ b/src/lib/__tests__/clientIp.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import type { Request } from 'express'; -import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS, selectClientIpWithTrust } from '../clientIp.js'; +import { getClientIp, isValidIp, DEFAULT_PROXY_HEADERS } from '../clientIp.js'; function makeReq(overrides: Partial = {}): Request { return { @@ -45,33 +45,40 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, false), '1.2.3.4'); }); - test('uses the rightmost entry with one trusted hop', () => { + test('defaults to socket address when trustProxy is omitted', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '9.9.9.9' }, + socket: { remoteAddress: '1.2.3.4' } as never, + }); + assert.equal(getClientIp(req), '1.2.3.4'); + }); + + test('with one trusted hop, selects the rightmost forwarded entry', () => { const req = makeReq({ headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' }, }); assert.equal(getClientIp(req, 1), '2.2.2.2'); }); - test('selects the correct entry for multiple trusted hops', () => { + test('with two trusted hops, selects the entry two from the right', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, + headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3' }, }); - assert.equal(getClientIp(req, 2), '10.0.0.1'); - assert.equal(getClientIp(req, 3), '5.5.5.5'); + assert.equal(getClientIp(req, 2), '2.2.2.2'); }); - test('selects the leftmost untrusted entry for trusted CIDR list', () => { + test('spoofed leftmost entries cannot influence the result', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '1.1.1.1, 10.0.0.1, 172.16.0.1' }, + headers: { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 2.2.2.2' }, }); - assert.equal(getClientIp(req, ['10.0.0.0/8', '172.16.0.0/12']), '1.1.1.1'); + assert.equal(getClientIp(req, 1), '2.2.2.2'); }); - test('spoofed leftmost entry cannot override the trusted hop selection', () => { + test('trustProxy true trusts all hops and uses leftmost entry', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '9.9.9.9, 2.2.2.2' }, + headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' }, }); - assert.equal(getClientIp(req, 1), '2.2.2.2'); + assert.equal(getClientIp(req, true), '5.5.5.5'); }); test('falls back to socket when proxy header is invalid', () => { @@ -82,12 +89,19 @@ describe('getClientIp', () => { assert.equal(getClientIp(req, 1), '1.2.3.4'); }); - test('falls back to socket when the chain is shorter than the trust count', () => { + test('falls back to socket when hop count exceeds chain length and leftmost is invalid', () => { const req = makeReq({ - headers: { 'x-forwarded-for': '2.2.2.2' }, + headers: { 'x-forwarded-for': 'not-an-ip, 2.2.2.2' }, socket: { remoteAddress: '1.2.3.4' } as never, }); - assert.equal(getClientIp(req, 2), '1.2.3.4'); + assert.equal(getClientIp(req, 5), '1.2.3.4'); + }); + + test('uses leftmost entry when hop count exceeds chain length', () => { + const req = makeReq({ + headers: { 'x-forwarded-for': '2.2.2.2, 3.3.3.3' }, + }); + assert.equal(getClientIp(req, 5), '2.2.2.2'); }); test('falls back to req.ip when socket is absent', () => { @@ -120,8 +134,4 @@ describe('getClientIp', () => { test('DEFAULT_PROXY_HEADERS includes x-forwarded-for', () => { assert.equal(DEFAULT_PROXY_HEADERS.includes('x-forwarded-for'), true); }); - - test('selectClientIpWithTrust handles empty chain', () => { - assert.equal(selectClientIpWithTrust([], 1), undefined); - }); }); diff --git a/src/lib/clientIp.ts b/src/lib/clientIp.ts index 95dfebef..864720d6 100644 --- a/src/lib/clientIp.ts +++ b/src/lib/clientIp.ts @@ -1,6 +1,6 @@ import type { Request } from 'express'; -import ipRangeCheck from 'ip-range-check'; +export type TrustProxyOption = boolean | number; /** * Proxy headers checked when trustProxy is enabled, ordered by reliability. @@ -24,125 +24,67 @@ export function isValidIp(ip: string): boolean { return ipv4.test(ip) || ipv6.test(ip) || ip.includes(':'); } -/** - * Trust proxy configuration. - * - * - `false` (default): no proxy headers are trusted; the socket address is used. - * - `number`: the number of trusted proxy hops between the client and the - * application. The client IP selected from the forwarded chain is taken - * that many positions from the right. - * - `string[]`: a list of trusted proxy CIDRs. The forwarded chain is walked - * from the right, skipping addresses that fall within the trusted CIDRs, - * until the first untrusted address is found. - */ -export type TrustProxy = boolean | number | readonly string[]; - -/** Normalises an IP for comparison (lowercase, no brackets/port). */ -function normalizeIp(ip: string): string { - let value = ip.trim().toLowerCase(); - if (value.startsWith('[')) { - const end = value.indexOf(']'); - if (end !== -1) { - value = value.slice(1, end); - } - } else if (/^\d+\.\d+\.\d+\.\d+:/.test(value)) { - // IPv4 with port - value = value.split(':')[0]; - } - return value; -} - -/** Returns true when the IP falls within any of the trusted proxy CIDRs. */ -function isTrustedProxyIp(ip: string, trustedCidrs: readonly string[]): boolean { - if (trustedCidrs.length === 0) return false; - try { - return ipRangeCheck(ip, trustedCidrs as string[]); - } catch { - return false; - } -} - -/** - * Selects the client IP from a forwarded-for chain given a trust configuration. - * - * The chain is the comma-separated value of a forwarded header, ordered - * client-first. The rightmost entry is the address added by the closest - * trusted proxy, so it is the only one that can be trusted by default. - */ -export function selectClientIpWithTrust( - chain: readonly string[], - trustProxy: TrustProxy, -): string | undefined { - if (chain.length === 0) return undefined; - - if (trustProxy === true) { - // Backwards-compatible boolean: trust exactly one hop. - trustProxy = 1; - } - - if (trustProxy === false) { - return undefined; - } - - if (typeof trustProxy === 'number') { - if (!Number.isFinite(trustProxy) || trustProxy < 1) return undefined; - const index = chain.length - trustProxy; - if (index < 0 || index >= chain.length) return undefined; - return chain[index]; - } - - // CIDR list: walk from the right, skipping trusted proxies. - const trustedCidrs = trustProxy as readonly string[]; - for (let i = chain.length - 1; i >= 0; i--) { - const candidate = chain[i]; - if (!isTrustedProxyIp(candidate, trustedCidrs)) { - return candidate; - } - } - - // Every hop is trusted; fall back to the leftmost entry. - return chain[0]; -} - /** * Extracts the real client IP from an Express request. * - * When `trustProxy` is false (the default) the direct socket address is - * returned, making IP spoofing via headers impossible. + * Trust semantics follow Express' `trust proxy` model: + * - `false` (default): all forwarded headers are ignored and the direct + * socket address is returned, making header spoofing impossible. + * - `true`: trust all hops (equivalent to a hop count of `Infinity`). + * - `number N >= 1`: trust the last N hops. The client address is taken + * N entries from the right of the forwarded chain. With one trusted hop, + * `'1.1.1.1, 2.2.2.2'` yields `2.2.2.2`. This prevents a client from + * spoofing the leftmost entry to bypass the admin IP-allowlist or per-IP + * rate limits. * - * When `trustProxy` is a number or a CIDR list, the proxy headers listed in - * `proxyHeaders` are consulted in order; the first header that yields a valid - * client IP wins. For `x-forwarded-for` the entry is selected from the right - * according to the trust configuration, so client-controlled leftmost entries - * cannot be used to spoof an address. + * Because the client address is selected from the right of the chain, + * spoofed leftmost entries cannot influence the result as long as the + * configured hop count matches the actual number of trusted proxies. * * @param req Express request object - * @param trustProxy Trust configuration (false | hop count | trusted CIDRs) + * @param trustProxy False (no trust), true (trust all), or a hop count >= 1 * @param proxyHeaders Ordered list of headers to inspect (defaults to {@link DEFAULT_PROXY_HEADERS}) */ export function getClientIp( req: Request, - trustProxy: TrustProxy = false, + trustProxy: TrustProxyOption = false, proxyHeaders: readonly string[] = DEFAULT_PROXY_HEADERS, ): string { - const socketIp = req.ip ?? req.socket?.remoteAddress ?? ''; + const hops = normalizeTrustProxy(trustProxy); - if (trustProxy !== false) { + if (hops > 0) { for (const header of proxyHeaders) { const value = req.headers[header.toLowerCase()]; if (typeof value !== 'string' || !value.trim()) continue; - const chain = value + const entries = value .split(',') - .map((entry) => normalizeIp(entry)) - .filter((entry) => isValidIp(entry)); + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); - if (chain.length === 0) continue; + if (entries.length === 0) continue; - const selected = selectClientIpWithTrust(chain, trustProxy); - if (selected && isValidIp(selected)) return selected; + // Select the entry `hops` positions from the right. When the chain is + // shorter than the configured hop count, the leftmost entry is the + // best available candidate. + const index = Math.max(0, entries.length - hops); + const candidate = entries[index]; + if (candidate && isValidIp(candidate)) return candidate; } } - return socketIp; + return req.ip ?? req.socket?.remoteAddress ?? ''; +} + +/** + * Normalises the `trustProxy` option into a non-negative hop count. + * `false` -> 0, `true` -> Infinity, and any number >= 1 -> that number. + */ +function normalizeTrustProxy(trustProxy: TrustProxyOption): number { + if (trustProxy === true) return Number.POSITIVE_INFINITY; + if (trustProxy === false) return 0; + if (typeof trustProxy === 'number' && Number.isFinite(trustProxy) && trustProxy >= 1) { + return Math.floor(trustProxy); + } + return 0; } diff --git a/src/middleware/ipAllowlist.ts b/src/middleware/ipAllowlist.ts index ef9e7c71..25c2a0bd 100644 --- a/src/middleware/ipAllowlist.ts +++ b/src/middleware/ipAllowlist.ts @@ -1 +1 @@ -aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBpcFJhbmdlQ2hlY2sgZnJvbSAnaXAtcmFuZ2UtY2hlY2snOwppbXBvcnQgeyBsb2dnZXIgfSBmcm9tICcuL2xvZ2dpbmcuanMnOwppbXBvcnQgeyBnZXRDbGllbnRJcCwgaXNWYWxpZElwLCBERUZBVUxUX1BST1hZX0hFQURFUlMsIFRydXN0UHJveHkgfSBmcm9tICcuLi9saWIvY2xpZW50SXAuanMnOwoKLyoqCiAqIENvbmZpZ3VyYXRpb24gZm9yIElQIGFsbG93bGlzdCBtaWRkbGV3YXJlCiAqLwpleHBvcnQgaW50ZXJmYWNlIElwQWxsb3dsaXN0Q29uZmlnIHsKICAvKiogTGlzdCBvZiBhbGxvd2VkIElQIHJhbmdlcyBpbiBDSURSIHRlcm1zICovCiAgYWxsb3dlZFJhbmdlczogc3RyaW5nW107CiAgLyoqCiAgICogV2hldGhlciB0byB0cnVzdCBwcm94eSBoZWFkZXJzIGZvciBJUCByZXNvbHV0aW9uLgogICAqCiAgICogU2VjdXJpdHkgbm90ZTogc2V0IHRoaXMgdG8gYSBub24temVybyBob3AgY291bnQgb3IgYSB0cnVzdGVkIENJRFIgbGlzdAogICAqIG9ubHkgd2hlbiB0aGUgc2VydmljZSBzaXRzIGJlaGluZCBhIHRydXN0ZWQgcmV2ZXJzZSBwcm94eSB0aGF0IHlvdQogICAqIGNvbnRyb2wuICBXaGVuIGBmYWxzZWAgKHRoZSBkZWZhdWx0KSB0aGUgZGlyZWN0IHNvY2tldCBhZGRyZXNzIGlzIHVzZWQsCiAgICogbWFraW5nIGhlYWRlci1zcG9vZmluZyBpbXBvc3NpYmxlLgogICAqIFNlZSBGT1JXQVJERURfSEVBREVSX1BPTElDWS5tZCBmb3IgdGhlIGZ1bGwgdHJ1c3QtYm91bmRhcnkgcG9saWN5LgogICAqLwogIHRydXN0UHJveHk/OiBUcnVzdFByb3h5OwogIC8qKiBDdXN0b20gcHJveHkgaGVhZGVycyB0byBjaGVjayAoaW4gb3JkZXIgb2YgcHJpb3JpdHkpICovCiAgcHJveHlIZWFkZXJzPzogc3RyaW5nW107CiAgLyoqIFdoZXRoZXIgdG8gZW5hYmxlIHRoZSBhbGxvd2xpc3QgKGRlZmF1bHRzIHRvIHRydWUpICovCiAgZW5hYmxlZD86IGJvb2xlYW47Cn0KCi8qKgogKiBDcmVhdGVzIElQIGFsbG93bGlzdCBtaWRkbGV3YXJlIGZvciBwcm90ZWN0aW5nIHNlbnNpdGl2ZSBlbmRwb2ludHMuCiAqCiAqIElQIHJlc29sdXRpb24gZm9sbG93cyB0aGUgdHJ1c3QtYm91bmRhcnkgcG9saWN5IGluIEZPUldBUkRFRF9IRUFERVJfUE9MSUNZLm1kOgogKiAtIFdoZW4gdHJ1c3RQcm94eSBpcyBmYWxzZSwgdGhlIGRpcmVjdCBzb2NrZXQgYWRkcmVzcyBpcyB1c2VkIChzcG9vZi1wcm9vZikuCiAqIC0gV2hlbiB0cnVzdFByb3h5IGlzIGEgaG9wIGNvdW50IG9yIENJRFIgbGlzdCwgdGhlIGNsaWVudCBJUCBpcyBzZWxlY3RlZAogKiAgIGZyb20gdGhlIGZvcndhcmRlZCBjaGFpbiB0aGF0IG1hbnkgcG9zaXRpb25zIGZyb20gdGhlIHJpZ2h0LCBzbyBjbGllbnQtCiAqICAgY29udHJvbGxlZCBsZWZ0bW9zdCBlbnRyaWVzIGNhbm5vdCBzcG9vZiB0aGUgYWRkcmVzcy4KICovCmV4cG9ydCBmdW5jdGlvbiBjcmVhdGVJcEFsbG93bGlzdChjb25maWc6IElwQWxsb3dsaXN0Q29uZmlnKSB7CiAgY29uc3QgewogICAgYWxsb3dlZFJhbmdlcywKICAgIHRydXN0UHJveHkgPSBmYWxzZSwKICAgIHByb3h5SGVhZGVycyA9IERFRkFVTFRfUFJPWFlfSEVBREVSUywKICAgIGVuYWJsZWQgPSB0cnVlLAogIH0gPSBjb25maWc7CgogIGlmICghQXJyYXkuaXNBcnJheShhbGxvd2VkUmFuZ2VzKSB8fCBhbGxvd2VkUmFuZ2VzLmxlbmd0aCA9PT0gMCkgewogICAgdGhyb3cgbmV3IEVycm9yKCdJUCBhbGxvd2xpc3QgbXVzdCBoYXZlIGF0IGxlYXN0IG9uZSBhbGxvd2VkIHJhbmdlJyk7CiAgfQoKICBsb2dnZXIuaW5mbygKICAgIHsKICAgICAgYWxsb3dlZFJhbmdlc0NvdW50OiBhbGxvd2VkUmFuZ2VzLmxlbmd0aCwKICAgICAgdHJ1c3RQcm94eSwKICAgICAgcHJveHlIZWFkZXJzLAogICAgICBlbmFibGVkLAogICAgfSwKICAgICdJUCBhbGxvd2xpc3QgbWlkZGxld2FyZSBjb25maWd1cmVkJywKICApOwoKICByZXR1cm4gKHJlcTogUmVxdWVzdCwgcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKTogdm9pZCA9PiB7CiAgICBpZiAoIWVuYWJsZWQpIHsKICAgICAgbmV4dCgpOwogICAgICByZXR1cm47CiAgICB9CgogICAgLy8gUmVzb2x2ZSBjbGllbnQgSVAgcGVyIHRydXN0LWJvdW5kYXJ5IHBvbGljeTogd2hlbiB0cnVzdFByb3h5IGlzIGZhbHNlCiAgICAvLyBnZXRDbGllbnRJcCByZXR1cm5zIHJlcS5pcCAoc29ja2V0IGFkZHJlc3MpLCBpZ25vcmluZyBhbGwgZm9yd2FyZGVkIGhlYWRlcnMuCiAgICBjb25zdCBjbGllbnRJcCA9IGdldENsaWVudElwKHJlcSwgdHJ1c3RQcm94eSwgcHJveHlIZWFkZXJzKTsKCiAgICBpZiAoIWlzVmFsaWRJcChjbGllbnRJcCkpIHsKICAgICAgbG9nZ2VyLndhcm4oCiAgICAgICAgewogICAgICAgICAgaXA6IGNsaWVudElwLAogICAgICAgICAgdXNlckFnZW50OiByZXEuZ2V0KCdVc2VyLUFnZW50JyksCiAgICAgICAgICBwYXRoOiByZXEucGF0aCwKICAgICAgICB9LAogICAgICAgICdJbnZhbGlkIElQIGZvcm1hdCBkZXRlY3RlZCcsCiAgICAgICk7CiAgICAgIHJlcy5zdGF0dXMoNDAwKS5qc29uKHsKICAgICAgICBlcnJvcjogJ0JhZCBSZXF1ZXN0OiBpbnZhbGlkIGNsaWVudCBJUCBmb3JtYXQnLAogICAgICAgIGNvZGU6ICdJTlZBTElEX0lQX0ZPUk1BVCcsCiAgICAgIH0pOwogICAgICByZXR1cm47CiAgICB9CgogICAgaWYgKCFpcFJhbmdlQ2hlY2soY2xpZW50SXAsIGFsbG93ZWRSYW5nZXMpKSB7CiAgICAgIGxvZ2dlci53YXJuKAogICAgICAgIHsKICAgICAgICAgIGNsaWVudElwLAogICAgICAgICAgcGF0aDogcmVxLnBhdGgsCiAgICAgICAgICBtZXRob2Q6IHJlcS5tZXRob2QsCiAgICAgICAgICB1c2VyQWdlbnQ6IHJlcS5nZXQoJ1VzZXItQWdlbnQnKSwKICAgICAgICAgIHRpbWVzdGFtcDogbmV3IERhdGUoKS50b0lTT1N0cmluZygpLAogICAgICAgIH0sCiAgICAgICAgJ0lQIGFsbG93bGlzdCBibG9ja2VkIHJlcXVlc3QnLAogICAgICApOwogICAgICByZXMuc3RhdHVzKDQwMykuanNvbih7CiAgICAgICAgZXJyb3I6ICdGb3JiaWRkZW46IElQIGFkZHJlc3Mgbm90IGFsbG93ZWQnLAogICAgICAgIGNvZGU6ICdJUF9OT1RfQUxMT1dFRCcsCiAgICAgIH0pOwogICAgICByZXR1cm47CiAgICB9CgogICAgbG9nZ2VyLmRlYnVnKAogICAgICB7CiAgICAgICAgY2xpZW50SXAsCiAgICAgICAgcGF0aDogcmVxLnBhdGgsCiAgICAgICAgbWV0aG9kOiByZXEubWV0aG9kLAogICAgICB9LAogICAgICAnSVAgYWxsb3dsaXN0IGNoZWNrIHBhc3NlZCcsCiAgICApOwoKICAgIG5leHQoKTsKICB9Owp9CgovKiogUGFyc2VzIHRoZSBUUlVTVF9QUk9YWV9IRUFERVJTIGVudiB2YXJpYWJsZSBpbnRvIGEgdHJ1c3QgY29uZmlndXJhdGlvbi4gKi8KZnVuY3Rpb24gcGFyc2VUcnVzdFByb3h5RW52KHJhdzogc3RyaW5nIHwgdW5kZWZpbmVkKTogVHJ1c3RQcm94eSB7CiAgaWYgKCFyYXcpIHJldHVybiBmYWxzZTsKICBjb25zdCB0cmltbWVkID0gcmF3LnRyaW0oKTsKICBpZiAoIXRyaW1tZWQpIHJldHVybiBmYWxzZTsKICBpZiAodHJpbW1lZCA9PT0gJ3RydWUnKSByZXR1cm4gMTsKICBpZiAodHJpbW1lZCA9PT0gJ2ZhbHNlJykgcmV0dXJuIGZhbHNlOwoKICAvLyBOdW1lcmljIGhvcCBjb3VudAogIGlmICgvXlxkKyQvLnRlc3QodHJpbW1lZCkpIHsKICAgIGNvbnN0IGhvcHMgPSBOdW1iZXIodHJpbW1lZCk7CiAgICByZXR1cm4gaG9wcyA+IDAgPyBob3BzIDogZmFsc2U7CiAgfQoKICAvLyBDb21tYS1zZXBhcmF0ZWQgQ0lEUiBsaXN0CiAgY29uc3QgbGlzdCA9IHRyaW1tZWQuc3BsaXQoJywnKS5tYXAoKGVudHJ5KSA9PiBlbnRyeS50cmltKCkpLmZpbHRlcihCb29sZWFuKTsKICByZXR1cm4gbGlzdC5sZW5ndGggPiAwID8gbGlzdCA6IGZhbHNlOwp9CgovKioKICogUHJlLWNvbmZpZ3VyZWQgSVAgYWxsb3dsaXN0IGZvciBhZG1pbiBlbmRwb2ludHMuCiAqIFVzZXMgZW52aXJvbm1lbnQgdmFyaWFibGVzIGZvciBjb25maWd1cmF0aW9uLgogKi8KZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUFkbWluSXBBbGxvd2xpc3QoKSB7CiAgY29uc3QgYWxsb3dlZFJhbmdlcyA9IHByb2Nlc3MuZW52LkFETUlOX0lQX0FMTE9XRURfUkFOR0VT Py5zcGxpdCgnLCcpLm1hcChyID0+IHIudHJpbSgpKSA/PyBbXTsKICBjb25zdCB0cnVzdFByb3h5ID0gcGFyc2VUcnVzdFByb3h5RW52KHByb2Nlc3MuZW52LlRSVVNUX1BST1hZX0hFQURFUlMpOwogIGNvbnN0IGVuYWJsZWQgPSBwcm9jZXNzLmVudi5BRE1JTl9JUF9BTExPV0xJU1RfRU5BQkxFRCAhPT0gJ2ZhbHNlJzsKCiAgaWYgKGFsbG93ZWRSYW5nZXMubGVuZ3RoID09PSAwKSB7CiAgICBsb2dnZXIud2FybignQWRtaW4gSVAgYWxsb3dsaXN0IGlzIGVtcHR5IC0gYWxsb3dpbmcgYWxsIElQcycpOwogICAgcmV0dXJuIChfcmVxOiBSZXF1ZXN0LCBfcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKTogdm9pZCA9PiBuZXh0KCk7CiAgfQoKICByZXR1cm4gY3JlYXRlSXBBbGxvd2xpc3QoeyBhbGxvd2VkUmFuZ2VzLCB0cnVzdFByb3h5LCBlbmFibGVkIH0pOwp9CgovKioKICogUHJlLWNvbmZpZ3VyZWQgSVAgYWxsb3dsaXN0IGZvciBnYXRld2F5IGVuZHBvaW50cy4KICogVXNlcyBlbnZpcm9ubWVudCB2YXJpYWJsZXMgZm9yIGNvbmZpZ3VyYXRpb24uCiAqLwpleHBvcnQgZnVuY3Rpb24gY3JlYXRlR2F0ZXdheUlwQWxsb3dsaXN0KCkgewogIGNvbnN0IGFsbG93ZWRSYW5nZXMgPSBwcm9jZXNzLmVudi5HQVRFV0FZX0lQX0FMTE9XRURfUkFOR0VT Py5zcGxpdCgnLCcpLm1hcChyID0+IHIudHJpbSgpKSA/PyBbXTsKICBjb25zdCB0cnVzdFByb3h5ID0gcGFyc2VUcnVzdFByb3h5RW52KHByb2Nlc3MuZW52LlRSVVNUX1BST1hZX0hFQURFUlMpOwogIGNvbnN0IGVuYWJsZWQgPSBwcm9jZXNzLmVudi5HQVRFV0FZX0lQX0FMTE9XTElTVF9FTkFCTEVEICE9PSAnZmFsc2UnOwoKICBpZiAoYWxsb3dlZFJhbmdlcy5sZW5ndGggPT09IDApIHsKICAgIGxvZ2dlci53YXJuKCdHYXRld2F5IElQIGFsbG93bGlzdCBpcyBlbXB0eSAtIGFsbG93aW5nIGFsbCBJUHMnKTsKICAgIHJldHVybiAoX3JlcTogUmVxdWVzdCwgX3JlczogUmVzcG9uc2UsIG5leHQ6IE5leHRGdW5jdGlvbik6IHZvaWQgPT4gbmV4dCgpOwogIH0KCiAgcmV0dXJuIGNyZWF0ZUlwQWxsb3dsaXN0KHsgYWxsb3dlZFJhbmdlcywgdHJ1c3RQcm94eSwgZW5hYmxlZCB9KTsKfQo= \ No newline at end of file +aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBpcFJhbmdlQ2hlY2sgZnJvbSAnaXAtcmFuZ2UtY2hlY2snOwppbXBvcnQgeyBsb2dnZXIgfSBmcm9tICcuL2xvZ2dpbmcuanMnO2ltcG9ydCB7IGdldENsaWVudElwLCBpc1ZhbGlkSXAsIERFRkFVTFRfUFJPWFlfSEVBREVSUyB9IGZyb20gJy4uL2xpYi9jbGllbnRJcC5qcyc7CgovKioKICogQ29uZmlndXJhdGlvbiBmb3IgSVAgYWxsb3dsaXN0IG1pZGRsZXdhcmUKICovCmV4cG9ydCBpbnRlcmZhY2UgSXBBbGxvd2xpc3RDb25maWcgewogIC8qKiBMaXN0IG9mIGFsbG93ZWQgSVAgcmFuZ2VzIGluIENJRFIgbm90YXRpb24gKi8KICBhbGxvd2VkUmFuZ2VzOiBzdHJpbmdbXTsKICAvKioKICAgKiBOdW1iZXIgb2YgdHJ1c3RlZCBwcm94eSBob3BzIGFoZWFkIG9mIHRoZSBhcHBsaWNhdGlvbi4KICAgKgogICAqIFNlY3VyaXR5IG5vdGU6IHNldCB0aGlzIHRvIGEgcG9zaXRpdmUgaW50ZWdlciBvbmx5IHdoZW4gdGhlCiAgICogc2VydmljZSBzaXRzIGJlaGluZCB0aGF0IG1hbnkgdHJ1c3RlZCByZXZlcnNlIHByb3hpZXMgdGhhdCB5b3UKICAgKiBjb250cm9sLiAgV2hlbiAwICh0aGUgZGVmYXVsdCkgdGhlIGRpcmVjdCBzb2NrZXQgYWRkcmVzcyBpcyB1c2VkLAogICAqIG1ha2luZyBoZWFkZXItc3Bvb2ZpbmcgaW1wb3NzaWJsZS4gIFNlZSBGT1JXQVJERURfSEVBREVSX1BPTElDWS5tZAogICAqIGZvciB0aGUgZnVsbCB0cnVzdC1ib3VuZGFyeSBwb2xpY3kuCiAgICovCiAgdHJ1c3RQcm94eUhvcHM/OiBudW1iZXI7CiAgLyoqIEN1c3RvbSBwcm94eSBoZWFkZXJzIHRvIGNoZWNrIChpbiBvcmRlciBvZiBwcmlvcml0eSkgKi8KICBwcm94eUhlYWRlcnM/OiBzdHJpbmdbXTsKICAvKiogV2hldGhlciB0byBlbmFibGUgdGhlIGFsbG93bGlzdCAoZGVmYXVsdHMgdG8gdHJ1ZSkgKi8KICBlbmFibGVkPzogYm9vbGVhbjsKfQoKLyoqCiAqIENyZWF0ZXMgSVAgYWxsb3dsaXN0IG1pZGRsZXdhcmUgZm9yIHByb3RlY3Rpbmcgc2Vuc2l0aXZlIGVuZHBvaW50cy4KICoKICogSVAgcmVzb2x1dGlvbiBmb2xsb3dzIHRoZSB0cnVzdC1ib3VuZGFyeSBwb2xpY3kgaW4gRk9SV0FSREVEX0hFQURFUl9QT0xJQ1kubWQ6CiAqIC0gV2hlbiB0cnVzdFByb3h5SG9wcyBpcyAwLCB0aGUgZGlyZWN0IHNvY2tldCBhZGRyZXNzIGlzIHVzZWQgKHNwb29mLXByb29mKS4KICogLSBXaGVuIHRydXN0UHJveHlIb3BzIGlzIE4sIHRoZSBOLXRoIGVudHJ5IGZyb20gdGhlIHJpZ2h0IG9mCiAqICAgWC1Gb3J3YXJkZWQtRm9yIGlzIHVzZWQsIGFsaWduaW5nIHdpdGggRXhwcmVzcyAndHJ1c3QgcHJveHknIHNlbWFudGljcy4KICovCmV4cG9ydCBmdW5jdGlvbiBjcmVhdGVJcEFsbG93bGlzdChjb25maWc6IElwQWxsb3dsaXN0Q29uZmlnKSB7CiAgY29uc3QgewogICAgYWxsb3dlZFJhbmdlcywKICAgIHRydXN0UHJveHlIb3BzID0gMCwKICAgIHByb3h5SGVhZGVycyA9IERFRkFVTFRfUFJPWFlfSEVBREVSUywKICAgIGVuYWJsZWQgPSB0cnVlLAogIH0gPSBjb25maWc7CgogIGlmICghQXJyYXkuaXNBcnJheShhbGxvd2VkUmFuZ2VzKSB8fCBhbGxvd2VkUmFuZ2VzLmxlbmd0aCA9PT0gMCkgewogICAgdGhyb3cgbmV3IEVycm9yKCdJUCBhbGxvd2xpc3QgbXVzdCBoYXZlIGF0IGxlYXN0IG9uZSBhbGxvd2VkIHJhbmdlJyk7CiAgfQoKICBpZiAoIU51bWJlci5pc0ludGVnZXIodHJ1c3RQcm94eUhvcHMpIHx8IHRydXN0UHJveHlIb3BzIDwgMCkgewogICAgdGhyb3cgbmV3IEVycm9yKCd0cnVzdFByb3h5SG9wcyBtdXN0IGJlIGEgbm9uLW5lZ2F0aXZlIGludGVnZXInKTsKICB9CgogIGxvZ2dlci5pbmZvKAogICAgewogICAgICBhbGxvd2VkUmFuZ2VzQ291bnQ6IGFsbG93ZWRSYW5nZXMubGVuZ3RoLAogICAgICB0cnVzdFByb3h5SG9wcywKICAgICAgcHJveHlIZWFkZXJzLAogICAgICBlbmFibGVkLAogICAgfSwKICAgICdJUCBhbGxvd2xpc3QgbWlkZGxld2FyZSBjb25maWd1cmVkJywKICApOwoKICByZXR1cm4gKHJlcTogUmVxdWVzdCwgcmVzOiBSZXNwb25zZSwgbmV4dDogTmV4dEZ1bmN0aW9uKTogdm9pZCA9PiB7CiAgICBpZiAoIWVuYWJsZWQpIHsKICAgICAgbmV4dCgpOwogICAgICByZXR1cm47CiAgICB9CgogICAgLy8gUmVzb2x2ZSBjbGllbnQgSVAgcGVyIHRydXN0LWJvdW5kYXJ5IHBvbGljeTogd2hlbiB0cnVzdFByb3h5SG9wcyBpcyAwCiAgICAvLyBnZXRDbGllbnRJcCByZXR1cm5zIHJlcS5pcCAoc29ja2V0IGFkZHJlc3MpLCBpZ25vcmluZyBhbGwgZm9yd2FyZGVkIGhlYWRlcnMuCiAgICBjb25zdCBjbGllbnRJcCA9IGdldENsaWVudElwKHJlcSwgdHJ1c3RQcm94eUhvcHMsIHByb3h5SGVhZGVycyk7CgogICAgaWYgKCFpc1ZhbGlkSXAoY2xpZW50SXApKSB7CiAgICAgIGxvZ2dlci53YXJuKAogICAgICAgIHsKICAgICAgICAgIGlwOiBjbGllbnRJcCwKICAgICAgICAgIHVzZXJBZ2VudDogcmVxLmdldCgnVXNlci1BZ2VudCcpLAogICAgICAgICAgcGF0aDogcmVxLnBhdGgsCiAgICAgICAgfSwKICAgICAgICAnSW52YWxpZCBJUCBmb3JtYXQgZGV0ZWN0ZWQnLAogICAgICApOwogICAgICByZXMuc3RhdHVzKDQwMCkuanNvbih7CiAgICAgICAgZXJyb3I6ICdCYWQgUmVxdWVzdDogaW52YWxpZCBjbGllbnQgSVAgZm9ybWF0JywKICAgICAgICBjb2RlOiAnSU5WQUxJRF9JUF9GT1JNQVQnLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmICghaXBSYW5nZUNoZWNrKGNsaWVudElwLCBhbGxvd2VkUmFuZ2VzKSkgewogICAgICBsb2dnZXIud2FybigKICAgICAgICB7CiAgICAgICAgICBjbGllbnRJcCwKICAgICAgICAgIHBhdGg6IHJlcS5wYXRoLAogICAgICAgICAgbWV0aG9kOiByZXEubWV0aG9kLAogICAgICAgICAgdXNlckFnZW50OiByZXEuZ2V0KCdVc2VyLUFnZW50JyksCiAgICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSwKICAgICAgICB9LAogICAgICAgICdJUCBhbGxvd2xpc3QgYmxvY2tlZCByZXF1ZXN0JywKICAgICAgKTsKICAgICAgcmVzLnN0YXR1cyg0MDMpLmpzb24oewogICAgICAgIGVycm9yOiAnRm9yYmlkZGVuOiBJUCBhZGRyZXNzIG5vdCBhbGxvd2VkJywKICAgICAgICBjb2RlOiAnSVBfTk9UX0FMTE9XRUQnLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGxvZ2dlci5kZWJ1ZygKICAgICAgewogICAgICAgIGNsaWVudElwLAogICAgICAgIHBhdGg6IHJlcS5wYXRoLAogICAgICAgIG1ldGhvZDogcmVxLm1ldGhvZCwKICAgICAgfSwKICAgICAgJ0lQIGFsbG93bGlzdCBjaGVjayBwYXNzZWQnLAogICAgKTsKCiAgICBuZXh0KCk7CiAgfTsKfQoKLyoqCiAqIFJlc29sdmUgdGhlIGNvbmZpZ3VyZWQgbnVtYmVyIG9mIHRydXN0ZWQgcHJveHkgaG9wcyBmcm9tIHRoZQogKiBlbnZpcm9ubWVudC4gIEZhbGxzIGJhY2sgdG8gdGhlIGxlZ2FjeSBib29sZWFuIFRSVVNUX1BST1hZX0hFQURFUlMKICogKHRydWUgPT4gMSBob3ApIGZvciBiYWNrd2FyZHMgY29tcGF0aWJpbGl0eSwgYW5kIDAgKG5vIHRydXN0KSBvdGhlcndpc2UuCiAqLwpmdW5jdGlvbiByZXNvbHZlVHJ1c3RQcm94eUhvcHMoKTogbnVtYmVyIHsKICBjb25zdCByYXdIb3BzID0gcHJvY2Vzcy5lbnYuVFJVU1RfUFJPWFlfSE9QUzsKICBpZiAocmF3SG9wcyAhPT0gdW5kZWZpbmVkICYmIHJhd0hvcHMgIT09ICcnKSB7CiAgICBjb25zdCBwYXJzZWQgPSBOdW1iZXIocmF3SG9wcyk7CiAgICBpZiAoTnVtYmVyLmlzSW50ZWdlcihwYXJzZWQpICYmIHBhcnNlZCA+PSAwKSB7CiAgICAgIHJldHVybiBwYXJzZWQ7CiAgICB9CiAgICBsb2dnZXIud2Fybih7IHJhd0hvcHMgfSwgJ0ludmFsaWQgVFJVU1RfUFJPWFlfSE9QUyB2YWx1ZTsgZmFsbGluZyBiYWNrIHRvIGRlZmF1bHQnKTsKICB9CiAgaWYgKHByb2Nlc3MuZW52LlRSVVNUX1BST1hZX0hFQURFUlMgPT09ICd0cnVlJykgewogICAgcmV0dXJuIDE7CiAgfQogIHJldHVybiAwOwp9CgovKioKICogUHJlLWNvbmZpZ3VyZWQgSVAgYWxsb3dsaXN0IGZvciBhZG1pbiBlbmRwb2ludHMuCiAqIFVzZXMgZW52aXJvbm1lbnQgdmFyaWFibGVzIGZvciBjb25maWd1cmF0aW9uLgogKi8KZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUFkbWluSXBBbGxvd2xpc3QoKSB7CiAgY29uc3QgYWxsb3dlZFJhbmdlcyA9IHByb2Nlc3MuZW52LkFETUlOX0lQX0FMTE9XRURfUkFOR0VT Py5zcGxpdCgnLCcpLm1hcChyID0+IHIudHJpbSgpKSA/PyBbXTsKICBjb25zdCB0cnVzdFByb3h5SG9wcyA9IHJlc29sdmVUcnVzdFByb3h5SG9wcygpOwogIGNvbnN0IGVuYWJsZWQgPSBwcm9jZXNzLmVudi5BRE1JTl9JUF9BTExPV0xJU1RfRU5BQkxFRCAhPT0gJ2ZhbHNlJzsKCiAgaWYgKGFsbG93ZWRSYW5nZXMubGVuZ3RoID09PSAwKSB7CiAgICBsb2dnZXIud2FybigndGhlIEFkbWluIElQIGFsbG93bGlzdCBpcyBlbXB0eSAtIGFsbG93aW5nIGFsbCBJUHMnKTsKICAgIHJldHVybiAoX3JlcTogUmVxdWVzdCwgX3JlczogUmVzcG9uc2UsIG5leHQ6IE5leHRGdW5jdGlvbik6IHZvaWQgPT4gbmV4dCgpOwogIH0KCiAgcmV0dXJuIGNyZWF0ZUlwQWxsb3dsaXN0KHsgYWxsb3dlZFJhbmdlcywgdHJ1c3RQcm94eUhvcHMsIGVuYWJsZWQgfSk7Cn0KCi8qKgogKiBQcmUtY29uZmlndXJlZCBJUCBhbGxvd2xpc3QgZm9yIGdhdGV3YXkgZW5kcG9pbnRzLgogKiBVc2VzIGVudmlyb25tZW50IHZhcmlhYmxlcyBmb3IgY29uZmlndXJhdGlvbi4KICovCmV4cG9ydCBmdW5jdGlvbiBjcmVhdGVHYXRld2F5SXBBbGxvd2xpc3QoKSB7CiAgY29uc3QgYWxsb3dlZFJhbmdlcyA9IHByb2Nlc3MuZW52LkdBVEVXQVlfSVBfQUxMT1dFRF9SQU5HRVM/LnNwbGl0KCcsJykubWFwKHIgPT4gci50cmltKCkpID8/IFtdOwogIGNvbnN0IHRydXN0UHJveHlIb3BzID0gcmVzb2x2ZVRydXN0UHJveHlIb3BzKCk7CiAgY29uc3QgZW5hYmxlZCA9IHByb2Nlc3MuZW52LkdBVEVXQVlfSVBfQUxMT1dMSVNUX0VOQUJMRUQgIT09ICdmYWxzZSc7CgogIGlmIChhbGxvd2VkUmFuZ2VzLmxlbmd0aCA9PT0gMCkgewogICAgbG9nZ2VyLndhcm4oJ3RoZSBHYXRld2F5IElQIGFsbG93bGlzdCBpcyBlbXB0eSAtIGFsbG93aW5nIGFsbCBJUHMnKTsKICAgIHJldHVybiAoX3JlcTogUmVxdWVzdCwgX3JlczogUmVzcG9uc2UsIG5leHQ6IE5leHRGdW5jdGlvbik6IHZvaWQgPT4gbmV4dCgpOwogIH0KCiAgcmV0dXJuIGNyZWF0ZUlwQWxsb3dsaXN0KHsgYWxsb3dlZFJhbmdlcywgdHJ1c3RQcm94eUhvcHMsIGVuYWJsZWQgfSk7Cn0K \ No newline at end of file