fix(api): resolve client ip from peer when untrusted (#3420)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-12 15:34:36 +02:00
1 parent 17746fcdc4
commit 68cb2a4bbb
13 files changed
+208 -54

No files matched your search

+6 -14
View File
@@ -1,11 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ParsedIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
import {canonicalizeIpAddress} from '@fluxer/ip_utils/src/IpAddress';
interface ClientIpExtractionOptions {
trustClientIpHeader?: boolean;
clientIpHeaderName?: string;
peerAddress?: string | null;
}
export class MissingClientIpError extends Error {
@@ -13,7 +13,7 @@ export class MissingClientIpError extends Error {
readonly status = 403;
constructor() {
super('Client IP header is required');
super('Client IP is required');
this.name = 'MissingClientIpError';
}
}
@@ -28,20 +28,12 @@ export function resolveClientIpHeaderName(clientIpHeaderName?: string): string {
return normalizeHeaderName(clientIpHeaderName ?? DEFAULT_CLIENT_IP_HEADER_NAME);
}
function parseClientIpHeaderValue(value: string | null): ParsedIpAddress | null {
if (value === null) {
return null;
}
return parseIpAddress(value.split(',', 1)[0]);
}
export function extractClientIp(req: Request, options?: ClientIpExtractionOptions): string | null {
if (!options?.trustClientIpHeader) {
return null;
return options?.peerAddress ? canonicalizeIpAddress(options.peerAddress) : null;
}
return (
parseClientIpHeaderValue(req.headers.get(resolveClientIpHeaderName(options.clientIpHeaderName)))?.normalized ?? null
);
const headerValue = req.headers.get(resolveClientIpHeaderName(options.clientIpHeaderName));
return headerValue === null ? null : canonicalizeIpAddress(headerValue.split(',', 1)[0]);
}
export function requireClientIp(req: Request, options?: ClientIpExtractionOptions): string {
+11
View File
@@ -102,6 +102,17 @@ export function parseIpAddress(value: string): ParsedIpAddress | null {
};
}
export function canonicalizeIpAddress(value: string): string | null {
const parsed = parseIpAddress(value);
if (!parsed) {
return null;
}
if (parsed.family === 'ipv4') {
return parsed.normalized;
}
return getIpv4MappedIpv6(expandIpv6ToGroups(parsed.normalized)) ?? parsed.normalized;
}
export function isValidIp(value: string): boolean {
return parseIpAddress(value) !== null;
}
@@ -50,18 +50,43 @@ describe('extractClientIp', () => {
'192.168.1.1',
);
});
it('returns null when trust is disabled', () => {
it('ignores the header and uses the peer address when trust is disabled', () => {
const request = new Request('http://example.com', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(extractClientIp(request, {trustClientIpHeader: false})).toBeNull();
expect(extractClientIp(request)).toBeNull();
expect(extractClientIp(request, {trustClientIpHeader: false, peerAddress: '203.0.113.7'})).toBe('203.0.113.7');
expect(extractClientIp(request, {peerAddress: '203.0.113.7'})).toBe('203.0.113.7');
});
it('returns null when no options are provided', () => {
it('uses the peer address when trust is disabled and no header is sent', () => {
const request = new Request('http://example.com');
expect(extractClientIp(request, {trustClientIpHeader: false, peerAddress: '2001:db8::7'})).toBe('2001:db8::7');
});
it('unmaps an ipv4-mapped peer address', () => {
const request = new Request('http://example.com');
expect(extractClientIp(request, {peerAddress: '::ffff:203.0.113.7'})).toBe('203.0.113.7');
});
it('returns null when trust is disabled and there is no usable peer address', () => {
const request = new Request('http://example.com', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(extractClientIp(request)).toBeNull();
expect(extractClientIp(request, {trustClientIpHeader: false, peerAddress: null})).toBeNull();
expect(extractClientIp(request, {trustClientIpHeader: false, peerAddress: 'not-an-ip'})).toBeNull();
});
it('ignores the peer address when the header is trusted', () => {
const request = new Request('http://example.com', {
headers: {'X-Forwarded-For': '198.51.100.4'},
});
expect(extractClientIp(request, {trustClientIpHeader: true, peerAddress: '10.0.0.2'})).toBe('198.51.100.4');
expect(
extractClientIp(new Request('http://example.com'), {trustClientIpHeader: true, peerAddress: '10.0.0.2'}),
).toBeNull();
});
it('unmaps an ipv4-mapped header value', () => {
const request = new Request('http://example.com', {
headers: {'X-Forwarded-For': '::ffff:198.51.100.4, 10.0.0.1'},
});
expect(extractClientIp(request, {trustClientIpHeader: true})).toBe('198.51.100.4');
});
it('returns null for missing or invalid headers', () => {
expect(extractClientIp(new Request('http://example.com'), {trustClientIpHeader: true})).toBeNull();
@@ -115,9 +140,16 @@ describe('requireClientIp', () => {
});
expect(requireClientIp(request, {trustClientIpHeader: true})).toBe('192.168.1.1');
});
it('returns the peer address when the header is not trusted', () => {
const request = new Request('http://example.com', {
headers: {'X-Forwarded-For': '192.168.1.1'},
});
expect(requireClientIp(request, {trustClientIpHeader: false, peerAddress: '203.0.113.7'})).toBe('203.0.113.7');
});
it('throws typed error when missing', () => {
const request = new Request('http://example.com');
expect(() => requireClientIp(request, {trustClientIpHeader: true})).toThrow(MissingClientIpError);
expect(() => requireClientIp(request, {trustClientIpHeader: true})).toThrow('Client IP header is required');
expect(() => requireClientIp(request, {trustClientIpHeader: true})).toThrow('Client IP is required');
expect(() => requireClientIp(request, {trustClientIpHeader: false})).toThrow(MissingClientIpError);
});
});
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
canonicalizeIpAddress,
getIpNetworkKey,
getSameIpDecisionKey,
getSubnet,
@@ -92,6 +93,21 @@ describe('parseIpAddress', () => {
});
});
describe('canonicalizeIpAddress', () => {
it('keeps ipv4 and plain ipv6 in their normalised form', () => {
expect(canonicalizeIpAddress(' 203.0.113.5 ')).toBe('203.0.113.5');
expect(canonicalizeIpAddress('[2001:DB8::1]')).toBe('2001:db8::1');
});
it('unmaps ipv4-mapped ipv6 in dotted and hex form', () => {
expect(canonicalizeIpAddress('::ffff:203.0.113.5')).toBe('203.0.113.5');
expect(canonicalizeIpAddress('::ffff:cb00:7105')).toBe('203.0.113.5');
});
it('returns null for anything that is not an address', () => {
expect(canonicalizeIpAddress('')).toBeNull();
expect(canonicalizeIpAddress('not-an-ip')).toBeNull();
});
});
describe('isValidIp', () => {
it('accepts valid values', () => {
expect(isValidIp('192.168.1.1')).toBe(true);