Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions fluxer_api/src/api/middleware/IpBanMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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>>;

Expand Down Expand Up @@ -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({
Expand Down
7 changes: 2 additions & 5 deletions fluxer_api/src/api/middleware/RateLimitMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
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';
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';

Expand Down Expand Up @@ -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}`;
}
Expand Down
4 changes: 2 additions & 2 deletions fluxer_api/src/api/middleware/ServiceMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 2 additions & 6 deletions fluxer_api/src/api/middleware/TorExitMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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});
}
Expand Down
19 changes: 3 additions & 16 deletions fluxer_api/src/api/middleware/UserMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
111 changes: 111 additions & 0 deletions fluxer_api/src/api/middleware/tests/ClientIpResolution.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions fluxer_api/src/api/types/HonoEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import type {UserContactChangeLogService} from '../user/services/UserContactChan
import type {UserContentRequestService} from '../user/services/UserContentRequestService';
import type {UserRelationshipRequestService} from '../user/services/UserRelationshipRequestService';
import type {UserService} from '../user/services/UserService';
import type {ClientIpResolution} from '../utils/RequestClientIp';
import type {SweegoWebhookService} from '../webhook/SweegoWebhookService';
import type {WebhookRequestService} from '../webhook/WebhookRequestService';
import type {WebhookService} from '../webhook/WebhookService';
Expand All @@ -91,6 +92,7 @@ export interface HonoEnv {
apiContext: ApiContext;
user: User;
requestId?: string;
clientIpResolution?: ClientIpResolution;
responseSchema: unknown;
adminService: AdminService;
adminArchiveService: AdminArchiveService;
Expand Down
48 changes: 48 additions & 0 deletions fluxer_api/src/api/utils/RequestClientIp.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading