feat(api): accept CIDR ranges in FLUXER_API_IP_BAN_EXEMPT_IPS (#2988)

This commit is contained in:
Hampus
2026-09-27 21:19:35 +02:00
committed by GitHub
parent fa3fd0027c
commit 9def9fbef6
4 changed files with 106 additions and 15 deletions
+9
View File
@@ -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<string>): Array<string> {
const normalized = new Set<string>();
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}`);
+49 -14
View File
@@ -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<string> | null = null;
interface ExemptRange {
family: IpAddressFamily;
start: bigint;
end: bigint;
}
function getExemptDecisionKeys(): ReadonlySet<string> {
if (exemptDecisionKeys) {
return exemptDecisionKeys;
interface IpBanExemptions {
decisionKeys: ReadonlySet<string>;
ranges: ReadonlyArray<ExemptRange>;
}
let exemptions: IpBanExemptions | null = null;
function getExemptions(): IpBanExemptions {
if (exemptions) {
return exemptions;
}
const keys = new Set<string>();
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<string>();
const ranges: Array<ExemptRange> = [];
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;
}
@@ -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<string>;
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);
});
});
@@ -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.