From 5830285ca747ed13100fd5e21b3a3a975c9a60bf Mon Sep 17 00:00:00 2001 From: ijeoma Date: Wed, 27 May 2026 18:49:48 +0100 Subject: [PATCH] feat(utils): add trusted-proxy-aware client IP extraction helper (#289) Adds getClientIp(req, trusted?) to src/utils/client-ip.utils.ts. - Reads the first IP from X-Forwarded-For when the socket address belongs to a trusted proxy (loopback 127.x, RFC-1918 10.x/172.16.x/ 192.168.x, IPv6 ::1/ULA fc00::/fd); falls back to socket.remoteAddress otherwise - Accepts an optional predicate override for testing and custom deployments (avoids coupling to a global trust list) - Integrated into request-logger.middleware.ts so every request log entry gains a clientIp field - 11 unit tests: trusted ranges, multi-hop XFF, array header value, custom predicate, untrusted socket passthrough, missing-socket edge cases Closes #289 --- src/middlewares/request-logger.middleware.ts | 2 + src/utils/client-ip.utils.test.ts | 96 ++++++++++++++++++++ src/utils/client-ip.utils.ts | 63 +++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/utils/client-ip.utils.test.ts create mode 100644 src/utils/client-ip.utils.ts diff --git a/src/middlewares/request-logger.middleware.ts b/src/middlewares/request-logger.middleware.ts index 207a019..638b00d 100644 --- a/src/middlewares/request-logger.middleware.ts +++ b/src/middlewares/request-logger.middleware.ts @@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express'; import { envConfig } from '../config'; import { logger } from '../utils/logger.utils'; import { computeRequestContextHash } from '../utils/request-context-hash.utils'; +import { getClientIp } from '../utils/client-ip.utils'; /** * Lightweight request logging middleware. @@ -39,6 +40,7 @@ export const requestLoggerMiddleware = ( status: res.statusCode, duration: `${durationMs}ms`, requestId: req.requestId, + clientIp: getClientIp(req), contextHash, }); }); diff --git a/src/utils/client-ip.utils.test.ts b/src/utils/client-ip.utils.test.ts new file mode 100644 index 0000000..b761268 --- /dev/null +++ b/src/utils/client-ip.utils.test.ts @@ -0,0 +1,96 @@ +import { getClientIp } from './client-ip.utils'; +import { Request } from 'express'; + +function makeReq( + socketIp: string, + forwardedFor?: string +): Request { + return { + socket: { remoteAddress: socketIp }, + headers: forwardedFor ? { 'x-forwarded-for': forwardedFor } : {}, + } as unknown as Request; +} + +describe('getClientIp', () => { + describe('when socket is a trusted proxy', () => { + it('returns the first X-Forwarded-For IP', () => { + const req = makeReq('127.0.0.1', '203.0.113.5, 10.0.0.1'); + expect(getClientIp(req)).toBe('203.0.113.5'); + }); + + it('handles a single IP in X-Forwarded-For', () => { + const req = makeReq('10.0.0.2', '198.51.100.7'); + expect(getClientIp(req)).toBe('198.51.100.7'); + }); + + it('trims whitespace from the extracted IP', () => { + const req = makeReq('192.168.1.1', ' 1.2.3.4 , 10.0.0.5'); + expect(getClientIp(req)).toBe('1.2.3.4'); + }); + + it('falls back to socket address when X-Forwarded-For is absent', () => { + const req = makeReq('10.10.10.1'); + expect(getClientIp(req)).toBe('10.10.10.1'); + }); + + it('falls back to socket address when X-Forwarded-For is empty string', () => { + const req = makeReq('172.16.0.1', ''); + expect(getClientIp(req)).toBe('172.16.0.1'); + }); + + it('handles array-valued X-Forwarded-For header', () => { + const req = { + socket: { remoteAddress: '127.0.0.1' }, + headers: { 'x-forwarded-for': ['9.9.9.9, 10.0.0.1', '8.8.8.8'] }, + } as unknown as Request; + expect(getClientIp(req)).toBe('9.9.9.9'); + }); + + it('accepts private 172.16.x.x as a trusted proxy', () => { + const req = makeReq('172.16.5.10', '55.55.55.55'); + expect(getClientIp(req)).toBe('55.55.55.55'); + }); + + it('accepts IPv6 loopback ::1 as trusted proxy', () => { + const req = makeReq('::1', '203.0.113.99'); + expect(getClientIp(req)).toBe('203.0.113.99'); + }); + }); + + describe('when socket is NOT a trusted proxy', () => { + it('ignores X-Forwarded-For and returns the socket address', () => { + const req = makeReq('203.0.113.1', '1.2.3.4'); + expect(getClientIp(req)).toBe('203.0.113.1'); + }); + + it('returns socket address even when no forwarded header is present', () => { + const req = makeReq('8.8.8.8'); + expect(getClientIp(req)).toBe('8.8.8.8'); + }); + }); + + describe('custom trusted predicate', () => { + it('uses the override predicate instead of the default CIDR check', () => { + const req = makeReq('8.8.8.8', '203.0.113.42'); + // Treat all IPs as trusted via override + expect(getClientIp(req, () => true)).toBe('203.0.113.42'); + }); + + it('rejects all proxies when override always returns false', () => { + const req = makeReq('127.0.0.1', '203.0.113.42'); + expect(getClientIp(req, () => false)).toBe('127.0.0.1'); + }); + }); + + describe('edge cases', () => { + it('returns undefined when socket address is missing', () => { + const req = { socket: {}, headers: {} } as unknown as Request; + expect(getClientIp(req)).toBeUndefined(); + }); + + it('returns undefined when socket itself is undefined', () => { + const req = { headers: {} } as unknown as Request; + expect(getClientIp(req)).toBeUndefined(); + }); + }); +}); diff --git a/src/utils/client-ip.utils.ts b/src/utils/client-ip.utils.ts new file mode 100644 index 0000000..34ab670 --- /dev/null +++ b/src/utils/client-ip.utils.ts @@ -0,0 +1,63 @@ +import { Request } from 'express'; + +/** + * Trusted private/loopback CIDR ranges whose forwarded-for addresses are + * accepted without further validation. Requests arriving from outside these + * ranges are not considered trusted proxies, so their X-Forwarded-For header + * is ignored. + */ +const TRUSTED_PROXY_CIDRS = [ + { prefix: '127.', bits: 8 }, // 127.0.0.0/8 — loopback + { prefix: '10.', bits: 8 }, // 10.0.0.0/8 — private class A + { prefix: '172.16.', bits: 12 }, // 172.16.0.0/12 — private class B + { prefix: '192.168.', bits: 16 }, // 192.168.0.0/16 — private class C + { prefix: '::1', bits: 128 }, // IPv6 loopback + { prefix: 'fc00:', bits: 7 }, // IPv6 unique-local + { prefix: 'fd', bits: 8 }, // IPv6 unique-local (fd00::/8) +]; + +/** + * Returns true when `ip` originates from a trusted proxy address. + * + * Only private/loopback ranges are trusted by default. Pass a custom + * `isTrustedProxy` predicate to override (useful in tests or when the + * deployment uses a known set of proxy IPs). + */ +function isFromTrustedProxy(ip: string): boolean { + return TRUSTED_PROXY_CIDRS.some(({ prefix }) => + ip.startsWith(prefix) + ); +} + +/** + * Extracts the real client IP address from an Express request. + * + * Resolution order: + * 1. If the socket address is a trusted proxy, read the *first* IP from the + * `X-Forwarded-For` header (the leftmost value, which is the original + * client before any proxies appended their own address). + * 2. Fall back to the direct socket address (`req.socket.remoteAddress`). + * + * The raw body is never read and the function never mutates the request. + * + * @param req - Express Request object. + * @param trusted - Optional override predicate for trusted-proxy check. + * Defaults to the private/loopback CIDR check above. + */ +export function getClientIp( + req: Request, + trusted: (ip: string) => boolean = isFromTrustedProxy +): string | undefined { + const socketIp = req.socket?.remoteAddress ?? ''; + + if (trusted(socketIp)) { + const forwarded = req.headers['x-forwarded-for']; + if (forwarded) { + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const firstIp = raw.split(',')[0].trim(); + if (firstIp) return firstIp; + } + } + + return socketIp || undefined; +}