feat(api): exempt configured ASNs from abusive IP auto-bans (#2833)

This commit is contained in:
Hampus
2026-09-18 23:01:58 +02:00
committed by GitHub
parent 3cec27ba57
commit efd677f32b
5 changed files with 128 additions and 2 deletions
@@ -9,6 +9,7 @@ import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {getIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {getKVClient} from '@app/api/middleware/ServiceRegistry';
import {getCacheService} from '@app/api/middleware/ServiceSingletons';
import {isAutoBanExemptAsn} from '@app/api/risk/AutoBanAsnExemptions';
import {isIpBanExempt} from '@app/api/risk/IpBanExemptions';
import type {HonoEnv} from '@app/api/types/HonoEnv';
import {parseJsonRecord} from '@app/api/utils/JsonBoundaryUtils';
@@ -18,7 +19,7 @@ import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider';
import {createMiddleware} from 'hono/factory';
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown';
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown' | 'exempt';
type TriggerKind = 'score' | 'token_diversity' | 'score_and_token_diversity';
interface AbuseRecord {
@@ -104,7 +105,7 @@ const IP_CLASS_CLAIM_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_
const DEFAULT_IP_CLASS_PENDING_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_PENDING_TTL_MS', 20_000);
const DEFAULT_IP_CLASS_NEGATIVE_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS', 300_000);
const DEFAULT_IP_CLASS_HINT_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS', 600_000);
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown'] as const;
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown', 'exempt'] as const;
const POD_ID = process.env.HOSTNAME ?? randomUUID();
type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array<string>, lookupIp: string];
@@ -185,6 +186,7 @@ function scoreThresholdFor(ipClass: IpClass): number {
return THRESHOLD_MOBILE;
case 'residential':
case 'unknown':
case 'exempt':
return THRESHOLD_RESIDENTIAL;
}
}
@@ -199,6 +201,7 @@ function tokenDiversityThresholdFor(ipClass: IpClass): number {
return TOKEN_DIVERSITY_MOBILE;
case 'residential':
case 'unknown':
case 'exempt':
return TOKEN_DIVERSITY_RESIDENTIAL;
}
}
@@ -390,6 +393,10 @@ async function claimIpClassLookup(key: string): Promise<boolean> {
async function runIpClassLookup(key: string, lookupIp: string): Promise<void> {
try {
if (await isAutoBanExemptAsn(lookupIp)) {
setOwnIpClass(key, lookupIp, 'exempt', false);
return;
}
if (!(await claimIpClassLookup(key))) return;
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
setOwnIpClass(key, lookupIp, classifyIpInfo(result), !result.available);
@@ -427,6 +434,14 @@ function maybeFireAutoBan(key: string, rec: AbuseRecord): void {
if (resolved.blocked) {
return;
}
if (ipClass === 'exempt') {
rec.autoBanFired = true;
Logger.warn(
{ip: key, ipClass, score: rec.score, distinctTokens: rec.distinctTokenHashes.size},
'[abuse-auto-ban] Skipping automatic IP ban because the ASN is exempt',
);
return;
}
if (shouldSkipAutoBanForIpClass(ipClass)) {
rec.autoBanFired = true;
Logger.warn(
@@ -13,13 +13,19 @@ import {
} from '@app/api/middleware/AbusiveIpAutoBanner';
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
import {resetAutoBanAsnExemptionsForTesting, setInjectedAutoBanAsnLookup} from '@app/api/risk/AutoBanAsnExemptions';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import type {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {GeoipAsnResult} from '@pkgs/geoip/src/GeoipLookup';
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
function asnResult(asn: number | null): GeoipAsnResult {
return {normalizedIp: null, asn, asnOrg: null, available: asn !== null};
}
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
ip,
@@ -96,6 +102,7 @@ describe('AbusiveIpAutoBanner', () => {
let harness: ApiTestHarness;
let adminRepository: AdminRepository;
let lookupCount = 0;
let asnLookupCount = 0;
beforeAll(async () => {
harness = await createApiTestHarness();
adminRepository = new AdminRepository();
@@ -104,6 +111,9 @@ describe('AbusiveIpAutoBanner', () => {
await harness.reset();
resetAbuseTrackingForTests();
ipBanCache.resetCaches();
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
resetAutoBanAsnExemptionsForTesting();
asnLookupCount = 0;
lookupCount = 0;
setInjectedIpInfoService({
async lookup(ip: string) {
@@ -117,6 +127,8 @@ describe('AbusiveIpAutoBanner', () => {
afterAll(async () => {
await stopAbuseReplicationSubscriber();
setInjectedIpInfoService(undefined);
delete process.env.FLUXER_ABUSE_EXEMPT_ASNS;
resetAutoBanAsnExemptionsForTesting();
await harness.shutdown();
});
it('temporarily bans an IP that tries many distinct invalid tokens', async () => {
@@ -175,6 +187,49 @@ describe('AbusiveIpAutoBanner', () => {
expect(ipBanCache.isBanned(ip)).toBe(false);
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
});
it('does not auto-ban an IP whose ASN is exempt', async () => {
const ip = '9.9.9.9';
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501, not-an-asn, 64502';
resetAutoBanAsnExemptionsForTesting();
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
for (let i = 0; i < 100; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`exempt-${i}`)});
}
await drainAbuseIpClassLookupsForTests();
await drainAbuseAutoBanTasksForTests();
expect(ipBanCache.isBanned(ip)).toBe(false);
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
expect(lookupCount).toBe(0);
});
it('still auto-bans an IP whose ASN is not on the exempt list', async () => {
const ip = '9.9.9.10';
process.env.FLUXER_ABUSE_EXEMPT_ASNS = '64501';
resetAutoBanAsnExemptionsForTesting();
setInjectedAutoBanAsnLookup(async () => asnResult(64502));
for (let i = 0; i < 10; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`not-exempt-${i}`)});
}
await waitForAssertion(() => {
expect(ipBanCache.isBanned(ip)).toBe(true);
});
await drainAbuseAutoBanTasksForTests();
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(true);
});
it('does not resolve an ASN when no exemptions are configured', async () => {
const ip = '9.9.9.11';
setInjectedAutoBanAsnLookup(async () => {
asnLookupCount += 1;
return asnResult(64502);
});
for (let i = 0; i < 10; i += 1) {
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`no-exempt-list-${i}`)});
}
await waitForAssertion(() => {
expect(ipBanCache.isBanned(ip)).toBe(true);
});
await drainAbuseAutoBanTasksForTests();
expect(asnLookupCount).toBe(0);
});
it('does not auto-ban loopback or private IP addresses', async () => {
for (const ip of ['127.0.0.1', '10.0.0.10', '::ffff:127.0.0.1']) {
for (let i = 0; i < 20; i += 1) {
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '@app/api/Config';
import {type GeoipAsnResult, lookupAsnByIp} from '@pkgs/geoip/src/GeoipLookup';
const ASN_ENTRY_REGEX = /^\d+$/u;
type AsnLookup = (ip: string) => Promise<GeoipAsnResult>;
let exemptAsns: ReadonlySet<number> | null = null;
let injectedAsnLookup: AsnLookup | undefined;
function getExemptAsns(): ReadonlySet<number> {
if (exemptAsns) {
return exemptAsns;
}
const asns = new Set<number>();
const rawValue = process.env.FLUXER_ABUSE_EXEMPT_ASNS;
if (rawValue) {
for (const entry of rawValue.split(',')) {
const trimmed = entry.trim();
if (!ASN_ENTRY_REGEX.test(trimmed)) continue;
const asn = Number.parseInt(trimmed, 10);
if (Number.isSafeInteger(asn) && asn > 0) asns.add(asn);
}
}
exemptAsns = asns;
return asns;
}
function resolveAsn(ip: string): Promise<GeoipAsnResult> {
if (injectedAsnLookup) {
return injectedAsnLookup(ip);
}
return lookupAsnByIp(ip, Config.geoip.maxmindAsnDbPath);
}
export async function isAutoBanExemptAsn(ip: string): Promise<boolean> {
const asns = getExemptAsns();
if (asns.size === 0) return false;
const result = await resolveAsn(ip);
return result.asn !== null && asns.has(result.asn);
}
export function setInjectedAutoBanAsnLookup(lookup: AsnLookup | undefined): void {
injectedAsnLookup = lookup;
}
export function resetAutoBanAsnExemptionsForTesting(): void {
exemptAsns = null;
injectedAsnLookup = undefined;
}
@@ -14,6 +14,7 @@ import {resetServiceSingletonsForTesting} from '@app/api/middleware/ServiceSingl
import {torExitListCache} from '@app/api/middleware/TorExitListCache';
import {urlBlocklistCache} from '@app/api/middleware/UrlBlocklistCache';
import {resetAdminSecretHashForTesting} from '@app/api/oauth/repositories/ApplicationRepository';
import {resetAutoBanAsnExemptionsForTesting} from '@app/api/risk/AutoBanAsnExemptions';
import {resetIpBanExemptionsForTesting} from '@app/api/risk/IpBanExemptions';
import {setThemeCssMaxBytesForTesting} from '@app/api/theme/ThemeService';
import {resetGeoipReadersForTesting} from '@pkgs/geoip/src/GeoipLookup';
@@ -23,6 +24,7 @@ export async function resetServiceStateForTesting(): Promise<void> {
resetServiceSingletonsForTesting();
resetServiceMiddlewareForTesting();
resetIpBanExemptionsForTesting();
resetAutoBanAsnExemptionsForTesting();
resetGlobalLimitConfigServiceForTesting();
resetSudoModeServiceForTesting();
resetSsoRequestUrlPolicyForTesting();
@@ -1011,6 +1011,8 @@ Defaults to the inverse of `FLUXER_SELF_HOSTED`. External blocklist feeds. Off b
A second family, unrelated to the rules above, tunes the IP auto-banner: `FLUXER_ABUSE_WINDOW_MS`, the `FLUXER_ABUSE_THRESHOLD_` names, the `FLUXER_ABUSE_TOKEN_DIVERSITY_` names, `FLUXER_ABUSE_BAN_TTL_SEC`, `FLUXER_ABUSE_BATCH_FLUSH_MS`, `FLUXER_ABUSE_MAX_BATCH_TICKS`, `FLUXER_ABUSE_MAX_NEW_TOKENS_PER_TICK`, `FLUXER_ABUSE_MAX_TRACKED_IPS`, `FLUXER_ABUSE_MAX_TOKEN_HASHES_PER_IP`, `FLUXER_ABUSE_MIN_SCORE_FOR_LOOKUP`, `FLUXER_ABUSE_MIN_TOKENS_FOR_LOOKUP`, and `FLUXER_ABUSE_REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN`. All are read directly from the environment, none are in `.env.example` or the Compose file, and a non-finite value or one at or below zero falls back to the default.
`FLUXER_ABUSE_EXEMPT_ASNS` takes comma-separated ASN numbers that the auto-banner never bans. Non-numeric entries are dropped. Before it buys a classification the auto-banner resolves the IP against the local MaxMind ASN database, and an IP on a listed ASN is neither classified nor banned. With no MaxMind ASN database the list has no effect. It does not change admin or guild IP bans, and `FLUXER_API_IP_BAN_EXEMPT_IPS` remains the way to exempt single addresses and ranges from every kind of IP ban.
Other names in that family limit how often the auto-banner buys an IP classification from ipinfo. A shared claim lets one replica do the lookup for the others. With the claim off, every API replica looks up the same attacking IP at the same moment, so one IP costs one lookup per replica.
- `FLUXER_ABUSE_IP_CLASS_CLAIM_ENABLED` defaults to `1` and gates the shared claim. It is read as a string, and only `0` turns the claim off.