mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): resolve client ip from peer when untrusted (#3420)
This commit is contained in:
1 parent
17746fcdc4
commit
68cb2a4bbb
13 files changed
+208
-54
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user