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);
});
});