fix(api): resolve client ip from peer when untrusted (#3420)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-12 15:34:36 +02:00
1 parent 17746fcdc4
commit 68cb2a4bbb
13 files changed
+208 -54

No files matched your search

@@ -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(
@@ -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<HonoEnv> {
function createProductionApp(trustClientIpHeader = true): Hono<HonoEnv> {
const routes = new Hono<HonoEnv>({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<HonoEnv>({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);
});
});
});
@@ -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<HonoEnv>): 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);
},
);
@@ -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<string, string>) => Promise<Response>;
request: (headers: Record<string, string>, peerAddress?: string) => Promise<Response>;
resolutions: Array<ClientIpResolution | undefined>;
}
function createHarness(path = 'http://localhost/v1/messages'): Harness {
const resolutions: Array<ClientIpResolution | undefined> = [];
const app = new Hono<HonoEnv>();
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({});
+19 -3
View File
@@ -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<Request, string>();
export const PeerAddressMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
const remoteAddress = (ctx.env as Partial<HttpBindings> | 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<HonoEnv>, options: ClientIpResolutionOptions): string | null {
const clientIpHeaderName = resolveClientIpHeaderName(options.clientIpHeaderName);
const {trustClientIpHeader} = options;
@@ -33,7 +49,7 @@ export function resolveClientIpWithOptions(ctx: Context<HonoEnv>, 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<HonoEnv>): string | null {
@@ -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<string, string>): 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<string | null> = [];
const app = new Hono<HonoEnv>();
@@ -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<string | null> = [];
const app = new Hono<HonoEnv>();
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']);
});
});