mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): resolve client ip from peer when untrusted (#3420)
This commit is contained in:
1 parent
17746fcdc4
commit
68cb2a4bbb
13 files changed
+208
-54
No files matched your search
@@ -157,8 +157,8 @@ MEILI_MASTER_KEY=CHANGE_ME
|
|||||||
#FLUXER_GEOIP_DB_PATH=
|
#FLUXER_GEOIP_DB_PATH=
|
||||||
|
|
||||||
# The client address. The edge sets X-Forwarded-For on every hop, so keep the
|
# 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
|
# trust on and the default header. Turning the trust off makes the api ignore
|
||||||
# every request outside its exempt routes with a 403.
|
# the header and use the connection's peer address, which here is the edge.
|
||||||
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,11 @@
|
|||||||
|
|
||||||
import {AGE_BLOCKED_GEOS, AGE_RESTRICTED_GEOS} from '@fluxer/instance_bootstrap/src/AgeGeos';
|
import {AGE_BLOCKED_GEOS, AGE_RESTRICTED_GEOS} from '@fluxer/instance_bootstrap/src/AgeGeos';
|
||||||
import type {GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types';
|
import type {GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types';
|
||||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
|
||||||
import {lookupGeoipByIp} from '@pkgs/geoip/src/GeoipLookup';
|
import {lookupGeoipByIp} from '@pkgs/geoip/src/GeoipLookup';
|
||||||
|
|
||||||
interface ResolveClientGeoipOptions {
|
|
||||||
maxmindDbPath: string | undefined;
|
|
||||||
trustClientIpHeader: boolean;
|
|
||||||
clientIpHeaderName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveClientGeoip(
|
export async function resolveClientGeoip(
|
||||||
req: Request,
|
ip: string | null,
|
||||||
options: ResolveClientGeoipOptions,
|
maxmindDbPath: string | undefined,
|
||||||
): Promise<GeolocationResponse> {
|
): Promise<GeolocationResponse> {
|
||||||
const response: GeolocationResponse = {
|
const response: GeolocationResponse = {
|
||||||
countryCode: null,
|
countryCode: null,
|
||||||
@@ -23,14 +16,10 @@ export async function resolveClientGeoip(
|
|||||||
ageRestrictedGeos: AGE_RESTRICTED_GEOS,
|
ageRestrictedGeos: AGE_RESTRICTED_GEOS,
|
||||||
ageBlockedGeos: AGE_BLOCKED_GEOS,
|
ageBlockedGeos: AGE_BLOCKED_GEOS,
|
||||||
};
|
};
|
||||||
const ip = extractClientIp(req, {
|
|
||||||
trustClientIpHeader: options.trustClientIpHeader,
|
|
||||||
clientIpHeaderName: options.clientIpHeaderName,
|
|
||||||
});
|
|
||||||
if (!ip) {
|
if (!ip) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
const geoip = await lookupGeoipByIp(ip, options.maxmindDbPath);
|
const geoip = await lookupGeoipByIp(ip, maxmindDbPath);
|
||||||
response.countryCode = geoip.countryCode;
|
response.countryCode = geoip.countryCode;
|
||||||
response.regionCode = geoip.regionCode ?? null;
|
response.regionCode = geoip.regionCode ?? null;
|
||||||
response.latitude = geoip.latitude != null ? geoip.latitude.toString() : null;
|
response.latitude = geoip.latitude != null ? geoip.latitude.toString() : null;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {ServiceMiddleware} from '@app/api/middleware/ServiceMiddleware';
|
|||||||
import {TrustedClientIpHeaderMiddleware} from '@app/api/middleware/TrustedClientIpHeaderMiddleware';
|
import {TrustedClientIpHeaderMiddleware} from '@app/api/middleware/TrustedClientIpHeaderMiddleware';
|
||||||
import {UserMiddleware} from '@app/api/middleware/UserMiddleware';
|
import {UserMiddleware} from '@app/api/middleware/UserMiddleware';
|
||||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
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 {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||||
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
import {InvalidApiOriginError} from '@fluxer/errors/src/domains/core/InvalidApiOriginError';
|
||||||
import {cors} from '@fluxer/hono/src/middleware/Cors';
|
import {cors} from '@fluxer/hono/src/middleware/Cors';
|
||||||
@@ -35,6 +36,7 @@ interface MiddlewarePipelineOptions {
|
|||||||
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
export function configureMiddleware(routes: HonoApp, options: MiddlewarePipelineOptions): void {
|
||||||
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
const {logger, nodeEnv, corsOrigins, trustClientIpHeader, clientIpHeaderName, maxInflightRequests} = options;
|
||||||
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
const resolvedHeader = resolveClientIpHeaderName(clientIpHeaderName);
|
||||||
|
routes.use(PeerAddressMiddleware);
|
||||||
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
routes.use('/webhooks/:webhook_id/:token', cors({origins: '*'}));
|
||||||
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
routes.use('/webhooks/:webhook_id/:token/messages/:message_id', cors({origins: '*'}));
|
||||||
routes.use(
|
routes.use(
|
||||||
|
|||||||
@@ -2,31 +2,34 @@
|
|||||||
|
|
||||||
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
import {configureMiddleware} from '@app/api/app/MiddlewarePipeline';
|
||||||
import {Config} from '@app/api/Config';
|
import {Config} from '@app/api/Config';
|
||||||
|
import {InstanceController} from '@app/api/instance/InstanceController';
|
||||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||||
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||||
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
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 {AppErrorHandler, AppNotFoundHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||||
import {Hono} from 'hono';
|
import {Hono} from 'hono';
|
||||||
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
import {afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
const CLIENT_IP_HEADER_NAME = 'x-real-ip';
|
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});
|
const routes = new Hono<HonoEnv>({strict: true});
|
||||||
configureMiddleware(routes, {
|
configureMiddleware(routes, {
|
||||||
logger: new NoopLogger(),
|
logger: new NoopLogger(),
|
||||||
nodeEnv: 'production',
|
nodeEnv: 'production',
|
||||||
corsOrigins: ['https://web.fluxer.app'],
|
corsOrigins: ['https://web.fluxer.app'],
|
||||||
trustClientIpHeader: true,
|
trustClientIpHeader,
|
||||||
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
|
clientIpHeaderName: CLIENT_IP_HEADER_NAME,
|
||||||
maxInflightRequests: 100,
|
maxInflightRequests: 100,
|
||||||
});
|
});
|
||||||
routes.onError(AppErrorHandler);
|
routes.onError(AppErrorHandler);
|
||||||
routes.notFound(AppNotFoundHandler);
|
routes.notFound(AppNotFoundHandler);
|
||||||
|
InstanceController(routes);
|
||||||
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
|
routes.post('/internal/rpc', (ctx) => ctx.json({ok: true}));
|
||||||
routes.get('/connections/bluesky/jwks.json', (ctx) => ctx.json({keys: []}));
|
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});
|
const app = new Hono<HonoEnv>({strict: true});
|
||||||
app.route('/v1', routes);
|
app.route('/v1', routes);
|
||||||
app.route('/', routes);
|
app.route('/', routes);
|
||||||
@@ -91,4 +94,52 @@ describe('client ip requirements across the production middleware pipeline', ()
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
|
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 {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
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 {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
|
||||||
import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip';
|
import {resolveClientGeoip} from '@pkgs/geoip/src/ResolveClientGeoip';
|
||||||
import type {Hono} from 'hono';
|
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-Origin', '*');
|
||||||
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
ctx.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
ctx.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||||
const response = await resolveClientGeoip(ctx.req.raw, {
|
const response = await resolveClientGeoip(getRequestClientIp(ctx), Config.geoip.maxmindDbPath);
|
||||||
maxmindDbPath: Config.geoip.maxmindDbPath,
|
|
||||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
|
||||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
|
||||||
});
|
|
||||||
return ctx.json(response);
|
return ctx.json(response);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,19 +3,20 @@
|
|||||||
import {Config} from '@app/api/Config';
|
import {Config} from '@app/api/Config';
|
||||||
import {RequireClientIpMiddleware} from '@app/api/middleware/RequireClientIpMiddleware';
|
import {RequireClientIpMiddleware} from '@app/api/middleware/RequireClientIpMiddleware';
|
||||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
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 {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||||
import {Hono} from 'hono';
|
import {Hono} from 'hono';
|
||||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
request: (headers: Record<string, string>) => Promise<Response>;
|
request: (headers: Record<string, string>, peerAddress?: string) => Promise<Response>;
|
||||||
resolutions: Array<ClientIpResolution | undefined>;
|
resolutions: Array<ClientIpResolution | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness(path = 'http://localhost/v1/messages'): Harness {
|
function createHarness(path = 'http://localhost/v1/messages'): Harness {
|
||||||
const resolutions: Array<ClientIpResolution | undefined> = [];
|
const resolutions: Array<ClientIpResolution | undefined> = [];
|
||||||
const app = new Hono<HonoEnv>();
|
const app = new Hono<HonoEnv>();
|
||||||
|
app.use(PeerAddressMiddleware);
|
||||||
app.use(RequireClientIpMiddleware());
|
app.use(RequireClientIpMiddleware());
|
||||||
app.get('/v1/messages', (ctx) => {
|
app.get('/v1/messages', (ctx) => {
|
||||||
resolutions.push(ctx.get('clientIpResolution'));
|
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.get('/internal/rpc', (ctx) => ctx.text('OK'));
|
||||||
app.onError(AppErrorHandler);
|
app.onError(AppErrorHandler);
|
||||||
return {
|
return {
|
||||||
request: async (headers) => app.request(path, {headers}),
|
request: async (headers, peerAddress) =>
|
||||||
|
app.request(path, {headers}, peerAddress ? {incoming: {socket: {remoteAddress: peerAddress}}} : undefined),
|
||||||
resolutions,
|
resolutions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,15 @@ describe('RequireClientIpMiddleware', () => {
|
|||||||
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
|
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;
|
Config.proxy.trust_client_ip_header = false;
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const response = await harness.request({'x-forwarded-for': '203.0.113.10'});
|
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'});
|
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 () => {
|
it('rejects a request with no client ip header with 403 FORBIDDEN', async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const response = await harness.request({});
|
const response = await harness.request({});
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
requireClientIp,
|
requireClientIp,
|
||||||
resolveClientIpHeaderName,
|
resolveClientIpHeaderName,
|
||||||
} from '@fluxer/ip_utils/src/ClientIp';
|
} from '@fluxer/ip_utils/src/ClientIp';
|
||||||
|
import type {HttpBindings} from '@hono/node-server';
|
||||||
import type {Context} from 'hono';
|
import type {Context} from 'hono';
|
||||||
|
import {createMiddleware} from 'hono/factory';
|
||||||
|
|
||||||
export interface ClientIpResolution {
|
export interface ClientIpResolution {
|
||||||
trustClientIpHeader: boolean;
|
trustClientIpHeader: boolean;
|
||||||
@@ -22,6 +24,20 @@ interface ClientIpResolutionOptions {
|
|||||||
clientIpHeaderName: string;
|
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 {
|
export function resolveClientIpWithOptions(ctx: Context<HonoEnv>, options: ClientIpResolutionOptions): string | null {
|
||||||
const clientIpHeaderName = resolveClientIpHeaderName(options.clientIpHeaderName);
|
const clientIpHeaderName = resolveClientIpHeaderName(options.clientIpHeaderName);
|
||||||
const {trustClientIpHeader} = options;
|
const {trustClientIpHeader} = options;
|
||||||
@@ -33,7 +49,7 @@ export function resolveClientIpWithOptions(ctx: Context<HonoEnv>, options: Clien
|
|||||||
) {
|
) {
|
||||||
return cached.ip;
|
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});
|
ctx.set('clientIpResolution', {trustClientIpHeader, clientIpHeaderName, ip});
|
||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
@@ -43,11 +59,11 @@ function proxyClientIpOptions(proxy: APIConfig['proxy']): ClientIpResolutionOpti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function extractConfiguredClientIp(request: Request): string | null {
|
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 {
|
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 {
|
export function getRequestClientIp(ctx: Context<HonoEnv>): string | null {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
import {Config} from '@app/api/Config';
|
||||||
import type {HonoEnv} from '@app/api/types/HonoEnv';
|
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 {AppErrorHandler} from '@fluxer/errors/src/domains/core/ErrorHandlers';
|
||||||
import {MissingClientIpError} from '@fluxer/ip_utils/src/ClientIp';
|
import {MissingClientIpError} from '@fluxer/ip_utils/src/ClientIp';
|
||||||
import {Hono} from 'hono';
|
import {Hono} from 'hono';
|
||||||
import {describe, expect, it} from 'vitest';
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
|
||||||
interface CountingRequest {
|
interface CountingRequest {
|
||||||
request: Request;
|
request: Request;
|
||||||
@@ -28,6 +36,12 @@ function createCountingRequest(headers: Record<string, string>): CountingRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('RequestClientIp', () => {
|
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 () => {
|
it('normalizes the resolved address the same way for every consumer', async () => {
|
||||||
const seen: Array<string | null> = [];
|
const seen: Array<string | null> = [];
|
||||||
const app = new Hono<HonoEnv>();
|
const app = new Hono<HonoEnv>();
|
||||||
@@ -102,4 +116,22 @@ describe('RequestClientIp', () => {
|
|||||||
expect(response.status).toBe(403);
|
expect(response.status).toBe(403);
|
||||||
expect(await response.json()).toMatchObject({code: 'FORBIDDEN'});
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -332,7 +332,7 @@ All are optional.
|
|||||||
|
|
||||||
#### `FLUXER_TRUST_CLIENT_IP_HEADER`
|
#### `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`
|
#### `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`.
|
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`
|
#### `FLUXER_API_TRUSTED_CALLERS`
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {ParsedIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
import {canonicalizeIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
|
||||||
|
|
||||||
interface ClientIpExtractionOptions {
|
interface ClientIpExtractionOptions {
|
||||||
trustClientIpHeader?: boolean;
|
trustClientIpHeader?: boolean;
|
||||||
clientIpHeaderName?: string;
|
clientIpHeaderName?: string;
|
||||||
|
peerAddress?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MissingClientIpError extends Error {
|
export class MissingClientIpError extends Error {
|
||||||
@@ -13,7 +13,7 @@ export class MissingClientIpError extends Error {
|
|||||||
readonly status = 403;
|
readonly status = 403;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('Client IP header is required');
|
super('Client IP is required');
|
||||||
this.name = 'MissingClientIpError';
|
this.name = 'MissingClientIpError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,20 +28,12 @@ export function resolveClientIpHeaderName(clientIpHeaderName?: string): string {
|
|||||||
return normalizeHeaderName(clientIpHeaderName ?? DEFAULT_CLIENT_IP_HEADER_NAME);
|
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 {
|
export function extractClientIp(req: Request, options?: ClientIpExtractionOptions): string | null {
|
||||||
if (!options?.trustClientIpHeader) {
|
if (!options?.trustClientIpHeader) {
|
||||||
return null;
|
return options?.peerAddress ? canonicalizeIpAddress(options.peerAddress) : null;
|
||||||
}
|
}
|
||||||
return (
|
const headerValue = req.headers.get(resolveClientIpHeaderName(options.clientIpHeaderName));
|
||||||
parseClientIpHeaderValue(req.headers.get(resolveClientIpHeaderName(options.clientIpHeaderName)))?.normalized ?? null
|
return headerValue === null ? null : canonicalizeIpAddress(headerValue.split(',', 1)[0]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireClientIp(req: Request, options?: ClientIpExtractionOptions): string {
|
export function requireClientIp(req: Request, options?: ClientIpExtractionOptions): string {
|
||||||
|
|||||||
@@ -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 {
|
export function isValidIp(value: string): boolean {
|
||||||
return parseIpAddress(value) !== null;
|
return parseIpAddress(value) !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,18 +50,43 @@ describe('extractClientIp', () => {
|
|||||||
'192.168.1.1',
|
'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', {
|
const request = new Request('http://example.com', {
|
||||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||||
});
|
});
|
||||||
expect(extractClientIp(request, {trustClientIpHeader: false})).toBeNull();
|
expect(extractClientIp(request, {trustClientIpHeader: false, peerAddress: '203.0.113.7'})).toBe('203.0.113.7');
|
||||||
expect(extractClientIp(request)).toBeNull();
|
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', {
|
const request = new Request('http://example.com', {
|
||||||
headers: {'X-Forwarded-For': '192.168.1.1'},
|
headers: {'X-Forwarded-For': '192.168.1.1'},
|
||||||
});
|
});
|
||||||
expect(extractClientIp(request)).toBeNull();
|
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', () => {
|
it('returns null for missing or invalid headers', () => {
|
||||||
expect(extractClientIp(new Request('http://example.com'), {trustClientIpHeader: true})).toBeNull();
|
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');
|
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', () => {
|
it('throws typed error when missing', () => {
|
||||||
const request = new Request('http://example.com');
|
const request = new Request('http://example.com');
|
||||||
expect(() => requireClientIp(request, {trustClientIpHeader: true})).toThrow(MissingClientIpError);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
canonicalizeIpAddress,
|
||||||
getIpNetworkKey,
|
getIpNetworkKey,
|
||||||
getSameIpDecisionKey,
|
getSameIpDecisionKey,
|
||||||
getSubnet,
|
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', () => {
|
describe('isValidIp', () => {
|
||||||
it('accepts valid values', () => {
|
it('accepts valid values', () => {
|
||||||
expect(isValidIp('192.168.1.1')).toBe(true);
|
expect(isValidIp('192.168.1.1')).toBe(true);
|
||||||
|
|||||||
Reference in new issue
Block a user