mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
feat(api): scope forwarded client ip trust per caller (#3198)
This commit is contained in:
1 parent
2ac164d5b8
commit
0aeff01c2d
15 files changed
+352
-30
No files matched your search
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user