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', {