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