perf(api): resolve the client ip once per request (#2218)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-31 00:13:24 +02:00
1 parent 3eeba1da2b
commit b07e2c397c
11 files changed
+316 -38

No files matched your search

@@ -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<T> = Record<IpAddressFamily, Map<string, T>>;
@@ -371,10 +370,7 @@ class IpBanCache {
export const ipBanCache = new IpBanCache();
export const IpBanMiddleware = createMiddleware<HonoEnv>(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({
@@ -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<HonoEnv>): 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}`;
}
@@ -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<HonoEnv>(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);
@@ -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<HonoEnv>(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,
@@ -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});
}
@@ -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<HonoEnv>, 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<HonoEnv>(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);
@@ -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<string, string>) => Promise<Response>;
resolutions: Array<ClientIpResolution | undefined>;
errors: Array<unknown>;
}
function createPipeline(clientIpHeaderName = 'x-forwarded-for'): Pipeline {
const resolutions: Array<ClientIpResolution | undefined> = [];
const errors: Array<unknown> = [];
const app = new Hono<HonoEnv>();
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);
});
});
@@ -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', {
+2
View File
@@ -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;
@@ -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<HonoEnv>, 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<HonoEnv>): string | null {
return resolveClientIpWithOptions(ctx, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
clientIpHeaderName: Config.proxy.client_ip_header,
});
}
export function requireRequestClientIp(ctx: Context<HonoEnv>): string {
const ip = getRequestClientIp(ctx);
if (!ip) {
throw new MissingClientIpError();
}
return ip;
}
@@ -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<string, string>): CountingRequest {
const request = new Request('http://localhost/v1/messages', {headers});
const reads = new Map<string, number>();
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<string | null> = [];
const app = new Hono<HonoEnv>();
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<string | null> = [];
const app = new Hono<HonoEnv>();
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<string | null> = [];
const errors: Array<unknown> = [];
const app = new Hono<HonoEnv>();
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<string | null> = [];
const app = new Hono<HonoEnv>();
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<string | null> = [];
const app = new Hono<HonoEnv>();
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<string | null> = [];
const app = new Hono<HonoEnv>();
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']);
});
});