diff --git a/deploy/self-hosting/.env.example b/deploy/self-hosting/.env.example index b657fd7a2..39430593d 100644 --- a/deploy/self-hosting/.env.example +++ b/deploy/self-hosting/.env.example @@ -138,6 +138,7 @@ MEILI_MASTER_KEY=CHANGE_ME #FLUXER_STRIPE_PRICES={} #FLUXER_STRIPE_LEGACY_PRICES={} #FLUXER_API_DONATION_PROXY_KEY= +#FLUXER_API_TRUSTED_CALLERS=[] #FLUXER_VISIONARIES_GUILD_ID= #FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID= diff --git a/deploy/self-hosting/docker-compose.yml b/deploy/self-hosting/docker-compose.yml index a83021fa1..e1d6859dc 100644 --- a/deploy/self-hosting/docker-compose.yml +++ b/deploy/self-hosting/docker-compose.yml @@ -128,6 +128,7 @@ x-fluxer-env: &fluxer-env FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-} FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-} FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-} + FLUXER_API_TRUSTED_CALLERS: ${FLUXER_API_TRUSTED_CALLERS:-} FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-} FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-} diff --git a/fluxer_api/src/api/Config.test.ts b/fluxer_api/src/api/Config.test.ts index 736d66be4..79a59654b 100644 --- a/fluxer_api/src/api/Config.test.ts +++ b/fluxer_api/src/api/Config.test.ts @@ -211,3 +211,75 @@ describe('buildAPIConfigFromMaster optional outbound lookups', () => { expect(config.breachedPasswordCheck.enabled).toBe(false); }); }); + +async function trustedCallersFromEnv(env: Record) { + 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', + ); + }); +}); diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 2ace88bd5..28aa3a201 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -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 { + 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), diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index 7681bbb50..6905d1ce7 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -18,6 +18,12 @@ export interface AppStoreAppConfig { appAppleId: number; } +export interface TrustedCallerConfig { + name: string; + key: string; + buckets: Array; +} + export interface APICachePurgeConfig { adapter: CachePurgeAdapterName; http: { @@ -129,7 +135,7 @@ export interface APIConfig { }; internal: { gatewayRpcAuthToken: string; - donationProxyKey: string; + trustedCallers: Array; }; hosts: { marketing: string; diff --git a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts index a4964ea12..9a2287929 100644 --- a/fluxer_api/src/api/middleware/RateLimitMiddleware.ts +++ b/fluxer_api/src/api/middleware/RateLimitMiddleware.ts @@ -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): boolean { if (!Config.dev.testModeEnabled) { @@ -57,24 +58,32 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean { return accountType === 'bot' || accountType === 'webhook'; } -function isTrustedInternalCaller(ctx: Context): 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): string | null { - if (!isTrustedInternalCaller(ctx)) return null; - const headerValue = ctx.req.header(DONOR_IP_HEADER)?.split(',', 1)[0].trim(); +function isTrustedCallerForBucket(ctx: Context, 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, 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, routeConfig: RouteRateLimitConfig): string { @@ -86,9 +95,9 @@ function getClientIdentifier(ctx: Context, 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'; diff --git a/fluxer_api/src/api/middleware/tests/RateLimitMiddleware.test.ts b/fluxer_api/src/api/middleware/tests/RateLimitMiddleware.test.ts index 791656977..bf14981ac 100644 --- a/fluxer_api/src/api/middleware/tests/RateLimitMiddleware.test.ts +++ b/fluxer_api/src/api/middleware/tests/RateLimitMiddleware.test.ts @@ -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 = [ + {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 | null = null; + const app = new Hono({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 { + if (!context) { + throw new Error('no request has run yet'); + } + return context; + }, + }; +} + +async function callTrustedRoute(harness: Harness, headers: Record): Promise { + 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; + + 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`]); + }); +}); diff --git a/fluxer_api/src/api/oauth/OAuth2Controller.ts b/fluxer_api/src/api/oauth/OAuth2Controller.ts index b98a33ee3..8ba1e9a70 100644 --- a/fluxer_api/src/api/oauth/OAuth2Controller.ts +++ b/fluxer_api/src/api/oauth/OAuth2Controller.ts @@ -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', diff --git a/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts index 1c4fbe1cf..7c42dcf5f 100644 --- a/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/DonationRateLimitConfig.ts @@ -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')}, diff --git a/fluxer_api/src/api/rate_limit_configs/OAuthRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/OAuthRateLimitConfig.ts index 67c5adccd..d26caab53 100644 --- a/fluxer_api/src/api/rate_limit_configs/OAuthRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/OAuthRateLimitConfig.ts @@ -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', diff --git a/fluxer_docs/src/content/docs/http-api/oauth2.mdx b/fluxer_docs/src/content/docs/http-api/oauth2.mdx index 40b09527b..b03d77c2f 100644 --- a/fluxer_docs/src/content/docs/http-api/oauth2.mdx +++ b/fluxer_docs/src/content/docs/http-api/oauth2.mdx @@ -647,7 +647,7 @@ No Gateway Dispatch is emitted, so a client holding a revoked token learns of th ### Rate limit -120 requests per minute for each client IP address, on the `oauth:introspect` bucket. +120 requests per minute for each client IP address, on the `oauth:revoke` bucket. ## Get current OAuth2 authorisation diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index c842aa927..f3c3a2bca 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -312,9 +312,19 @@ Default empty. Addresses and CIDR ranges exempt from IP bans. Comma separated. A 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. +#### `FLUXER_API_TRUSTED_CALLERS` + +Default `[]`, which keys every rate limit on the caller. A JSON array of trusted callers, such as a front end that talks to the API for its own visitors. Each entry is an object with a `name`, a `key` of at least 32 characters, and `buckets`, the rate limit buckets the caller may set the client address for. Only `donation:request_link`, `donation:manage`, `donation:checkout`, `oauth:token` and `oauth:revoke` accept it. Any other bucket name is ignored. A malformed entry or a short key fails the API at boot. + +The caller sends its key in `X-Fluxer-Internal-Key` and its own client's address in `X-Fluxer-Client-Ip`. The API keys the request on that address only when the key matches an entry that lists the route's bucket. Otherwise it ignores both headers and keys the request on the caller, so a browser cannot set the address. Self-hosters leave this empty. Compose forwards it from `.env`. + +```json +[{"name": "bugs", "key": "", "buckets": ["oauth:token", "oauth:revoke"]}] +``` + #### `FLUXER_API_DONATION_PROXY_KEY` -Default empty, which keys donation rate limits on the caller. Set it to let a trusted front end, such as the marketing site, send the donor address the limits apply to. The key must be at least 32 characters or the API fails at boot. The front end sends the same value in `X-Fluxer-Internal-Key` and the donor address in `X-Fluxer-Donor-Ip`. The API ignores the address header unless the key matches, so a browser cannot set it. Self-hosters leave this empty. Compose forwards it from `.env`. +Default empty. The older form of a trusted caller limited to the three donation buckets, for the marketing site. The key must be at least 32 characters or the API fails at boot. The API also accepts the address in the older `X-Fluxer-Donor-Ip` header. Self-hosters leave this empty. Compose forwards it from `.env`. ## Images diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 0a7b3c48d..60c87a092 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -98,6 +98,7 @@ function defaultConfig(): MasterConfig { max_inflight_requests: 512, ip_ban_exempt_ips: [], donation_proxy_key: '', + trusted_callers: [], presigned_attachment_uploads_enabled: false, presigned_harvest_downloads_enabled: true, unfurl_ignored_hosts: [], diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 70ee3b255..bd9393656 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -86,6 +86,11 @@ export interface MasterConfig { max_inflight_requests: number; ip_ban_exempt_ips: Array; donation_proxy_key: string; + trusted_callers: Array<{ + name: string; + key: string; + buckets: Array; + }>; presigned_attachment_uploads_enabled: boolean; presigned_harvest_downloads_enabled: boolean; unfurl_ignored_hosts: Array; diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 715eb6886..c9c0c8127 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -73,6 +73,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_API_MAX_INFLIGHT_REQUESTS: {path: ['services', 'api', 'max_inflight_requests'], parse: parseInteger}, FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv}, FLUXER_API_DONATION_PROXY_KEY: {path: ['services', 'api', 'donation_proxy_key']}, + FLUXER_API_TRUSTED_CALLERS: {path: ['services', 'api', 'trusted_callers'], parse: parseJsonArray}, FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: { path: ['services', 'api', 'presigned_attachment_uploads_enabled'], parse: parseBoolean,