mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): scope forwarded client ip trust per caller (#3198)
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "<at least 32 characters>", "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
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -86,6 +86,11 @@ export interface MasterConfig {
|
||||
max_inflight_requests: number;
|
||||
ip_ban_exempt_ips: Array<string>;
|
||||
donation_proxy_key: string;
|
||||
trusted_callers: Array<{
|
||||
name: string;
|
||||
key: string;
|
||||
buckets: Array<string>;
|
||||
}>;
|
||||
presigned_attachment_uploads_enabled: boolean;
|
||||
presigned_harvest_downloads_enabled: boolean;
|
||||
unfurl_ignored_hosts: Array<string>;
|
||||
|
||||
@@ -73,6 +73,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user