mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(api): exempt configured ASNs from abusive IP auto-bans (#2833)
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user