diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index fa4c88785..586344a58 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -157,8 +157,8 @@ MEILI_MASTER_KEY=CHANGE_ME #FLUXER_GEOIP_DB_PATH= # The client address. The edge sets X-Forwarded-For on every hop, so keep the -# trust on and the default header. Turning the trust off makes the api refuse -# every request outside its exempt routes with a 403. +# trust on and the default header. Turning the trust off makes the api ignore +# the header and use the connection's peer address, which here is the edge. #FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for #FLUXER_TRUST_CLIENT_IP_HEADER=true diff --git a/fluxer_api/pkgs/geoip/src/ResolveClientGeoip.ts b/fluxer_api/pkgs/geoip/src/ResolveClientGeoip.ts index 539230adc..cb0ee78e0 100644 --- a/fluxer_api/pkgs/geoip/src/ResolveClientGeoip.ts +++ b/fluxer_api/pkgs/geoip/src/ResolveClientGeoip.ts @@ -2,18 +2,11 @@ import {AGE_BLOCKED_GEOS, AGE_RESTRICTED_GEOS} from '@fluxer/instance_bootstrap/src/AgeGeos'; import type {GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types'; -import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {lookupGeoipByIp} from '@pkgs/geoip/src/GeoipLookup'; -interface ResolveClientGeoipOptions { - maxmindDbPath: string | undefined; - trustClientIpHeader: boolean; - clientIpHeaderName: string; -} - export async function resolveClientGeoip( - req: Request, - options: ResolveClientGeoipOptions, + ip: string | null, + maxmindDbPath: string | undefined, ): Promise { const response: GeolocationResponse = { countryCode: null, @@ -23,14 +16,10 @@ export async function resolveClientGeoip( ageRestrictedGeos: AGE_RESTRICTED_GEOS, ageBlockedGeos: AGE_BLOCKED_GEOS, }; - const ip = extractClientIp(req, { - trustClientIpHeader: options.trustClientIpHeader, - clientIpHeaderName: options.clientIpHeaderName, - }); if (!ip) { return response; } - const geoip = await lookupGeoipByIp(ip, options.maxmindDbPath); + const geoip = await lookupGeoipByIp(ip, maxmindDbPath); response.countryCode = geoip.countryCode; response.regionCode = geoip.regionCode ?? null; response.latitude = geoip.latitude != null ? geoip.latitude.toString() : null; diff --git a/fluxer_api/src/api/app/MiddlewarePipeline.ts b/fluxer_api/src/api/app/MiddlewarePipeline.ts index 768fe0f2b..42a890b81 100644 --- a/fluxer_api/src/api/app/MiddlewarePipeline.ts +++ b/fluxer_api/src/api/app/MiddlewarePipeline.ts @@ -15,6 +15,7 @@ import {ServiceMiddleware} from '@app/api/middleware/ServiceMiddleware'; import {TrustedClientIpHeaderMiddleware} from '@app/api/middleware/TrustedClientIpHeaderMiddleware'; import {UserMiddleware} from '@app/api/middleware/UserMiddleware'; import type {HonoApp} from '@app/api/types/HonoEnv'; +import {PeerAddressMiddleware} from '@app/api/utils/RequestClientIp'; import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers'; import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError'; import {cors} from '@fluxer/hono/src/middleware/Cors'; @@ -35,6 +36,7 @@ interface MiddlewarePipelineOptions { export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void { const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options; const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName); + routes.use(PeerAddressMiddleware); routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'})); routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'})); routes.use( diff --git a/fluxer_api/src/api/app/tests/ProductionClientIpPipeline.test.ts b/fluxer_api/src/api/app/tests/ProductionClientIpPipeline.test.ts index 9450e0586..e10d16793 100644 --- a/fluxer_api/src/api/app/tests/ProductionClientIpPipeline.test.ts +++ b/fluxer_api/src/api/app/tests/ProductionClientIpPipeline.test.ts @@ -2,31 +2,34 @@ import {configureMiddleware} from '@app/api/app/MiddlewarePipeline'; import {Config} from '@app/api/Config'; +import {InstanceController} from '@app/api/instance/InstanceController'; import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry'; import {NoopLogger} from '@app/api/test/mocks/NoopLogger'; import {NoopWorkerService} from '@app/api/test/NoopWorkerService'; import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {getRequestClientIp} from '@app/api/utils/RequestClientIp'; import {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; import {Hono} from 'hono'; import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest'; const CLIENT_IP_HEADER_NAME = 'x-real-ip'; -function createProductionApp(): Hono { +function createProductionApp(trustClientIpHeader = true): Hono { const routes = new Hono({strict: true}); configureMiddleware(routes, { logger: new NoopLogger(), nodeEnv: 'production', corsOrigins: ['https://web.fluxer.app'], - trustClientIpHeader: true, + trustClientIpHeader, clientIpHeaderName: CLIENT_IP_HEADER_NAME, maxInflightRequests: 100, }); routes.onError(AppErrorHandler); routes.notFound(AppNotFoundHandler); + InstanceController(routes); routes.post('/internal/rpc', (ctx) => ctx.json({ok: true})); routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []})); - routes.get('/users/@me', (ctx) => ctx.json({ok: true})); + routes.get('/users/@me', (ctx) => ctx.json({ok: true, ip: getRequestClientIp(ctx)})); const app = new Hono({strict: true}); app.route('/v1', routes); app.route('/', routes); @@ -91,4 +94,52 @@ describe('client ip requirements across the production middleware pipeline', () expect(response.status).toBe(403); expect(await response.json()).toMatchObject({code: 'FORBIDDEN'}); }); + + it('resolves the custom client ip header while it is trusted', async () => { + const app = createProductionApp(); + const response = await app.request( + 'http://api:8080/v1/users/@me', + {headers: {[CLIENT_IP_HEADER_NAME]: '203.0.113.10', 'x-forwarded-for': '198.51.100.1'}}, + {incoming: {socket: {remoteAddress: '10.0.0.5'}}}, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ip: '203.0.113.10'}); + }); + + describe('with the client ip header untrusted', () => { + beforeEach(() => { + Config.proxy.trust_client_ip_header = false; + }); + + it('keys the request on the peer address and ignores a spoofed header', async () => { + const app = createProductionApp(false); + const response = await app.request( + 'http://api:8080/v1/users/@me', + {headers: {[CLIENT_IP_HEADER_NAME]: '203.0.113.10', 'x-forwarded-for': '203.0.113.11'}}, + {incoming: {socket: {remoteAddress: '::ffff:198.51.100.20'}}}, + ); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ip: '198.51.100.20'}); + }); + + it('serves the discovery document', async () => { + const app = createProductionApp(false); + const response = await app.request( + 'http://api:8080/.well-known/fluxer', + {headers: {'x-forwarded-for': '203.0.113.10'}}, + {incoming: {socket: {remoteAddress: '198.51.100.20'}}}, + ); + expect(response.status).toBe(200); + }); + + it('serves the internal rpc route', async () => { + const app = createProductionApp(false); + const response = await app.request('http://api:8080/internal/rpc', { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: '{}', + }); + expect(response.status).toBe(200); + }); + }); }); diff --git a/fluxer_api/src/api/geolocation/GeolocationController.ts b/fluxer_api/src/api/geolocation/GeolocationController.ts index 9532541df..afdfa897b 100644 --- a/fluxer_api/src/api/geolocation/GeolocationController.ts +++ b/fluxer_api/src/api/geolocation/GeolocationController.ts @@ -5,6 +5,7 @@ import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware'; import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoEnv} from '@app/api/types/HonoEnv'; +import {getRequestClientIp} from '@app/api/utils/RequestClientIp'; import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas'; import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip'; import type {Hono} from 'hono'; @@ -27,11 +28,7 @@ export function GeolocationController(app: Hono): void { ctx.header('Access-Control-Allow-Origin', '*'); ctx.header('Access-Control-Allow-Headers', 'Content-Type'); ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS'); - const response = await resolveClientGeoip(ctx.req.raw, { - maxmindDbPath: Config.geoip.maxmindDbPath, - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const response = await resolveClientGeoip(getRequestClientIp(ctx), Config.geoip.maxmindDbPath); return ctx.json(response); }, ); diff --git a/fluxer_api/src/api/middleware/tests/RequireClientIpMiddleware.test.ts b/fluxer_api/src/api/middleware/tests/RequireClientIpMiddleware.test.ts index cfd232ac1..ebc65ebf4 100644 --- a/fluxer_api/src/api/middleware/tests/RequireClientIpMiddleware.test.ts +++ b/fluxer_api/src/api/middleware/tests/RequireClientIpMiddleware.test.ts @@ -3,19 +3,20 @@ import {Config} from '@app/api/Config'; import {RequireClientIpMiddleware} from '@app/api/middleware/RequireClientIpMiddleware'; import type {HonoEnv} from '@app/api/types/HonoEnv'; -import type {ClientIpResolution} from '@app/api/utils/RequestClientIp'; +import {type ClientIpResolution, PeerAddressMiddleware} from '@app/api/utils/RequestClientIp'; import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; import {Hono} from 'hono'; import {afterEach, beforeEach, describe, expect, it} from 'vitest'; interface Harness { - request: (headers: Record) => Promise; + request: (headers: Record, peerAddress?: string) => Promise; resolutions: Array; } function createHarness(path = 'http://localhost/v1/messages'): Harness { const resolutions: Array = []; const app = new Hono(); + app.use(PeerAddressMiddleware); app.use(RequireClientIpMiddleware()); app.get('/v1/messages', (ctx) => { resolutions.push(ctx.get('clientIpResolution')); @@ -25,7 +26,8 @@ function createHarness(path = 'http://localhost/v1/messages'): Harness { app.get('/internal/rpc', (ctx) => ctx.text('OK')); app.onError(AppErrorHandler); return { - request: async (headers) => app.request(path, {headers}), + request: async (headers, peerAddress) => + app.request(path, {headers}, peerAddress ? {incoming: {socket: {remoteAddress: peerAddress}}} : undefined), resolutions, }; } @@ -53,7 +55,15 @@ describe('RequireClientIpMiddleware', () => { expect(await response.json()).toMatchObject({code: 'FORBIDDEN'}); }); - it('rejects a request when the header is not trusted with 403', async () => { + it('passes an untrusted request on its peer address and ignores the header', async () => { + Config.proxy.trust_client_ip_header = false; + const harness = createHarness(); + const response = await harness.request({'x-forwarded-for': '203.0.113.10'}, '::ffff:198.51.100.20'); + expect(response.status).toBe(200); + expect(harness.resolutions[0]?.ip).toBe('198.51.100.20'); + }); + + it('rejects an untrusted request with no peer address with 403', async () => { Config.proxy.trust_client_ip_header = false; const harness = createHarness(); const response = await harness.request({'x-forwarded-for': '203.0.113.10'}); @@ -61,6 +71,12 @@ describe('RequireClientIpMiddleware', () => { expect(await response.json()).toMatchObject({code: 'FORBIDDEN'}); }); + it('ignores the peer address while the header is trusted', async () => { + const harness = createHarness(); + const response = await harness.request({}, '198.51.100.20'); + expect(response.status).toBe(403); + }); + it('rejects a request with no client ip header with 403 FORBIDDEN', async () => { const harness = createHarness(); const response = await harness.request({}); diff --git a/fluxer_api/src/api/utils/RequestClientIp.ts b/fluxer_api/src/api/utils/RequestClientIp.ts index 572cbb1d5..afe97fce4 100644 --- a/fluxer_api/src/api/utils/RequestClientIp.ts +++ b/fluxer_api/src/api/utils/RequestClientIp.ts @@ -9,7 +9,9 @@ import { requireClientIp, resolveClientIpHeaderName, } from '@fluxer/ip_utils/src/ClientIp'; +import type {HttpBindings} from '@hono/node-server'; import type {Context} from 'hono'; +import {createMiddleware} from 'hono/factory'; export interface ClientIpResolution { trustClientIpHeader: boolean; @@ -22,6 +24,20 @@ interface ClientIpResolutionOptions { clientIpHeaderName: string; } +const peerAddresses = new WeakMap(); + +export const PeerAddressMiddleware = createMiddleware(async (ctx, next) => { + const remoteAddress = (ctx.env as Partial | undefined)?.incoming?.socket?.remoteAddress; + if (remoteAddress) { + peerAddresses.set(ctx.req.raw, remoteAddress); + } + await next(); +}); + +function withPeerAddress(request: Request, options: ClientIpResolutionOptions) { + return {...options, peerAddress: peerAddresses.get(request) ?? null}; +} + export function resolveClientIpWithOptions(ctx: Context, options: ClientIpResolutionOptions): string | null { const clientIpHeaderName = resolveClientIpHeaderName(options.clientIpHeaderName); const {trustClientIpHeader} = options; @@ -33,7 +49,7 @@ export function resolveClientIpWithOptions(ctx: Context, options: Clien ) { return cached.ip; } - const ip = extractClientIp(ctx.req.raw, {trustClientIpHeader, clientIpHeaderName}); + const ip = extractClientIp(ctx.req.raw, withPeerAddress(ctx.req.raw, {trustClientIpHeader, clientIpHeaderName})); ctx.set('clientIpResolution', {trustClientIpHeader, clientIpHeaderName, ip}); return ip; } @@ -43,11 +59,11 @@ function proxyClientIpOptions(proxy: APIConfig['proxy']): ClientIpResolutionOpti } export function extractConfiguredClientIp(request: Request): string | null { - return extractClientIp(request, proxyClientIpOptions(Config.proxy)); + return extractClientIp(request, withPeerAddress(request, proxyClientIpOptions(Config.proxy))); } export function requireConfiguredClientIp(request: Request, proxy: APIConfig['proxy']): string { - return requireClientIp(request, proxyClientIpOptions(proxy)); + return requireClientIp(request, withPeerAddress(request, proxyClientIpOptions(proxy))); } export function getRequestClientIp(ctx: Context): string | null { diff --git a/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts b/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts index ca76377f2..970c765a1 100644 --- a/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts +++ b/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts @@ -1,11 +1,19 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +import {Config} from '@app/api/Config'; import type {HonoEnv} from '@app/api/types/HonoEnv'; -import {getRequestClientIp, requireRequestClientIp, resolveClientIpWithOptions} from '@app/api/utils/RequestClientIp'; +import { + extractConfiguredClientIp, + getRequestClientIp, + PeerAddressMiddleware, + requireConfiguredClientIp, + requireRequestClientIp, + resolveClientIpWithOptions, +} from '@app/api/utils/RequestClientIp'; import {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers'; import {MissingClientIpError} from '@fluxer/ip_utils/src/ClientIp'; import {Hono} from 'hono'; -import {describe, expect, it} from 'vitest'; +import {afterEach, describe, expect, it} from 'vitest'; interface CountingRequest { request: Request; @@ -28,6 +36,12 @@ function createCountingRequest(headers: Record): CountingRequest } describe('RequestClientIp', () => { + const previousTrustClientIpHeader = Config.proxy.trust_client_ip_header; + + afterEach(() => { + Config.proxy.trust_client_ip_header = previousTrustClientIpHeader; + }); + it('normalizes the resolved address the same way for every consumer', async () => { const seen: Array = []; const app = new Hono(); @@ -102,4 +116,22 @@ describe('RequestClientIp', () => { expect(response.status).toBe(403); expect(await response.json()).toMatchObject({code: 'FORBIDDEN'}); }); + it('resolves the peer address for request only callers when the header is untrusted', async () => { + Config.proxy.trust_client_ip_header = false; + const seen: Array = []; + const app = new Hono(); + app.use(PeerAddressMiddleware); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + seen.push(extractConfiguredClientIp(ctx.req.raw)); + seen.push(requireConfiguredClientIp(ctx.req.raw, Config.proxy)); + return ctx.text('ok'); + }); + await app.request( + 'http://localhost/v1/messages', + {headers: {'x-forwarded-for': '203.0.113.10'}}, + {incoming: {socket: {remoteAddress: '::ffff:198.51.100.20'}}}, + ); + expect(seen).toEqual(['198.51.100.20', '198.51.100.20', '198.51.100.20']); + }); }); diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 2665be3d5..dde6b8baf 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -332,7 +332,7 @@ All are optional. #### `FLUXER_TRUST_CLIENT_IP_HEADER` -Default `false`. Whether the client-IP header is trusted. Compose forwards it from `.env` with a default of `true`, because the edge sets the header on every upstream hop. The API has no peer-address fallback, so `false` on `api` makes every non-exempt request a 403. +Default `false`. Whether the client-IP header is trusted. Compose forwards it from `.env` with a default of `true`, because the edge sets the header on every upstream hop. With `false`, `api` ignores the header and uses the address of the peer that opened the connection. That suits an `api` that clients reach directly. Behind any proxy it makes every request look as though it came from the proxy. #### `FLUXER_CLIENT_IP_HEADER_NAME` @@ -342,7 +342,7 @@ Default `x-forwarded-for`. Which header has the client IP. Read by `api`, `worke Default empty. Addresses and CIDR ranges exempt from IP bans. Comma separated. A bare IPv4 address exempts that address, a bare IPv6 address exempts its /64, and a CIDR range such as `2001:db8:1200::/56` exempts every address in it. Every entry must parse as an IP or a CIDR range or the API fails at boot. Compose forwards it from `.env`. -The API returns 403 for any request whose client-IP header is missing, empty, or not a parsable address, and for every request while `FLUXER_TRUST_CLIENT_IP_HEADER` is `false`. The exceptions are `/_health`, `/webhooks/livekit`, `/test`, and the Bluesky client metadata and JWKS routes. The edge sets the header on every upstream hop, so a missing or unparsable header happens only in a layout that puts something other than the edge directly in front of `api`. A proxy in front of the edge that never sets the header passes the check, and every request then looks as though it came from the proxy. +While `FLUXER_TRUST_CLIENT_IP_HEADER` is `true`, the API returns 403 for any request whose client-IP header is missing, empty, or not a parsable address. The exceptions are `/_health`, `/webhooks/livekit`, `/test`, and the Bluesky client metadata and JWKS routes. The edge sets the header on every upstream hop, so a missing or unparsable header happens only in a layout that puts something other than the edge directly in front of `api`. A proxy in front of the edge that never sets the header passes the check, and every request then looks as though it came from the proxy. #### `FLUXER_API_TRUSTED_CALLERS` diff --git a/packages/ip_utils/src/ClientIp.ts b/packages/ip_utils/src/ClientIp.ts index 6a3126232..89939264d 100644 --- a/packages/ip_utils/src/ClientIp.ts +++ b/packages/ip_utils/src/ClientIp.ts @@ -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 { diff --git a/packages/ip_utils/src/IpAddress.ts b/packages/ip_utils/src/IpAddress.ts index 57f8f5578..91e7c74e7 100644 --- a/packages/ip_utils/src/IpAddress.ts +++ b/packages/ip_utils/src/IpAddress.ts @@ -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; } diff --git a/packages/ip_utils/src/__tests__/ClientIp.test.ts b/packages/ip_utils/src/__tests__/ClientIp.test.ts index 6757aed8a..6ea5e5004 100644 --- a/packages/ip_utils/src/__tests__/ClientIp.test.ts +++ b/packages/ip_utils/src/__tests__/ClientIp.test.ts @@ -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); }); }); diff --git a/packages/ip_utils/src/__tests__/IpAddress.test.ts b/packages/ip_utils/src/__tests__/IpAddress.test.ts index 4f4e60c8b..ec09a3752 100644 --- a/packages/ip_utils/src/__tests__/IpAddress.test.ts +++ b/packages/ip_utils/src/__tests__/IpAddress.test.ts @@ -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);