From 9def9fbef64388a47ea12e20bfa93e06eb89825e Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 27 Sep 2026 21:19:35 +0200 Subject: [PATCH] feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988) --- fluxer_api/src/api/Config.ts | 9 +++ fluxer_api/src/api/risk/IpBanExemptions.ts | 63 ++++++++++++++----- .../risk/__tests__/IpBanExemptions.test.ts | 47 ++++++++++++++ .../content/docs/operator/configuration.mdx | 2 +- 4 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 fluxer_api/src/api/risk/__tests__/IpBanExemptions.test.ts diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 7f3079d0d..86ace4d79 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 {parseIpBanEntry} from '@app/api/utils/IpRangeUtils'; import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig'; import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress'; @@ -82,6 +83,14 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean { function normalizeIpBanExemptIps(values: Array): Array { const normalized = new Set(); for (const value of values) { + if (value.includes('/')) { + const range = parseIpBanEntry(value); + if (range?.type !== 'range') { + throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid CIDR range: ${value}`); + } + normalized.add(range.canonical); + continue; + } const parsed = parseIpAddress(value); if (!parsed) { throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`); diff --git a/fluxer_api/src/api/risk/IpBanExemptions.ts b/fluxer_api/src/api/risk/IpBanExemptions.ts index 9171dee30..51c25be02 100644 --- a/fluxer_api/src/api/risk/IpBanExemptions.ts +++ b/fluxer_api/src/api/risk/IpBanExemptions.ts @@ -1,34 +1,69 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Config} from '@app/api/Config'; +import {parseIpBanEntry, tryParseSingleIp} from '@app/api/utils/IpRangeUtils'; +import type {IpAddressFamily} from '@fluxer/ip_utils/src/IpAddress'; import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; -let exemptDecisionKeys: ReadonlySet | null = null; +interface ExemptRange { + family: IpAddressFamily; + start: bigint; + end: bigint; +} -function getExemptDecisionKeys(): ReadonlySet { - if (exemptDecisionKeys) { - return exemptDecisionKeys; +interface IpBanExemptions { + decisionKeys: ReadonlySet; + ranges: ReadonlyArray; +} + +let exemptions: IpBanExemptions | null = null; + +function getExemptions(): IpBanExemptions { + if (exemptions) { + return exemptions; } - const keys = new Set(); - for (const ip of Config.ipBanExemptIps) { - const key = getSameIpDecisionKey(ip); - if (!key) { - throw new Error(`Invalid IP ban exemption in API config: ${ip}`); + const decisionKeys = new Set(); + const ranges: Array = []; + for (const entry of Config.ipBanExemptIps) { + if (entry.includes('/')) { + const range = parseIpBanEntry(entry); + if (range?.type !== 'range') { + throw new Error(`Invalid IP ban exemption in API config: ${entry}`); + } + ranges.push({family: range.family, start: range.start, end: range.end}); + continue; } - keys.add(key); + const key = getSameIpDecisionKey(entry); + if (!key) { + throw new Error(`Invalid IP ban exemption in API config: ${entry}`); + } + decisionKeys.add(key); } - exemptDecisionKeys = keys; - return keys; + exemptions = {decisionKeys, ranges}; + return exemptions; } export function isIpBanExempt(ip: string | null | undefined): boolean { if (!ip) { return false; } + const {decisionKeys, ranges} = getExemptions(); const key = getSameIpDecisionKey(ip); - return key !== null && getExemptDecisionKeys().has(key); + if (key !== null && decisionKeys.has(key)) { + return true; + } + if (ranges.length === 0) { + return false; + } + const parsed = tryParseSingleIp(ip); + if (!parsed) { + return false; + } + return ranges.some( + (range) => range.family === parsed.family && parsed.value >= range.start && parsed.value <= range.end, + ); } export function resetIpBanExemptionsForTesting(): void { - exemptDecisionKeys = null; + exemptions = null; } diff --git a/fluxer_api/src/api/risk/__tests__/IpBanExemptions.test.ts b/fluxer_api/src/api/risk/__tests__/IpBanExemptions.test.ts new file mode 100644 index 000000000..9c4af79de --- /dev/null +++ b/fluxer_api/src/api/risk/__tests__/IpBanExemptions.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {getConfig} from '@app/api/Config'; +import {isIpBanExempt, resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +describe('isIpBanExempt', () => { + let originalExemptIps: Array; + + beforeEach(() => { + const config = getConfig(); + originalExemptIps = config.ipBanExemptIps; + config.ipBanExemptIps = ['198.51.100.7', '2001:db8:6::', '2001:db8:1200:1000::/56', '203.0.113.0/24']; + resetIpBanExemptionsForTesting(); + }); + + afterEach(() => { + getConfig().ipBanExemptIps = originalExemptIps; + resetIpBanExemptionsForTesting(); + }); + + it('matches a bare IPv4 address exactly', () => { + expect(isIpBanExempt('198.51.100.7')).toBe(true); + expect(isIpBanExempt('198.51.100.8')).toBe(false); + }); + + it('matches a bare IPv6 address on its /64', () => { + expect(isIpBanExempt('2001:db8:6::abcd')).toBe(true); + expect(isIpBanExempt('2001:db8:7::1')).toBe(false); + }); + + it('matches every address inside a CIDR range', () => { + expect(isIpBanExempt('2001:db8:1200:1000::1')).toBe(true); + expect(isIpBanExempt('2001:db8:1200:10ff:ffff:ffff:ffff:ffff')).toBe(true); + expect(isIpBanExempt('2001:db8:1200:1100::1')).toBe(false); + expect(isIpBanExempt('2001:db8:1200:fff::1')).toBe(false); + expect(isIpBanExempt('203.0.113.200')).toBe(true); + expect(isIpBanExempt('::ffff:203.0.113.200')).toBe(true); + expect(isIpBanExempt('203.0.114.1')).toBe(false); + }); + + it('does not match empty or unparsable input', () => { + expect(isIpBanExempt(null)).toBe(false); + expect(isIpBanExempt('')).toBe(false); + expect(isIpBanExempt('not-an-ip')).toBe(false); + }); +}); diff --git a/fluxer_docs/src/content/docs/operator/configuration.mdx b/fluxer_docs/src/content/docs/operator/configuration.mdx index 39ddadc05..dcee06a66 100644 --- a/fluxer_docs/src/content/docs/operator/configuration.mdx +++ b/fluxer_docs/src/content/docs/operator/configuration.mdx @@ -323,7 +323,7 @@ No default. Legacy unprefixed aliases. Accepted only by `admin` and `app-proxy`, #### `FLUXER_API_IP_BAN_EXEMPT_IPS` -Default empty. Addresses exempt from IP bans. Comma separated. Every entry must parse as an IP or the API fails at boot. +Default empty. Addresses and CIDR ranges exempt from IP bans. Comma separated. A bare IPv4 address exempts that address, a bare IPv6 address exempts its /64, and a CIDR range such as `2001:db8:1200::/56` exempts every address in it. Every entry must parse as an IP or a CIDR range or the API fails at boot. 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.