Skip to content

Commit cd73464

Browse files
committed
feat: add client IP resolution and debugging utilities
- Implemented `resolveClientIp` function to accurately determine the client's real IP address behind proxies and CDNs, enhancing security and audit capabilities. - Introduced `buildClientIpDebugPayload` for detailed debugging information regarding client IP resolution, including raw header values and hints for proper proxy configuration. - Added unit tests to validate the functionality of IP resolution and debugging utilities, ensuring robustness against various scenarios.
1 parent 0490917 commit cd73464

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import type { Request } from 'express';
2+
import requestIp from 'request-ip';
3+
4+
/**
5+
* Lit une en-tête HTTP (string ou première entrée d'un tableau).
6+
*/
7+
function headerString(req: Request, name: string): string | undefined {
8+
const v = req.headers[name.toLowerCase()];
9+
if (typeof v === 'string') {
10+
return v;
11+
}
12+
if (Array.isArray(v) && v.length > 0 && typeof v[0] === 'string') {
13+
return v[0];
14+
}
15+
return undefined;
16+
}
17+
18+
/**
19+
* Prend la première valeur d'une liste CSV (ex. X-Forwarded-For: client, proxy1).
20+
*/
21+
function firstCsvSegment(value: string | undefined): string | null {
22+
if (!value || typeof value !== 'string') {
23+
return null;
24+
}
25+
const part = value.split(',')[0]?.trim();
26+
return part && part.length > 0 ? part : null;
27+
}
28+
29+
function normalizeIp(raw: string | undefined | null): string | null {
30+
if (!raw || typeof raw !== 'string') {
31+
return null;
32+
}
33+
const value = raw.trim();
34+
if (value.length === 0) {
35+
return null;
36+
}
37+
return value.startsWith('::ffff:') ? value.slice(7) : value;
38+
}
39+
40+
function isLoopbackIp(ip: string | null): boolean {
41+
return ip === '127.0.0.1' || ip === '::1';
42+
}
43+
44+
function isPrivateIpv4(ip: string): boolean {
45+
const parts = ip.split('.').map((p): number => Number.parseInt(p, 10));
46+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
47+
return false;
48+
}
49+
const [a, b] = parts;
50+
if (a === 10) {
51+
return true;
52+
}
53+
if (a === 172 && b >= 16 && b <= 31) {
54+
return true;
55+
}
56+
if (a === 192 && b === 168) {
57+
return true;
58+
}
59+
return false;
60+
}
61+
62+
function hasForwardingHeaders(req: Request): boolean {
63+
const forwarded = ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip'] as const;
64+
return forwarded.some((name) => Boolean(normalizeIp(firstCsvSegment(headerString(req, name)) ?? headerString(req, name) ?? null)));
65+
}
66+
67+
function hostLooksLocal(req: Request): boolean {
68+
const host = headerString(req, 'host');
69+
if (!host) {
70+
return false;
71+
}
72+
const hostname = host.split(':')[0]?.trim().toLowerCase();
73+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
74+
}
75+
76+
function localDevFallbackIp(req: Request, peerIp: string | null): string | null {
77+
if (!hostLooksLocal(req) || hasForwardingHeaders(req) || !peerIp) {
78+
return null;
79+
}
80+
if (isLoopbackIp(peerIp) || isPrivateIpv4(peerIp)) {
81+
return null;
82+
}
83+
return '127.0.0.1';
84+
}
85+
86+
/** Paire TCP (souvent le dernier proxy / relai), normalisée. */
87+
function tcpPeerIp(req: Request): string | null {
88+
return normalizeIp(req.socket?.remoteAddress ?? null);
89+
}
90+
91+
/**
92+
* Nitro / certains proxies posent X-Forwarded-For = IP du pair TCP sans ajouter la vraie IP client.
93+
* Dans ce cas l’en-tête est trompeur : on l’ignore pour request-ip aussi.
94+
*/
95+
function requestForIpResolution(req: Request): Request {
96+
const peer = tcpPeerIp(req);
97+
const xffRaw = headerString(req, 'x-forwarded-for');
98+
const xffFirst = normalizeIp(firstCsvSegment(xffRaw) ?? (xffRaw?.trim() || null));
99+
if (!peer || !xffFirst || xffFirst !== peer) {
100+
return req;
101+
}
102+
const headers = { ...req.headers } as Request['headers'];
103+
delete headers['x-forwarded-for'];
104+
return { ...req, headers } as Request;
105+
}
106+
107+
/**
108+
* Résout l'IP client réelle derrière CDN / reverse-proxy (Cloudflare, nginx, etc.).
109+
* À utiliser pour l'auth et les audits ; combiner avec `trust proxy` sur Express si besoin.
110+
*
111+
* Si X-Forwarded-For ne fait que répéter l’IP du socket (souvent en dev derrière Nitro / Docker),
112+
* cette valeur n’est pas considérée comme « client d’origine » : on retombe sur la même IP pair
113+
* (cela ne fabrique pas une IP LAN magique — pour ça il faut un proxy qui pose une vraie chaîne XFF).
114+
*/
115+
export function resolveClientIp(req: Request): string | null {
116+
const peerIp = tcpPeerIp(req);
117+
const forcedLocalIp = localDevFallbackIp(req, peerIp);
118+
if (forcedLocalIp) {
119+
return forcedLocalIp;
120+
}
121+
const orderedHeaders = ['cf-connecting-ip', 'true-client-ip', 'x-real-ip', 'x-forwarded-for'] as const;
122+
123+
for (const name of orderedHeaders) {
124+
const raw = headerString(req, name);
125+
const segment = firstCsvSegment(raw) ?? (raw?.trim() || null);
126+
const ip = normalizeIp(segment);
127+
if (!ip) {
128+
continue;
129+
}
130+
// Ne pas prendre un « forwarded » qui ne fait que recopier le pair TCP (pas de chaîne utile).
131+
if (peerIp && ip === peerIp) {
132+
continue;
133+
}
134+
return ip;
135+
}
136+
137+
const fromLib = normalizeIp(requestIp.getClientIp(requestForIpResolution(req)) ?? null);
138+
if (fromLib) {
139+
return fromLib;
140+
}
141+
142+
const expressIp = normalizeIp(req.ip ?? null);
143+
if (expressIp) {
144+
return expressIp;
145+
}
146+
147+
return peerIp;
148+
}
149+
150+
/**
151+
* Objet JSON pour l’endpoint de debug (footer) : champs bruts + interprétation.
152+
*/
153+
export function buildClientIpDebugPayload(req: Request): Record<string, unknown> {
154+
const pick = (name: string): string | string[] | undefined => {
155+
const v = req.headers[name.toLowerCase()];
156+
return v;
157+
};
158+
159+
const peerIp = tcpPeerIp(req);
160+
const xffRaw = headerString(req, 'x-forwarded-for');
161+
const xffFirst = normalizeIp(firstCsvSegment(xffRaw) ?? (xffRaw?.trim() || null));
162+
const xffEchoesPeer = Boolean(peerIp && xffFirst && peerIp === xffFirst);
163+
const clientIp = resolveClientIp(req);
164+
const hasTrustedForward =
165+
Boolean(normalizeIp(headerString(req, 'x-real-ip'))) ||
166+
Boolean(normalizeIp(headerString(req, 'cf-connecting-ip'))) ||
167+
Boolean(normalizeIp(headerString(req, 'true-client-ip')));
168+
169+
let hintFr =
170+
'clientIp = valeur utilisée par Nest (auth, audits). Ce n’est pas forcément « votre PC » si la connexion TCP passe par un relai (tunnel, port forward, Docker/Nitro).';
171+
172+
if (xffEchoesPeer && !hasTrustedForward) {
173+
hintFr +=
174+
' Ici X-Forwarded-For ne fait que répéter l’IP du pair TCP (relai) : il est ignoré pour la résolution. Sans X-Real-IP / CF-Connecting-IP / premier hop X-Forwarded-For fiable, Nest ne peut pas inventer votre IP LAN. Solution : reverse-proxy (nginx, Traefik…) qui transmet le client, puis SESAME_TRUST_PROXY=1 sur l’API.';
175+
}
176+
177+
return {
178+
clientIp,
179+
tcpPeerNormalized: peerIp,
180+
xForwardedForFirstNormalized: xffFirst,
181+
xffIgnoredAsEchoOfTcpPeer: xffEchoesPeer,
182+
remoteAddress: req.socket?.remoteAddress ?? null,
183+
ip: req.ip ?? null,
184+
headers: req.headers,
185+
xForwardedFor: pick('x-forwarded-for') ?? null,
186+
xRealIp: pick('x-real-ip') ?? null,
187+
cfConnectingIp: pick('cf-connecting-ip') ?? null,
188+
host: pick('host') ?? null,
189+
trustProxyEnv: process.env['SESAME_TRUST_PROXY'] ?? null,
190+
hintFr,
191+
};
192+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { Request } from 'express';
2+
import { buildClientIpDebugPayload, resolveClientIp } from '~/_common/functions/resolve-client-ip';
3+
4+
function makeReq(partial: Partial<Request> & { headers?: Record<string, string | string[] | undefined> }): Request {
5+
return {
6+
headers: {},
7+
ip: undefined,
8+
socket: { remoteAddress: undefined },
9+
...partial,
10+
} as Request;
11+
}
12+
13+
describe('resolveClientIp', () => {
14+
it('returns CF-Connecting-IP when present', () => {
15+
const req = makeReq({
16+
headers: { 'cf-connecting-ip': '198.51.100.2' },
17+
ip: '10.0.0.1',
18+
});
19+
expect(resolveClientIp(req)).toBe('198.51.100.2');
20+
});
21+
22+
it('returns first X-Forwarded-For hop', () => {
23+
const req = makeReq({
24+
headers: { 'x-forwarded-for': '203.0.113.1, 10.0.0.2' },
25+
ip: '10.0.0.2',
26+
});
27+
expect(resolveClientIp(req)).toBe('203.0.113.1');
28+
});
29+
30+
it('returns X-Real-IP before falling back to req.ip', () => {
31+
const req = makeReq({
32+
headers: { 'x-real-ip': '192.0.2.50' },
33+
ip: '10.0.0.3',
34+
});
35+
expect(resolveClientIp(req)).toBe('192.0.2.50');
36+
});
37+
38+
it('strips IPv4-mapped IPv6 prefix', () => {
39+
const req = makeReq({
40+
headers: { 'x-real-ip': '::ffff:192.0.2.1' },
41+
});
42+
expect(resolveClientIp(req)).toBe('192.0.2.1');
43+
});
44+
45+
it('ignores X-Forwarded-For when it only echoes tcp peer but still prefers X-Real-IP', () => {
46+
const req = makeReq({
47+
headers: {
48+
'x-forwarded-for': '140.82.121.5',
49+
'x-real-ip': '192.168.1.50',
50+
},
51+
ip: '140.82.121.5',
52+
socket: { remoteAddress: '::ffff:140.82.121.5' } as any,
53+
});
54+
expect(resolveClientIp(req)).toBe('192.168.1.50');
55+
});
56+
57+
it('ignores echoing X-Forwarded-For and falls back to same peer ip', () => {
58+
const req = makeReq({
59+
headers: { 'x-forwarded-for': '140.82.121.5' },
60+
ip: '140.82.121.5',
61+
socket: { remoteAddress: '140.82.121.5' } as any,
62+
});
63+
expect(resolveClientIp(req)).toBe('140.82.121.5');
64+
});
65+
66+
it('buildClientIpDebugPayload flags echoing XFF', () => {
67+
const req = makeReq({
68+
headers: { 'x-forwarded-for': '140.82.121.5' },
69+
ip: '140.82.121.5',
70+
socket: { remoteAddress: '140.82.121.5' } as any,
71+
});
72+
const p = buildClientIpDebugPayload(req);
73+
expect(p.xffIgnoredAsEchoOfTcpPeer).toBe(true);
74+
expect(p.tcpPeerNormalized).toBe('140.82.121.5');
75+
expect(p.hintFr).toEqual(expect.stringContaining('X-Forwarded-For'));
76+
});
77+
78+
it('forces localhost when host is local and peer is unexpected public ip without forwarding headers', () => {
79+
const req = makeReq({
80+
headers: { host: '127.0.0.1:4002' },
81+
ip: '140.82.121.5',
82+
socket: { remoteAddress: '::ffff:140.82.121.5' } as any,
83+
});
84+
expect(resolveClientIp(req)).toBe('127.0.0.1');
85+
});
86+
});

0 commit comments

Comments
 (0)