From b07e2c397cd92f5a83d3d364612a078bcbd58d1a Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 31 Aug 2026 00:13:24 +0200 Subject: [PATCH] perf(api): resolve the client ip once per request (#2218) --- .../src/api/middleware/IpBanMiddleware.ts | 8 +- .../src/api/middleware/RateLimitMiddleware.ts | 7 +- .../src/api/middleware/ServiceMiddleware.ts | 4 +- .../src/api/middleware/TorExitMiddleware.ts | 8 +- .../TrustedClientIpHeaderMiddleware.ts | 5 +- .../src/api/middleware/UserMiddleware.ts | 19 +-- .../tests/ClientIpResolution.test.ts | 111 +++++++++++++++ .../TrustedClientIpHeaderMiddleware.test.ts | 14 ++ fluxer_api/src/api/types/HonoEnv.ts | 2 + fluxer_api/src/api/utils/RequestClientIp.ts | 48 +++++++ .../api/utils/tests/RequestClientIp.test.ts | 128 ++++++++++++++++++ 11 files changed, 316 insertions(+), 38 deletions(-) create mode 100644 fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts create mode 100644 fluxer_api/src/api/utils/RequestClientIp.ts create mode 100644 fluxer_api/src/api/utils/tests/RequestClientIp.test.ts diff --git a/fluxer_api/src/api/middleware/IpBanMiddleware.ts b/fluxer_api/src/api/middleware/IpBanMiddleware.ts index d4c4fda4a..293a59a92 100644 --- a/fluxer_api/src/api/middleware/IpBanMiddleware.ts +++ b/fluxer_api/src/api/middleware/IpBanMiddleware.ts @@ -1,18 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError'; -import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {getSameIpDecisionKey, type IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress'; import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider'; import {createMiddleware} from 'hono/factory'; import {AdminRepository} from '../admin/AdminRepository'; import type {BannedIpEntry, BannedIpKind} from '../admin/IAdminRepository'; -import {Config} from '../Config'; import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan'; import {Logger} from '../Logger'; import {isIpBanExempt} from '../risk/IpBanExemptions'; import type {HonoEnv} from '../types/HonoEnv'; import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils'; +import {getRequestClientIp} from '../utils/RequestClientIp'; type FamilyMap = Record>; @@ -371,10 +370,7 @@ class IpBanCache { export const ipBanCache = new IpBanCache(); export const IpBanMiddleware = createMiddleware(async (ctx, next) => { - const clientIp = extractClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const clientIp = getRequestClientIp(ctx); const match = clientIp ? ipBanCache.getMatch(clientIp) : null; if (match) { throw new IpBannedError({ diff --git a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts index a59b68a09..8444920f5 100644 --- a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts +++ b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts @@ -3,7 +3,6 @@ import {createHash} from 'node:crypto'; import {UserFlags} from '@fluxer/constants/src/UserConstants'; import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError'; -import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; import type {BucketConfig, RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService'; import type {Context, MiddlewareHandler} from 'hono'; @@ -11,6 +10,7 @@ import {createMiddleware} from 'hono/factory'; import * as AuthSession from '../auth/AuthSession'; import {Config} from '../Config'; import type {HonoEnv} from '../types/HonoEnv'; +import {getRequestClientIp} from '../utils/RequestClientIp'; type AccountType = 'user' | 'bot' | 'webhook'; @@ -58,10 +58,7 @@ function getClientIdentifier(ctx: Context): string { } return `user:${user.id}:${tokenType}`; } - const ip = extractClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const ip = getRequestClientIp(ctx); if (!ip) return 'internal'; return `ip:${getSameIpDecisionKey(ip) ?? ip}`; } diff --git a/fluxer_api/src/api/middleware/ServiceMiddleware.ts b/fluxer_api/src/api/middleware/ServiceMiddleware.ts index b99331b9f..bb5d00aff 100644 --- a/fluxer_api/src/api/middleware/ServiceMiddleware.ts +++ b/fluxer_api/src/api/middleware/ServiceMiddleware.ts @@ -77,7 +77,7 @@ import {UserChannelRequestService} from '../user/services/UserChannelRequestServ import {UserContentRequestService} from '../user/services/UserContentRequestService'; import {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService'; import {UserService} from '../user/services/UserService'; -import {resolveRequestClientIp} from '../utils/IpUtils'; +import {getRequestClientIp} from '../utils/RequestClientIp'; import {VoiceService} from '../voice/VoiceService'; import {WebhookRequestService} from '../webhook/WebhookRequestService'; import {WebhookService} from '../webhook/WebhookService'; @@ -1050,7 +1050,7 @@ class RequestServices implements RequestScopedServices { export const ServiceMiddleware = createMiddleware(async (ctx, next) => { const apiContext = createApiContext({ requestId: ctx.get('requestId') ?? crypto.randomUUID(), - clientIp: resolveRequestClientIp(ctx.req.raw), + clientIp: getRequestClientIp(ctx), userAgent: ctx.req.header('user-agent') ?? null, }); ctx.set('apiContext', apiContext); diff --git a/fluxer_api/src/api/middleware/TorExitMiddleware.ts b/fluxer_api/src/api/middleware/TorExitMiddleware.ts index ba57416b5..dcce1406c 100644 --- a/fluxer_api/src/api/middleware/TorExitMiddleware.ts +++ b/fluxer_api/src/api/middleware/TorExitMiddleware.ts @@ -1,17 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError'; -import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {createMiddleware} from 'hono/factory'; -import {Config} from '../Config'; import type {HonoEnv} from '../types/HonoEnv'; +import {getRequestClientIp} from '../utils/RequestClientIp'; import {torExitListCache} from './TorExitListCache'; export const TorExitMiddleware = createMiddleware(async (ctx, next) => { - const clientIp = extractClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const clientIp = getRequestClientIp(ctx); if (clientIp && torExitListCache.isTorExit(clientIp)) { throw new IpBannedError({ ipAddress: clientIp, diff --git a/fluxer_api/src/api/middleware/TrustedClientIpHeaderMiddleware.ts b/fluxer_api/src/api/middleware/TrustedClientIpHeaderMiddleware.ts index cc643a29a..90b2032b2 100644 --- a/fluxer_api/src/api/middleware/TrustedClientIpHeaderMiddleware.ts +++ b/fluxer_api/src/api/middleware/TrustedClientIpHeaderMiddleware.ts @@ -2,10 +2,10 @@ import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes'; import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; -import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress'; import {createMiddleware} from 'hono/factory'; import type {ILogger} from '../ILogger'; import type {HonoEnv} from '../types/HonoEnv'; +import {resolveClientIpWithOptions} from '../utils/RequestClientIp'; import {stripApiPrefix} from '../utils/RequestPathUtils'; interface TrustedClientIpHeaderOptions { @@ -46,8 +46,7 @@ export function TrustedClientIpHeaderMiddleware({ await next(); return; } - const firstHop = clientIpHeaderValue.split(',')[0]?.trim() ?? clientIpHeaderValue; - if (!parseIpAddress(firstHop)) { + if (!resolveClientIpWithOptions(ctx, {trustClientIpHeader, clientIpHeaderName})) { logger.warn({path, clientIpHeaderName}, 'Rejected request with invalid client IP header'); throw new ForbiddenError({code: APIErrorCodes.FORBIDDEN}); } diff --git a/fluxer_api/src/api/middleware/UserMiddleware.ts b/fluxer_api/src/api/middleware/UserMiddleware.ts index 0814a6d27..23d21947a 100644 --- a/fluxer_api/src/api/middleware/UserMiddleware.ts +++ b/fluxer_api/src/api/middleware/UserMiddleware.ts @@ -1,13 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {extractClientIpDetails, requireClientIp} from '@fluxer/ip_utils/src/ClientIp'; import type {Context} from 'hono'; import {createMiddleware} from 'hono/factory'; import * as AuthSession from '../auth/AuthSession'; -import {Config} from '../Config'; import {Logger} from '../Logger'; import type {User} from '../models/User'; import type {HonoEnv} from '../types/HonoEnv'; +import {requireRequestClientIp} from '../utils/RequestClientIp'; import {stripApiPrefix} from '../utils/RequestPathUtils'; import {hashAuthToken, recordAbuseSignal} from './AbusiveIpAutoBanner'; @@ -61,10 +60,7 @@ function setUserInContext(ctx: Context, user: User, trackActivity: bool ctx.set('user', user); if (trackActivity) { const now = new Date(); - const ip = requireClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const ip = requireRequestClientIp(ctx); const kvActivityTracker = ctx.get('kvActivityTracker'); const userActivityBuffer = ctx.get('userActivityBuffer'); userActivityBuffer.recordActivity(user.id, now, ip); @@ -81,16 +77,7 @@ export const UserMiddleware = createMiddleware(async (ctx, next) => { } const rawAuthHeader = ctx.req.header('Authorization'); const parsed = parseAuthHeader(rawAuthHeader); - const extractedClientIp = extractClientIpDetails(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); - const resolvedClientIp = - extractedClientIp?.ip ?? - requireClientIp(ctx.req.raw, { - trustClientIpHeader: Config.proxy.trust_client_ip_header, - clientIpHeaderName: Config.proxy.client_ip_header, - }); + const resolvedClientIp = requireRequestClientIp(ctx); ctx.set('oauthBearerToken', undefined); ctx.set('oauthBearerApplicationId', undefined); ctx.set('oauthBearerAllowed', false); diff --git a/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts b/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts new file mode 100644 index 000000000..6c83d49e8 --- /dev/null +++ b/fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError'; +import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError'; +import {Hono} from 'hono'; +import {beforeEach, describe, expect, it} from 'vitest'; +import {NoopLogger} from '../../test/mocks/NoopLogger'; +import type {HonoEnv} from '../../types/HonoEnv'; +import type {ClientIpResolution} from '../../utils/RequestClientIp'; +import {IpBanMiddleware, ipBanCache} from '../IpBanMiddleware'; +import {torExitListCache} from '../TorExitListCache'; +import {TorExitMiddleware} from '../TorExitMiddleware'; +import {TrustedClientIpHeaderMiddleware} from '../TrustedClientIpHeaderMiddleware'; + +interface Pipeline { + request: (headers: Record) => Promise; + resolutions: Array; + errors: Array; +} + +function createPipeline(clientIpHeaderName = 'x-forwarded-for'): Pipeline { + const resolutions: Array = []; + const errors: Array = []; + const app = new Hono(); + app.use(IpBanMiddleware); + app.use(async (ctx, next) => { + resolutions.push(ctx.get('clientIpResolution')); + await next(); + }); + app.use( + TrustedClientIpHeaderMiddleware({ + enabled: true, + logger: new NoopLogger(), + trustClientIpHeader: true, + clientIpHeaderName, + }), + ); + app.use(TorExitMiddleware); + app.get('/v1/messages', (ctx) => { + resolutions.push(ctx.get('clientIpResolution')); + return ctx.text('ok'); + }); + app.onError((error) => { + errors.push(error); + return new Response('error', {status: 403}); + }); + return { + request: async (headers) => app.request('http://localhost/v1/messages', {headers}), + resolutions, + errors, + }; +} + +beforeEach(() => { + ipBanCache.resetCaches(); + torExitListCache.clearForTesting(); +}); + +describe('client ip resolution across the request pipeline', () => { + it('resolves once and shares that resolution with every later middleware', async () => { + const pipeline = createPipeline(); + const response = await pipeline.request({'x-forwarded-for': '203.0.113.10, 10.0.0.1'}); + expect(response.status).toBe(200); + expect(pipeline.resolutions).toHaveLength(2); + expect(pipeline.resolutions[0]?.ip).toBe('203.0.113.10'); + expect(pipeline.resolutions[1]).toBe(pipeline.resolutions[0]); + }); + it('still blocks a banned client ip', async () => { + ipBanCache.ban('203.0.113.20'); + const pipeline = createPipeline(); + const response = await pipeline.request({'x-forwarded-for': '203.0.113.20'}); + expect(response.status).toBe(403); + expect(pipeline.errors[0]).toBeInstanceOf(IpBannedError); + }); + it('still blocks a tor exit client ip', async () => { + torExitListCache.seedForTesting(['203.0.113.30']); + const pipeline = createPipeline(); + const response = await pipeline.request({'x-forwarded-for': '203.0.113.30'}); + expect(response.status).toBe(403); + expect(pipeline.errors[0]).toBeInstanceOf(IpBannedError); + }); + it('rejects a malformed client ip header after the ban check saw no address', async () => { + const pipeline = createPipeline(); + const response = await pipeline.request({'x-forwarded-for': 'not-an-ip'}); + expect(response.status).toBe(403); + expect(pipeline.resolutions[0]?.ip).toBe(null); + expect(pipeline.errors[0]).toBeInstanceOf(ForbiddenError); + expect(pipeline.errors[0]).not.toBeInstanceOf(IpBannedError); + }); + it('passes requests through when no client ip header is present', async () => { + const pipeline = createPipeline(); + const response = await pipeline.request({}); + expect(response.status).toBe(200); + expect(pipeline.resolutions[0]?.ip).toBe(null); + expect(pipeline.resolutions[1]).toBe(pipeline.resolutions[0]); + }); + it('keeps ban checks on the configured header when the trusted header check uses another one', async () => { + ipBanCache.ban('198.51.100.7'); + const pipeline = createPipeline('x-real-ip'); + const response = await pipeline.request({'x-forwarded-for': '203.0.113.10', 'x-real-ip': '198.51.100.7'}); + expect(response.status).toBe(200); + expect(pipeline.resolutions[0]?.ip).toBe('203.0.113.10'); + expect(pipeline.resolutions[1]?.ip).toBe('203.0.113.10'); + }); + it('rejects an invalid trusted header even when the configured header carries a valid address', async () => { + const pipeline = createPipeline('x-real-ip'); + const response = await pipeline.request({'x-forwarded-for': '203.0.113.10', 'x-real-ip': 'not-an-ip'}); + expect(response.status).toBe(403); + expect(pipeline.errors[0]).toBeInstanceOf(ForbiddenError); + }); +}); diff --git a/fluxer_api/src/api/middleware/tests/TrustedClientIpHeaderMiddleware.test.ts b/fluxer_api/src/api/middleware/tests/TrustedClientIpHeaderMiddleware.test.ts index c311aed10..2d40f7a6f 100644 --- a/fluxer_api/src/api/middleware/tests/TrustedClientIpHeaderMiddleware.test.ts +++ b/fluxer_api/src/api/middleware/tests/TrustedClientIpHeaderMiddleware.test.ts @@ -59,6 +59,20 @@ describe('TrustedClientIpHeaderMiddleware', () => { }); expect(response.status).toBe(403); }); + it('accepts requests when the IP header holds only whitespace (passthrough)', async () => { + const app = createApp(); + const response = await app.request('http://localhost/v1/messages', { + headers: {'x-real-ip': ' '}, + }); + expect(response.status).toBe(200); + }); + it('rejects x-forwarded-for with an empty first hop', async () => { + const app = createApp('x-forwarded-for'); + const response = await app.request('http://localhost/v1/messages', { + headers: {'x-forwarded-for': ', 10.0.0.1'}, + }); + expect(response.status).toBe(403); + }); it('accepts x-forwarded-for with multiple hops and a valid first hop', async () => { const app = createApp('x-forwarded-for'); const response = await app.request('http://localhost/v1/messages', { diff --git a/fluxer_api/src/api/types/HonoEnv.ts b/fluxer_api/src/api/types/HonoEnv.ts index 936a8c2c0..cdf5427e8 100644 --- a/fluxer_api/src/api/types/HonoEnv.ts +++ b/fluxer_api/src/api/types/HonoEnv.ts @@ -81,6 +81,7 @@ import type {UserContactChangeLogService} from '../user/services/UserContactChan import type {UserContentRequestService} from '../user/services/UserContentRequestService'; import type {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService'; import type {UserService} from '../user/services/UserService'; +import type {ClientIpResolution} from '../utils/RequestClientIp'; import type {SweegoWebhookService} from '../webhook/SweegoWebhookService'; import type {WebhookRequestService} from '../webhook/WebhookRequestService'; import type {WebhookService} from '../webhook/WebhookService'; @@ -91,6 +92,7 @@ export interface HonoEnv { apiContext: ApiContext; user: User; requestId?: string; + clientIpResolution?: ClientIpResolution; responseSchema: unknown; adminService: AdminService; adminArchiveService: AdminArchiveService; diff --git a/fluxer_api/src/api/utils/RequestClientIp.ts b/fluxer_api/src/api/utils/RequestClientIp.ts new file mode 100644 index 000000000..0fa871cdc --- /dev/null +++ b/fluxer_api/src/api/utils/RequestClientIp.ts @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {extractClientIp, MissingClientIpError, resolveClientIpHeaderName} from '@fluxer/ip_utils/src/ClientIp'; +import type {Context} from 'hono'; +import {Config} from '../Config'; +import type {HonoEnv} from '../types/HonoEnv'; + +export interface ClientIpResolution { + trustClientIpHeader: boolean; + clientIpHeaderName: string; + ip: string | null; +} + +interface ClientIpResolutionOptions { + trustClientIpHeader: boolean; + clientIpHeaderName: string; +} + +export function resolveClientIpWithOptions(ctx: Context, options: ClientIpResolutionOptions): string | null { + const clientIpHeaderName = resolveClientIpHeaderName(options.clientIpHeaderName); + const {trustClientIpHeader} = options; + const cached = ctx.get('clientIpResolution'); + if ( + cached && + cached.trustClientIpHeader === trustClientIpHeader && + cached.clientIpHeaderName === clientIpHeaderName + ) { + return cached.ip; + } + const ip = extractClientIp(ctx.req.raw, {trustClientIpHeader, clientIpHeaderName}); + ctx.set('clientIpResolution', {trustClientIpHeader, clientIpHeaderName, ip}); + return ip; +} + +export function getRequestClientIp(ctx: Context): string | null { + return resolveClientIpWithOptions(ctx, { + trustClientIpHeader: Config.proxy.trust_client_ip_header, + clientIpHeaderName: Config.proxy.client_ip_header, + }); +} + +export function requireRequestClientIp(ctx: Context): string { + const ip = getRequestClientIp(ctx); + if (!ip) { + throw new MissingClientIpError(); + } + return ip; +} diff --git a/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts b/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts new file mode 100644 index 000000000..a5a66f030 --- /dev/null +++ b/fluxer_api/src/api/utils/tests/RequestClientIp.test.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {MissingClientIpError} from '@fluxer/ip_utils/src/ClientIp'; +import {Hono} from 'hono'; +import {describe, expect, it} from 'vitest'; +import type {HonoEnv} from '../../types/HonoEnv'; +import {getRequestClientIp, requireRequestClientIp, resolveClientIpWithOptions} from '../RequestClientIp'; + +interface CountingRequest { + request: Request; + countReads: (name: string) => number; +} + +function createCountingRequest(headers: Record): CountingRequest { + const request = new Request('http://localhost/v1/messages', {headers}); + const reads = new Map(); + const get = request.headers.get.bind(request.headers); + Object.defineProperty(request.headers, 'get', { + configurable: true, + value: (name: string): string | null => { + const key = name.toLowerCase(); + reads.set(key, (reads.get(key) ?? 0) + 1); + return get(name); + }, + }); + return {request, countReads: (name) => reads.get(name.toLowerCase()) ?? 0}; +} + +describe('RequestClientIp', () => { + it('resolves the client ip once and hands the same value to every consumer', async () => { + const {request, countReads} = createCountingRequest({'x-forwarded-for': '203.0.113.10, 10.0.0.1'}); + const seen: Array = []; + const app = new Hono(); + app.use(async (ctx, next) => { + seen.push(getRequestClientIp(ctx)); + await next(); + }); + app.use(async (ctx, next) => { + seen.push(getRequestClientIp(ctx)); + await next(); + }); + app.get('/v1/messages', (ctx) => { + seen.push(requireRequestClientIp(ctx)); + return ctx.text('ok'); + }); + const response = await app.request(request); + expect(response.status).toBe(200); + expect(seen).toEqual(['203.0.113.10', '203.0.113.10', '203.0.113.10']); + expect(countReads('x-forwarded-for')).toBe(1); + }); + it('normalizes the resolved address the same way for every consumer', async () => { + const seen: Array = []; + const app = new Hono(); + app.use(async (ctx, next) => { + seen.push(getRequestClientIp(ctx)); + await next(); + }); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + return ctx.text('ok'); + }); + await app.request('http://localhost/v1/messages', {headers: {'x-forwarded-for': '2001:DB8::1'}}); + expect(seen).toEqual(['2001:db8::1', '2001:db8::1']); + }); + it('reports a missing client ip header exactly as before', async () => { + const {request, countReads} = createCountingRequest({}); + const seen: Array = []; + const errors: Array = []; + const app = new Hono(); + app.use(async (ctx, next) => { + seen.push(getRequestClientIp(ctx)); + await next(); + }); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + try { + requireRequestClientIp(ctx); + } catch (error) { + errors.push(error); + } + return ctx.text('ok'); + }); + await app.request(request); + expect(seen).toEqual([null, null]); + expect(errors[0]).toBeInstanceOf(MissingClientIpError); + expect(countReads('x-forwarded-for')).toBe(1); + }); + it('reports a malformed client ip header exactly as before', async () => { + const seen: Array = []; + const app = new Hono(); + app.use(async (ctx, next) => { + seen.push(getRequestClientIp(ctx)); + await next(); + }); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + return ctx.text('ok'); + }); + await app.request('http://localhost/v1/messages', {headers: {'x-forwarded-for': 'not-an-ip'}}); + expect(seen).toEqual([null, null]); + }); + it('never reuses a resolution made under a different header name', async () => { + const seen: Array = []; + const app = new Hono(); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + seen.push(resolveClientIpWithOptions(ctx, {trustClientIpHeader: true, clientIpHeaderName: 'x-real-ip'})); + seen.push(getRequestClientIp(ctx)); + return ctx.text('ok'); + }); + await app.request('http://localhost/v1/messages', { + headers: {'x-forwarded-for': '203.0.113.10', 'x-real-ip': '198.51.100.7'}, + }); + expect(seen).toEqual(['203.0.113.10', '198.51.100.7', '203.0.113.10']); + }); + it('never reuses a resolution made under a different trust setting', async () => { + const seen: Array = []; + const app = new Hono(); + app.get('/v1/messages', (ctx) => { + seen.push(getRequestClientIp(ctx)); + seen.push(resolveClientIpWithOptions(ctx, {trustClientIpHeader: false, clientIpHeaderName: 'x-forwarded-for'})); + seen.push(getRequestClientIp(ctx)); + return ctx.text('ok'); + }); + await app.request('http://localhost/v1/messages', {headers: {'x-forwarded-for': '203.0.113.10'}}); + expect(seen).toEqual(['203.0.113.10', null, '203.0.113.10']); + }); +});