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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/middlewares/request-logger.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -39,6 +40,7 @@ export const requestLoggerMiddleware = (
status: res.statusCode,
duration: `${durationMs}ms`,
requestId: req.requestId,
clientIp: getClientIp(req),
contextHash,
});
});
Expand Down
96 changes: 96 additions & 0 deletions src/utils/client-ip.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
63 changes: 63 additions & 0 deletions src/utils/client-ip.utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading