mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
perf(api): resolve the client ip once per request (#2218)
This commit is contained in:
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
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
|
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 {getSameIpDecisionKey, type IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress';
|
||||||
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
|
||||||
import {createMiddleware} from 'hono/factory';
|
import {createMiddleware} from 'hono/factory';
|
||||||
import {AdminRepository} from '../admin/AdminRepository';
|
import {AdminRepository} from '../admin/AdminRepository';
|
||||||
import type {BannedIpEntry, BannedIpKind} from '../admin/IAdminRepository';
|
import type {BannedIpEntry, BannedIpKind} from '../admin/IAdminRepository';
|
||||||
import {Config} from '../Config';
|
|
||||||
import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan';
|
import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan';
|
||||||
import {Logger} from '../Logger';
|
import {Logger} from '../Logger';
|
||||||
import {isIpBanExempt} from '../risk/IpBanExemptions';
|
import {isIpBanExempt} from '../risk/IpBanExemptions';
|
||||||
import type {HonoEnv} from '../types/HonoEnv';
|
import type {HonoEnv} from '../types/HonoEnv';
|
||||||
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
|
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
|
||||||
|
import {getRequestClientIp} from '../utils/RequestClientIp';
|
||||||
|
|
||||||
type FamilyMap<T> = Record<IpAddressFamily, Map<string, T>>;
|
type FamilyMap<T> = Record<IpAddressFamily, Map<string, T>>;
|
||||||
|
|
||||||
@@ -371,10 +370,7 @@ class IpBanCache {
|
|||||||
|
|
||||||
export const ipBanCache = new IpBanCache();
|
export const ipBanCache = new IpBanCache();
|
||||||
export const IpBanMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
export const IpBanMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
const clientIp = extractClientIp(ctx.req.raw, {
|
const clientIp = getRequestClientIp(ctx);
|
||||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
|
||||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
|
||||||
});
|
|
||||||
const match = clientIp ? ipBanCache.getMatch(clientIp) : null;
|
const match = clientIp ? ipBanCache.getMatch(clientIp) : null;
|
||||||
if (match) {
|
if (match) {
|
||||||
throw new IpBannedError({
|
throw new IpBannedError({
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {createHash} from 'node:crypto';
|
import {createHash} from 'node:crypto';
|
||||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||||
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
|
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 {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||||
import type {BucketConfig, RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
|
import type {BucketConfig, RateLimitResult, RateLimitScope} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||||
import type {Context, MiddlewareHandler} from 'hono';
|
import type {Context, MiddlewareHandler} from 'hono';
|
||||||
@@ -11,6 +10,7 @@ import {createMiddleware} from 'hono/factory';
|
|||||||
import * as AuthSession from '../auth/AuthSession';
|
import * as AuthSession from '../auth/AuthSession';
|
||||||
import {Config} from '../Config';
|
import {Config} from '../Config';
|
||||||
import type {HonoEnv} from '../types/HonoEnv';
|
import type {HonoEnv} from '../types/HonoEnv';
|
||||||
|
import {getRequestClientIp} from '../utils/RequestClientIp';
|
||||||
|
|
||||||
type AccountType = 'user' | 'bot' | 'webhook';
|
type AccountType = 'user' | 'bot' | 'webhook';
|
||||||
|
|
||||||
@@ -58,10 +58,7 @@ function getClientIdentifier(ctx: Context<HonoEnv>): string {
|
|||||||
}
|
}
|
||||||
return `user:${user.id}:${tokenType}`;
|
return `user:${user.id}:${tokenType}`;
|
||||||
}
|
}
|
||||||
const ip = extractClientIp(ctx.req.raw, {
|
const ip = getRequestClientIp(ctx);
|
||||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
|
||||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
|
||||||
});
|
|
||||||
if (!ip) return 'internal';
|
if (!ip) return 'internal';
|
||||||
return `ip:${getSameIpDecisionKey(ip) ?? ip}`;
|
return `ip:${getSameIpDecisionKey(ip) ?? ip}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import {UserChannelRequestService} from '../user/services/UserChannelRequestServ
|
|||||||
import {UserContentRequestService} from '../user/services/UserContentRequestService';
|
import {UserContentRequestService} from '../user/services/UserContentRequestService';
|
||||||
import {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
|
import {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
|
||||||
import {UserService} from '../user/services/UserService';
|
import {UserService} from '../user/services/UserService';
|
||||||
import {resolveRequestClientIp} from '../utils/IpUtils';
|
import {getRequestClientIp} from '../utils/RequestClientIp';
|
||||||
import {VoiceService} from '../voice/VoiceService';
|
import {VoiceService} from '../voice/VoiceService';
|
||||||
import {WebhookRequestService} from '../webhook/WebhookRequestService';
|
import {WebhookRequestService} from '../webhook/WebhookRequestService';
|
||||||
import {WebhookService} from '../webhook/WebhookService';
|
import {WebhookService} from '../webhook/WebhookService';
|
||||||
@@ -1050,7 +1050,7 @@ class RequestServices implements RequestScopedServices {
|
|||||||
export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
export const ServiceMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
const apiContext = createApiContext({
|
const apiContext = createApiContext({
|
||||||
requestId: ctx.get('requestId') ?? crypto.randomUUID(),
|
requestId: ctx.get('requestId') ?? crypto.randomUUID(),
|
||||||
clientIp: resolveRequestClientIp(ctx.req.raw),
|
clientIp: getRequestClientIp(ctx),
|
||||||
userAgent: ctx.req.header('user-agent') ?? null,
|
userAgent: ctx.req.header('user-agent') ?? null,
|
||||||
});
|
});
|
||||||
ctx.set('apiContext', apiContext);
|
ctx.set('apiContext', apiContext);
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
|
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
|
||||||
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
|
||||||
import {createMiddleware} from 'hono/factory';
|
import {createMiddleware} from 'hono/factory';
|
||||||
import {Config} from '../Config';
|
|
||||||
import type {HonoEnv} from '../types/HonoEnv';
|
import type {HonoEnv} from '../types/HonoEnv';
|
||||||
|
import {getRequestClientIp} from '../utils/RequestClientIp';
|
||||||
import {torExitListCache} from './TorExitListCache';
|
import {torExitListCache} from './TorExitListCache';
|
||||||
|
|
||||||
export const TorExitMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
export const TorExitMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
|
||||||
const clientIp = extractClientIp(ctx.req.raw, {
|
const clientIp = getRequestClientIp(ctx);
|
||||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
|
||||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
|
||||||
});
|
|
||||||
if (clientIp && torExitListCache.isTorExit(clientIp)) {
|
if (clientIp && torExitListCache.isTorExit(clientIp)) {
|
||||||
throw new IpBannedError({
|
throw new IpBannedError({
|
||||||
ipAddress: clientIp,
|
ipAddress: clientIp,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||||
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
import {ForbiddenError} from '@fluxer/errors/src/domains/core/ForbiddenError';
|
||||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
|
||||||
import {createMiddleware} from 'hono/factory';
|
import {createMiddleware} from 'hono/factory';
|
||||||
import type {ILogger} from '../ILogger';
|
import type {ILogger} from '../ILogger';
|
||||||
import type {HonoEnv} from '../types/HonoEnv';
|
import type {HonoEnv} from '../types/HonoEnv';
|
||||||
|
import {resolveClientIpWithOptions} from '../utils/RequestClientIp';
|
||||||
import {stripApiPrefix} from '../utils/RequestPathUtils';
|
import {stripApiPrefix} from '../utils/RequestPathUtils';
|
||||||
|
|
||||||
interface TrustedClientIpHeaderOptions {
|
interface TrustedClientIpHeaderOptions {
|
||||||
@@ -46,8 +46,7 @@ export function TrustedClientIpHeaderMiddleware({
|
|||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const firstHop = clientIpHeaderValue.split(',')[0]?.trim() ?? clientIpHeaderValue;
|
if (!resolveClientIpWithOptions(ctx, {trustClientIpHeader, clientIpHeaderName})) {
|
||||||
if (!parseIpAddress(firstHop)) {
|
|
||||||
logger.warn({path, clientIpHeaderName}, 'Rejected request with invalid client IP header');
|
logger.warn({path, clientIpHeaderName}, 'Rejected request with invalid client IP header');
|
||||||
throw new ForbiddenError({code: APIErrorCodes.FORBIDDEN});
|
throw new ForbiddenError({code: APIErrorCodes.FORBIDDEN});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {extractClientIpDetails, requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
|
|
||||||
import type {Context} from 'hono';
|
import type {Context} from 'hono';
|
||||||
import {createMiddleware} from 'hono/factory';
|
import {createMiddleware} from 'hono/factory';
|
||||||
import * as AuthSession from '../auth/AuthSession';
|
import * as AuthSession from '../auth/AuthSession';
|
||||||
import {Config} from '../Config';
|
|
||||||
import {Logger} from '../Logger';
|
import {Logger} from '../Logger';
|
||||||
import type {User} from '../models/User';
|
import type {User} from '../models/User';
|
||||||
import type {HonoEnv} from '../types/HonoEnv';
|
import type {HonoEnv} from '../types/HonoEnv';
|
||||||
|
import {requireRequestClientIp} from '../utils/RequestClientIp';
|
||||||
import {stripApiPrefix} from '../utils/RequestPathUtils';
|
import {stripApiPrefix} from '../utils/RequestPathUtils';
|
||||||
import {hashAuthToken, recordAbuseSignal} from './AbusiveIpAutoBanner';
|
import {hashAuthToken, recordAbuseSignal} from './AbusiveIpAutoBanner';
|
||||||
|
|
||||||
@@ -61,10 +60,7 @@ function setUserInContext(ctx: Context<HonoEnv>, user: User, trackActivity: bool
|
|||||||
ctx.set('user', user);
|
ctx.set('user', user);
|
||||||
if (trackActivity) {
|
if (trackActivity) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ip = requireClientIp(ctx.req.raw, {
|
const ip = requireRequestClientIp(ctx);
|
||||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
|
||||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
|
||||||
});
|
|
||||||
const kvActivityTracker = ctx.get('kvActivityTracker');
|
const kvActivityTracker = ctx.get('kvActivityTracker');
|
||||||
const userActivityBuffer = ctx.get('userActivityBuffer');
|
const userActivityBuffer = ctx.get('userActivityBuffer');
|
||||||
userActivityBuffer.recordActivity(user.id, now, ip);
|
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 rawAuthHeader = ctx.req.header('Authorization');
|
||||||
const parsed = parseAuthHeader(rawAuthHeader);
|
const parsed = parseAuthHeader(rawAuthHeader);
|
||||||
const extractedClientIp = extractClientIpDetails(ctx.req.raw, {
|
const resolvedClientIp = requireRequestClientIp(ctx);
|
||||||
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,
|
|
||||||
});
|
|
||||||
ctx.set('oauthBearerToken', undefined);
|
ctx.set('oauthBearerToken', undefined);
|
||||||
ctx.set('oauthBearerApplicationId', undefined);
|
ctx.set('oauthBearerApplicationId', undefined);
|
||||||
ctx.set('oauthBearerAllowed', false);
|
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);
|
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 () => {
|
it('accepts x-forwarded-for with multiple hops and a valid first hop', async () => {
|
||||||
const app = createApp('x-forwarded-for');
|
const app = createApp('x-forwarded-for');
|
||||||
const response = await app.request('http://localhost/v1/messages', {
|
const response = await app.request('http://localhost/v1/messages', {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import type {UserContactChangeLogService} from '../user/services/UserContactChan
|
|||||||
import type {UserContentRequestService} from '../user/services/UserContentRequestService';
|
import type {UserContentRequestService} from '../user/services/UserContentRequestService';
|
||||||
import type {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
|
import type {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
|
||||||
import type {UserService} from '../user/services/UserService';
|
import type {UserService} from '../user/services/UserService';
|
||||||
|
import type {ClientIpResolution} from '../utils/RequestClientIp';
|
||||||
import type {SweegoWebhookService} from '../webhook/SweegoWebhookService';
|
import type {SweegoWebhookService} from '../webhook/SweegoWebhookService';
|
||||||
import type {WebhookRequestService} from '../webhook/WebhookRequestService';
|
import type {WebhookRequestService} from '../webhook/WebhookRequestService';
|
||||||
import type {WebhookService} from '../webhook/WebhookService';
|
import type {WebhookService} from '../webhook/WebhookService';
|
||||||
@@ -91,6 +92,7 @@ export interface HonoEnv {
|
|||||||
apiContext: ApiContext;
|
apiContext: ApiContext;
|
||||||
user: User;
|
user: User;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
clientIpResolution?: ClientIpResolution;
|
||||||
responseSchema: unknown;
|
responseSchema: unknown;
|
||||||
adminService: AdminService;
|
adminService: AdminService;
|
||||||
adminArchiveService: AdminArchiveService;
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user