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

+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),