feat(api): scope forwarded client ip trust per caller (#3198)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-04 16:36:41 +02:00
1 parent 2ac164d5b8
commit 0aeff01c2d
15 files changed
+352 -30

No files matched your search

+72
View File
@@ -211,3 +211,75 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => {
expect(config.breachedPasswordCheck.enabled).toBe(false);
});
});
async function trustedCallersFromEnv(env: Record<string, string>) {
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
resetConfig();
return buildAPIConfigFromMaster(await loadConfig()).internal.trustedCallers;
}
describe('buildAPIConfigFromMaster trusted callers', () => {
const bugsKey = 'b'.repeat(32);
const donationKey = 'd'.repeat(32);
test('reads callers from FLUXER_API_TRUSTED_CALLERS', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']},
]),
});
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'oauth:revoke']}]);
});
test('turns FLUXER_API_DONATION_PROXY_KEY into a caller scoped to the donation buckets', async () => {
const callers = await trustedCallersFromEnv({FLUXER_API_DONATION_PROXY_KEY: donationKey});
expect(callers).toEqual([
{
name: 'donation',
key: donationKey,
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
},
]);
});
test('keeps both forms side by side', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_DONATION_PROXY_KEY: donationKey,
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: ['oauth:token']}]),
});
expect(callers.map((caller) => caller.name)).toEqual(['bugs', 'donation']);
});
test('tolerates bucket names and fields this build does not know', async () => {
const callers = await trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([
{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket'], note: 'added later'},
]),
});
expect(callers).toEqual([{name: 'bugs', key: bugsKey, buckets: ['oauth:token', 'future:bucket']}]);
});
test('fails at boot on a short key', async () => {
await expect(
trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: 'short', buckets: ['oauth:token']}]),
}),
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 key must be at least 32 characters');
});
test('fails at boot on an entry with no buckets', async () => {
await expect(
trustedCallersFromEnv({
FLUXER_API_TRUSTED_CALLERS: JSON.stringify([{name: 'bugs', key: bugsKey, buckets: []}]),
}),
).rejects.toThrow('FLUXER_API_TRUSTED_CALLERS entry 1 buckets must be a non-empty list of bucket names');
});
test('fails at boot on a value that is not a JSON array', async () => {
await expect(trustedCallersFromEnv({FLUXER_API_TRUSTED_CALLERS: '{"name":"bugs"}'})).rejects.toThrow(
'FLUXER_API_TRUSTED_CALLERS must be a JSON array',
);
});
});
+50 -6
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {APIConfig, BlueskyOAuthConfig} from '@app/api/config/APIConfig';
import type {APIConfig, BlueskyOAuthConfig, TrustedCallerConfig} from '@app/api/config/APIConfig';
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
import {parseIpBanEntry} from '@app/api/utils/IpRangeUtils';
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
@@ -99,6 +100,52 @@ function mapApnsApps(
});
}
const TRUSTED_CALLER_MIN_KEY_LENGTH = 32;
function parseTrustedCaller(entry: unknown, index: number): TrustedCallerConfig {
const label = `FLUXER_API_TRUSTED_CALLERS entry ${index + 1}`;
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${label} must be a JSON object`);
}
const name = Reflect.get(entry, 'name');
if (typeof name !== 'string' || name.trim().length === 0) {
throw new Error(`${label} must have a name`);
}
const key = Reflect.get(entry, 'key');
if (typeof key !== 'string' || key.trim().length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
throw new Error(`${label} key must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
}
const buckets = Reflect.get(entry, 'buckets');
if (
!Array.isArray(buckets) ||
buckets.length === 0 ||
!buckets.every((bucket) => typeof bucket === 'string' && bucket.trim().length > 0)
) {
throw new Error(`${label} buckets must be a non-empty list of bucket names`);
}
return {
name: name.trim(),
key: key.trim(),
buckets: buckets.map((bucket: string) => bucket.trim()),
};
}
function buildTrustedCallers(master: MasterConfig): Array<TrustedCallerConfig> {
const trustedCallers = (master.services.api.trusted_callers ?? []).map(parseTrustedCaller);
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < TRUSTED_CALLER_MIN_KEY_LENGTH) {
throw new Error(`FLUXER_API_DONATION_PROXY_KEY must be at least ${TRUSTED_CALLER_MIN_KEY_LENGTH} characters`);
}
if (donationProxyKey.length > 0) {
trustedCallers.push({
name: 'donation',
key: donationProxyKey,
buckets: Object.values(DonationRateLimitConfigs).map((routeConfig) => routeConfig.bucket),
});
}
return trustedCallers;
}
export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (!master.internal) {
throw new Error('internal configuration is required for the API');
@@ -118,10 +165,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
if (Buffer.from(uploadRelaySecretBase64, 'base64').length < 32) {
throw new Error('FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes');
}
const donationProxyKey = (master.services.api.donation_proxy_key ?? '').trim();
if (donationProxyKey.length > 0 && donationProxyKey.length < 32) {
throw new Error('FLUXER_API_DONATION_PROXY_KEY must be at least 32 characters');
}
const trustedCallers = buildTrustedCallers(master);
if (!s3Config) {
throw new Error('S3 configuration is required for the API');
}
@@ -227,7 +271,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
},
internal: {
gatewayRpcAuthToken: master.services.gateway.rpc_auth_token ?? '',
donationProxyKey,
trustedCallers,
},
hosts: {
marketing: extractHostname(master.endpoints.marketing),
+7 -1
View File
@@ -18,6 +18,12 @@ export interface AppStoreAppConfig {
appAppleId: number;
}
export interface TrustedCallerConfig {
name: string;
key: string;
buckets: Array<string>;
}
export interface APICachePurgeConfig {
adapter: CachePurgeAdapterName;
http: {
@@ -129,7 +135,7 @@ export interface APIConfig {
};
internal: {
gatewayRpcAuthToken: string;
donationProxyKey: string;
trustedCallers: Array<TrustedCallerConfig>;
};
hosts: {
marketing: string;
@@ -19,7 +19,7 @@ export interface RouteRateLimitConfig {
bucket: string;
config: BucketConfig;
scope?: RateLimitScope;
trustDonorIpHeader?: boolean;
trustForwardedClientIp?: boolean;
emailBucket?: {
bucket: string;
config: BucketConfig;
@@ -29,7 +29,8 @@ export interface RouteRateLimitConfig {
const TEST_ENABLE_RATE_LIMITS_HEADER = 'x-fluxer-test-enable-rate-limits';
const TEST_GLOBAL_RATE_LIMIT_OVERRIDE_HEADER = 'x-fluxer-test-global-rate-limit';
const INTERNAL_KEY_HEADER = 'x-fluxer-internal-key';
const DONOR_IP_HEADER = 'x-fluxer-donor-ip';
const FORWARDED_CLIENT_IP_HEADER = 'x-fluxer-client-ip';
const LEGACY_FORWARDED_CLIENT_IP_HEADER = 'x-fluxer-donor-ip';
function shouldEnforceRateLimits(ctx: Context<HonoEnv>): boolean {
if (!Config.dev.testModeEnabled) {
@@ -57,24 +58,32 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
return accountType === 'bot' || accountType === 'webhook';
}
function isTrustedInternalCaller(ctx: Context<HonoEnv>): boolean {
const expectedKey = Config.internal.donationProxyKey;
if (!expectedKey) return false;
const providedKey = ctx.req.header(INTERNAL_KEY_HEADER);
if (!providedKey) return false;
function keysMatch(expectedKey: string, providedKey: string): boolean {
const expectedBuffer = Buffer.from(expectedKey);
const providedBuffer = Buffer.from(providedKey);
if (expectedBuffer.length !== providedBuffer.length) return false;
return timingSafeEqual(expectedBuffer, providedBuffer);
}
function getForwardedDonorIdentifier(ctx: Context<HonoEnv>): string | null {
if (!isTrustedInternalCaller(ctx)) return null;
const headerValue = ctx.req.header(DONOR_IP_HEADER)?.split(',', 1)[0].trim();
function isTrustedCallerForBucket(ctx: Context<HonoEnv>, bucket: string): boolean {
const providedKey = ctx.req.header(INTERNAL_KEY_HEADER);
if (!providedKey) return false;
let trusted = false;
for (const caller of Config.internal.trustedCallers) {
if (!caller.buckets.includes(bucket)) continue;
if (keysMatch(caller.key, providedKey)) trusted = true;
}
return trusted;
}
function getForwardedClientIdentifier(ctx: Context<HonoEnv>, bucket: string): string | null {
if (!isTrustedCallerForBucket(ctx, bucket)) return null;
const rawHeader = ctx.req.header(FORWARDED_CLIENT_IP_HEADER) ?? ctx.req.header(LEGACY_FORWARDED_CLIENT_IP_HEADER);
const headerValue = rawHeader?.split(',', 1)[0].trim();
if (!headerValue) return null;
const donorIp = parseIpAddress(headerValue);
if (!donorIp) return null;
return `ip:${getSameIpDecisionKey(donorIp.normalized) ?? donorIp.normalized}`;
const clientIp = parseIpAddress(headerValue);
if (!clientIp) return null;
return `ip:${getSameIpDecisionKey(clientIp.normalized) ?? clientIp.normalized}`;
}
function getClientIdentifier(ctx: Context<HonoEnv>, routeConfig: RouteRateLimitConfig): string {
@@ -86,9 +95,9 @@ function getClientIdentifier(ctx: Context<HonoEnv>, routeConfig: RouteRateLimitC
}
return `user:${user.id}:${tokenType}`;
}
if (routeConfig.trustDonorIpHeader) {
const donorIdentifier = getForwardedDonorIdentifier(ctx);
if (donorIdentifier) return donorIdentifier;
if (routeConfig.trustForwardedClientIp) {
const forwardedIdentifier = getForwardedClientIdentifier(ctx, routeConfig.bucket);
if (forwardedIdentifier) return forwardedIdentifier;
}
const ip = getRequestClientIp(ctx);
if (!ip) return 'internal';
@@ -1,7 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {Config} from '@app/api/Config';
import type {TrustedCallerConfig} from '@app/api/config/APIConfig';
import {RateLimitMiddleware, type RouteRateLimitConfig} from '@app/api/middleware/RateLimitMiddleware';
import {DonationRateLimitConfigs} from '@app/api/rate_limit_configs/DonationRateLimitConfig';
import {OAuthRateLimitConfigs} from '@app/api/rate_limit_configs/OAuthRateLimitConfig';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import type {
BucketConfig,
@@ -10,7 +14,7 @@ import type {
RateLimitResult,
} from '@pkgs/rate_limit/src/IRateLimitService';
import {type Context, Hono} from 'hono';
import {describe, expect, test} from 'vitest';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const CLIENT_IP = '203.0.113.10';
const SWAPPED_CLIENT_IP = '198.51.100.7';
@@ -146,3 +150,169 @@ describe('RateLimitMiddleware', () => {
expect(harness.service.buckets).toEqual([`ip:${CLIENT_IP}:webhook:read:111`]);
});
});
const CALLER_IP = '192.0.2.50';
const FORWARDED_IP = '203.0.113.77';
const OTHER_FORWARDED_IP = '203.0.113.78';
const BUGS_KEY = 'bugs-key-0123456789abcdefghijklmnopqrstuv';
const DONATION_KEY = 'donation-key-0123456789abcdefghijklmnopq';
const TRUSTED_CALLERS: Array<TrustedCallerConfig> = [
{name: 'bugs', key: BUGS_KEY, buckets: ['oauth:token', 'oauth:revoke']},
{
name: 'donation',
key: DONATION_KEY,
buckets: ['donation:request_link', 'donation:manage', 'donation:checkout'],
},
];
function buildTrustedHarness(routeConfig: RouteRateLimitConfig): Harness {
const service = new RecordingRateLimitService();
let context: Context<HonoEnv> | null = null;
const app = new Hono<HonoEnv>({strict: true});
app.use('*', async (ctx, next) => {
context = ctx;
ctx.set('rateLimitService', service);
await next();
});
app.post('/route', RateLimitMiddleware(routeConfig), (ctx) => ctx.text('ok'));
return {
app,
service,
getContext(): Context<HonoEnv> {
if (!context) {
throw new Error('no request has run yet');
}
return context;
},
};
}
async function callTrustedRoute(harness: Harness, headers: Record<string, string>): Promise<Response> {
return await harness.app.request('http://localhost/route', {
method: 'POST',
headers: {
'x-forwarded-for': CALLER_IP,
'x-fluxer-test-enable-rate-limits': 'true',
...headers,
},
});
}
describe('RateLimitMiddleware trusted callers', () => {
let previousTrustedCallers: Array<TrustedCallerConfig>;
beforeEach(() => {
previousTrustedCallers = Config.internal.trustedCallers;
Config.internal.trustedCallers = TRUSTED_CALLERS;
});
afterEach(() => {
Config.internal.trustedCallers = previousTrustedCallers;
});
test('keys oauth:token on the address the bugs caller forwards, one bucket per address', async () => {
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
const first = await callTrustedRoute(harness, {
'x-fluxer-internal-key': BUGS_KEY,
'x-fluxer-client-ip': FORWARDED_IP,
});
const second = await callTrustedRoute(harness, {
'x-fluxer-internal-key': BUGS_KEY,
'x-fluxer-client-ip': OTHER_FORWARDED_IP,
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(harness.service.globalIdentifiers).toEqual([`ip:${FORWARDED_IP}`, `ip:${OTHER_FORWARDED_IP}`]);
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:oauth:token`, `ip:${OTHER_FORWARDED_IP}:oauth:token`]);
});
test('keys oauth:revoke on the address the bugs caller forwards', async () => {
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_REVOKE);
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:oauth:revoke`]);
});
test('ignores the bugs key on a donation route', async () => {
const harness = buildTrustedHarness(DonationRateLimitConfigs.DONATION_MANAGE);
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-donor-ip': FORWARDED_IP});
expect(harness.service.globalIdentifiers).toEqual([`ip:${CALLER_IP}`, `ip:${CALLER_IP}`]);
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:donation:manage`, `ip:${CALLER_IP}:donation:manage`]);
});
test('ignores the donation key on oauth:token', async () => {
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
await callTrustedRoute(harness, {'x-fluxer-internal-key': DONATION_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:token`]);
});
test('accepts the donation key on its routes with the new and the old address header', async () => {
for (const routeConfig of Object.values(DonationRateLimitConfigs)) {
const harness = buildTrustedHarness(routeConfig);
await callTrustedRoute(harness, {'x-fluxer-internal-key': DONATION_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
await callTrustedRoute(harness, {
'x-fluxer-internal-key': DONATION_KEY,
'x-fluxer-donor-ip': OTHER_FORWARDED_IP,
});
expect(harness.service.buckets).toEqual([
`ip:${FORWARDED_IP}:${routeConfig.bucket}`,
`ip:${OTHER_FORWARDED_IP}:${routeConfig.bucket}`,
]);
}
});
test('prefers the new address header when both are sent', async () => {
const harness = buildTrustedHarness(DonationRateLimitConfigs.DONATION_MANAGE);
await callTrustedRoute(harness, {
'x-fluxer-internal-key': DONATION_KEY,
'x-fluxer-client-ip': FORWARDED_IP,
'x-fluxer-donor-ip': OTHER_FORWARDED_IP,
});
expect(harness.service.buckets).toEqual([`ip:${FORWARDED_IP}:donation:manage`]);
});
test('ignores a wrong key of the same or a different length, and a missing key', async () => {
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
const sameLengthWrongKey = `${BUGS_KEY.slice(0, -1)}${BUGS_KEY.endsWith('v') ? 'w' : 'v'}`;
await callTrustedRoute(harness, {'x-fluxer-internal-key': sameLengthWrongKey, 'x-fluxer-client-ip': FORWARDED_IP});
await callTrustedRoute(harness, {'x-fluxer-internal-key': `${BUGS_KEY}x`, 'x-fluxer-client-ip': FORWARDED_IP});
await callTrustedRoute(harness, {'x-fluxer-client-ip': FORWARDED_IP});
expect(harness.service.buckets).toEqual([
`ip:${CALLER_IP}:oauth:token`,
`ip:${CALLER_IP}:oauth:token`,
`ip:${CALLER_IP}:oauth:token`,
]);
});
test('falls back to the caller when a trusted key sends an unparsable address', async () => {
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_TOKEN);
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': 'not-an-ip'});
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:token`]);
});
test('ignores a trusted key on a route that does not opt in, even when the bucket is listed', async () => {
Config.internal.trustedCallers = [{name: 'wide', key: BUGS_KEY, buckets: ['oauth:introspect']}];
const harness = buildTrustedHarness(OAuthRateLimitConfigs.OAUTH_INTROSPECT);
await callTrustedRoute(harness, {'x-fluxer-internal-key': BUGS_KEY, 'x-fluxer-client-ip': FORWARDED_IP});
expect(harness.service.buckets).toEqual([`ip:${CALLER_IP}:oauth:introspect`]);
});
});
+1 -1
View File
@@ -173,7 +173,7 @@ export function OAuth2Controller(app: HonoApp) {
);
app.post(
'/oauth2/token/revoke',
RateLimitMiddleware(RateLimitConfigs.OAUTH_INTROSPECT),
RateLimitMiddleware(RateLimitConfigs.OAUTH_REVOKE),
Validator('form', RevokeRequestForm),
OpenAPI({
operationId: 'revoke_oauth2_token',
@@ -7,7 +7,7 @@ export const DonationRateLimitConfigs = {
DONATION_REQUEST_LINK: {
bucket: 'donation:request_link',
config: {limit: 3, windowMs: ms('1 hour')},
trustDonorIpHeader: true,
trustForwardedClientIp: true,
emailBucket: {
bucket: 'donation:request_link:email',
config: {limit: 10, windowMs: ms('1 hour')},
@@ -16,12 +16,12 @@ export const DonationRateLimitConfigs = {
DONATION_MANAGE: {
bucket: 'donation:manage',
config: {limit: 10, windowMs: ms('1 minute')},
trustDonorIpHeader: true,
trustForwardedClientIp: true,
} as RouteRateLimitConfig,
DONATION_CHECKOUT: {
bucket: 'donation:checkout',
config: {limit: 5, windowMs: ms('1 minute')},
trustDonorIpHeader: true,
trustForwardedClientIp: true,
emailBucket: {
bucket: 'donation:checkout:email',
config: {limit: 10, windowMs: ms('1 hour')},
@@ -15,6 +15,7 @@ export const OAuthRateLimitConfigs = {
OAUTH_TOKEN: {
bucket: 'oauth:token',
config: {limit: 120, windowMs: ms('1 minute')},
trustForwardedClientIp: true,
} as RouteRateLimitConfig,
OAUTH_INTROSPECT: {
bucket: 'oauth:introspect',
@@ -23,6 +24,7 @@ export const OAuthRateLimitConfigs = {
OAUTH_REVOKE: {
bucket: 'oauth:revoke',
config: {limit: 120, windowMs: ms('1 minute')},
trustForwardedClientIp: true,
} as RouteRateLimitConfig,
OAUTH_DEV_CLIENTS_LIST: {
bucket: 'oauth_dev:clients:list',