Skip to content
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ BCRYPT_COST_FACTOR=12
# -----------------------------------------------------------------------------
# Proxy / Gateway
# -----------------------------------------------------------------------------
# Number of trusted reverse-proxy hops in front of the app. 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
# Comma-separated allowlist of upstream hosts the gateway may proxy to.
# REQUIRED in production — startup fails if this variable is missing or empty.
# In development/test, localhost and loopback entries are permitted by default.
Expand Down
21 changes: 20 additions & 1 deletion FORWARDED_HEADER_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@ Header stripping is performed case-insensitively. All header name variations (e.
- Request IDs are included in error responses for debugging
- UUID v4 format ensures global uniqueness

## Client IP Trust Boundary

When the service sits behind one or more reverse proxies, client IP resolution follows Express's `trust proxy` semantics:

- **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.

### Configuration

- `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.

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

The header policy is implemented in `src/routes/proxyRoutes.ts`:
Expand All @@ -94,6 +110,8 @@ const DEFAULT_STRIP_HEADERS = [
];
```

`x-forwarded-for` and `x-real-ip` are also stripped before forwarding to upstream services.

Headers are processed case-insensitively using lowercase comparison:

```typescript
Expand All @@ -113,5 +131,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.
2 changes: 1 addition & 1 deletion src/__tests__/ipAllowlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ describe('IP Allowlist Middleware', () => {
expect(mockLogger.info).toHaveBeenCalledWith(
{
allowedRangesCount: 1,
trustProxy: true,
trustProxyHops: Number.POSITIVE_INFINITY,
proxyHeaders: expect.any(Array),
enabled: true,
},
Expand Down
24 changes: 24 additions & 0 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,30 @@ 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_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.
*
* 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),
TRUST_FORWARDED_USER_ID: z
.string()
.optional()
Expand Down
54 changes: 49 additions & 5 deletions src/lib/__tests__/clientIp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,36 @@ describe('getClientIp', () => {
assert.equal(getClientIp(req, false), '1.2.3.4');
});

test('uses x-forwarded-for leftmost IP when trustProxy is true', () => {
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('with two trusted hops, selects the entry two from the right', () => {
const req = makeReq({
headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3' },
});
assert.equal(getClientIp(req, 2), '2.2.2.2');
});

test('spoofed leftmost entries cannot influence the result', () => {
const req = makeReq({
headers: { 'x-forwarded-for': '10.0.0.1, 10.0.0.2, 2.2.2.2' },
});
assert.equal(getClientIp(req, 1), '2.2.2.2');
});

test('trustProxy true trusts all hops and uses leftmost entry', () => {
const req = makeReq({
headers: { 'x-forwarded-for': '5.5.5.5, 10.0.0.1, 172.16.0.1' },
});
Expand All @@ -57,7 +86,22 @@ 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 hop count exceeds chain length and leftmost is invalid', () => {
const req = makeReq({
headers: { 'x-forwarded-for': 'not-an-ip, 2.2.2.2' },
socket: { remoteAddress: '1.2.3.4' } as never,
});
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', () => {
Expand All @@ -75,16 +119,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');
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');
assert.equal(getClientIp(req, 1, ['x-custom-ip']), '7.7.7.7');
});

test('DEFAULT_PROXY_HEADERS includes x-forwarded-for', () => {
Expand Down
61 changes: 47 additions & 14 deletions src/lib/clientIp.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,33 +27,64 @@ export function isValidIp(ip: string): boolean {
/**
* 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 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.
* 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 Whether to honour proxy forwarding headers
* @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 = false,
trustProxy: TrustProxyOption = false,
proxyHeaders: readonly string[] = DEFAULT_PROXY_HEADERS,
): string {
if (trustProxy) {
const hops = normalizeTrustProxy(trustProxy);

if (hops > 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;
}
if (typeof value !== 'string' || !value.trim()) continue;

const entries = value
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);

if (entries.length === 0) continue;

// 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 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;
}
Loading