// SPDX-License-Identifier: AGPL-3.0-or-later import {createHash, randomUUID} from 'node:crypto'; import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp'; import {getSameIpDecisionKey, isPublicIpAddress, parseIpAddress} from '@fluxer/ip_utils/src/IpAddress'; import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService'; import type {IKVProvider, IKVSubscription} from '@pkgs/kv_client/src/IKVProvider'; import {createMiddleware} from 'hono/factory'; import {AdminRepository} from '../admin/AdminRepository'; import {Config} from '../Config'; import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan'; import {Logger} from '../Logger'; import {isIpBanExempt} from '../risk/IpBanExemptions'; import type {HonoEnv} from '../types/HonoEnv'; import {parseJsonRecord} from '../utils/JsonBoundaryUtils'; import {ipBanCache} from './IpBanMiddleware'; import {getIpInfoService} from './ServiceMiddleware'; import {getCacheService} from './ServiceSingletons'; type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown'; type TriggerKind = 'score' | 'token_diversity' | 'score_and_token_diversity'; interface AbuseRecord { score: number; windowStartMs: number; autoBanFired: boolean; triggeringReason: string | null; lookupIp: string; distinctTokenHashes: Set; } interface OutboundEntry { scoreDelta: number; lookupIp: string; newTokenHashes: Set; } interface PersistentScoreState { count: number; lastWindowStartMs: number; expiresAtMs: number; } interface AbuseSignalIp { banKey: string; lookupIp: string; } interface AbuseSignalOptions { tokenHash?: string; weight?: number; } const WINDOW_MS = positiveNumberFromEnv('FLUXER_ABUSE_WINDOW_MS', 60_000); const THRESHOLD_DATACENTER = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_DATACENTER', 20); const THRESHOLD_ANONYMOUS = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_ANONYMOUS', 500); const THRESHOLD_MOBILE = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_MOBILE', 1000); const THRESHOLD_RESIDENTIAL = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_RESIDENTIAL', 100); const TOKEN_DIVERSITY_DATACENTER = positiveNumberFromEnv('FLUXER_ABUSE_TOKEN_DIVERSITY_DATACENTER', 10); const TOKEN_DIVERSITY_ANONYMOUS = positiveNumberFromEnv('FLUXER_ABUSE_TOKEN_DIVERSITY_ANONYMOUS', 50); const TOKEN_DIVERSITY_MOBILE = positiveNumberFromEnv('FLUXER_ABUSE_TOKEN_DIVERSITY_MOBILE', 100); const TOKEN_DIVERSITY_RESIDENTIAL = positiveNumberFromEnv('FLUXER_ABUSE_TOKEN_DIVERSITY_RESIDENTIAL', 10); const AUTO_BAN_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_BAN_TTL_SEC', 86_400); const BATCH_FLUSH_MS = positiveNumberFromEnv('FLUXER_ABUSE_BATCH_FLUSH_MS', 500); const MAX_BATCH_TICKS = positiveNumberFromEnv('FLUXER_ABUSE_MAX_BATCH_TICKS', 5000); const MAX_NEW_TOKENS_PER_TICK = positiveNumberFromEnv('FLUXER_ABUSE_MAX_NEW_TOKENS_PER_TICK', 20); const MAX_TRACKED_IPS = positiveNumberFromEnv('FLUXER_ABUSE_MAX_TRACKED_IPS', 100_000); const MAX_TOKEN_HASHES_PER_IP = positiveNumberFromEnv('FLUXER_ABUSE_MAX_TOKEN_HASHES_PER_IP', 100); const MIN_SCORE_FOR_IP_LOOKUP = positiveNumberFromEnv('FLUXER_ABUSE_MIN_SCORE_FOR_LOOKUP', 20); const MIN_TOKENS_FOR_IP_LOOKUP = positiveNumberFromEnv('FLUXER_ABUSE_MIN_TOKENS_FOR_LOOKUP', 10); const REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN = positiveNumberFromEnv( 'FLUXER_ABUSE_REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN', 3, ); const REPLICATION_CHANNEL = 'abuse_tracker:ticks'; const POD_ID = process.env.HOSTNAME ?? randomUUID(); type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array, lookupIp: string]; interface ReplicationMessage { sender: string; ticks: Array; ts: number; } const records = new Map(); const outboundDeltas = new Map(); const persistentScoreWindows = new Map(); const ipClassCache = new Map(); const ipClassPending = new Set(); const recordedClientErrorRequests = new WeakSet(); const pendingAutoBanTasks = new Set>(); const adminRepository = new AdminRepository(); let kvPublisher: IKVProvider | null = null; let flushTimer: NodeJS.Timeout | null = null; let kvSubscription: IKVSubscription | null = null; let messageHandler: ((channel: string, message: string) => void) | null = null; let errorHandler: ((error: Error) => void) | null = null; function positiveNumberFromEnv(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) return fallback; const parsed = Number(raw); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } function clientErrorWeight(status: number): number { if (status === 429) return 3; if (status === 401) return 0.75; if (status === 403) return 0.5; if (status === 404) return 0.25; if (status >= 400 && status < 500) return 0.25; return 0; } function normalizeSignalIp(ip: string | null): AbuseSignalIp | null { if (!ip) return null; const parsed = parseIpAddress(ip); if (!parsed) return null; if (!isPublicIpAddress(parsed.normalized)) return null; if (isIpBanExempt(parsed.normalized)) return null; const banKey = getSameIpDecisionKey(parsed.normalized) ?? parsed.normalized; return {banKey, lookupIp: parsed.normalized}; } function classifyIpInfo(result: IpInfoLookupResult): IpClass { if (!result.available) return 'unknown'; if (result.flags.isMobile || result.mobile.name) return 'mobile'; if (result.anonymous.isAnonymous) return 'anonymous'; if (result.flags.isHosting) return 'datacenter'; return 'residential'; } function scoreThresholdFor(ipClass: IpClass): number { switch (ipClass) { case 'datacenter': return THRESHOLD_DATACENTER; case 'anonymous': return THRESHOLD_ANONYMOUS; case 'mobile': return THRESHOLD_MOBILE; case 'residential': case 'unknown': return THRESHOLD_RESIDENTIAL; } } function tokenDiversityThresholdFor(ipClass: IpClass): number { switch (ipClass) { case 'datacenter': return TOKEN_DIVERSITY_DATACENTER; case 'anonymous': return TOKEN_DIVERSITY_ANONYMOUS; case 'mobile': return TOKEN_DIVERSITY_MOBILE; case 'residential': case 'unknown': return TOKEN_DIVERSITY_RESIDENTIAL; } } function shouldSkipAutoBanForIpClass(ipClass: IpClass): boolean { return ipClass === 'mobile'; } function pruneIfNeeded(now: number): void { if (records.size < MAX_TRACKED_IPS) return; for (const [key, rec] of records) { if (rec.windowStartMs + WINDOW_MS < now) { records.delete(key); ipClassCache.delete(key); ipClassPending.delete(key); } if (records.size < MAX_TRACKED_IPS * 0.9) return; } } function getOrResetRecord(signalIp: AbuseSignalIp, now: number, reason: string): AbuseRecord { let rec = records.get(signalIp.banKey); if (!rec || rec.windowStartMs + WINDOW_MS < now) { pruneIfNeeded(now); rec = { score: 0, windowStartMs: now, autoBanFired: false, triggeringReason: reason, lookupIp: signalIp.lookupIp, distinctTokenHashes: new Set(), }; records.set(signalIp.banKey, rec); return rec; } rec.lookupIp = signalIp.lookupIp; if (!rec.triggeringReason) rec.triggeringReason = reason; return rec; } function getOrInitOutbound(signalIp: AbuseSignalIp): OutboundEntry { let entry = outboundDeltas.get(signalIp.banKey); if (!entry) { entry = {scoreDelta: 0, lookupIp: signalIp.lookupIp, newTokenHashes: new Set()}; outboundDeltas.set(signalIp.banKey, entry); return entry; } entry.lookupIp = signalIp.lookupIp; return entry; } function queueOutboundDelta( signalIp: AbuseSignalIp, weight: number, tokenHash: string | undefined, hadToken: boolean, ): void { if (!kvPublisher) return; if (!outboundDeltas.has(signalIp.banKey) && outboundDeltas.size >= MAX_TRACKED_IPS) return; const outbound = getOrInitOutbound(signalIp); outbound.scoreDelta += weight; if (tokenHash && !hadToken) { outbound.newTokenHashes.add(tokenHash); } } function shouldEnsureIpClassLookup(key: string, rec: AbuseRecord): boolean { if (ipClassCache.has(key) || ipClassPending.has(key)) return false; return rec.score >= MIN_SCORE_FOR_IP_LOOKUP || rec.distinctTokenHashes.size >= MIN_TOKENS_FOR_IP_LOOKUP; } function markScoreThresholdWindow(key: string, rec: AbuseRecord, now: number): number { const expiresAtMs = rec.windowStartMs + WINDOW_MS * (REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN + 2); let state = persistentScoreWindows.get(key); if (!state || state.expiresAtMs <= now) { state = { count: 1, lastWindowStartMs: rec.windowStartMs, expiresAtMs, }; persistentScoreWindows.set(key, state); return state.count; } state.expiresAtMs = expiresAtMs; if (state.lastWindowStartMs !== rec.windowStartMs) { state.count += 1; state.lastWindowStartMs = rec.windowStartMs; } return state.count; } function ensureIpClassLookup(key: string, lookupIp: string): void { if (ipClassCache.has(key) || ipClassPending.has(key)) return; ipClassPending.add(key); void (async () => { try { const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'}); ipClassCache.set(key, classifyIpInfo(result)); } catch (err) { ipClassCache.set(key, 'unknown'); Logger.warn({err, ip: lookupIp}, '[abuse-auto-ban] IP classification lookup failed'); } finally { ipClassPending.delete(key); const rec = records.get(key); if (rec) maybeFireAutoBan(key, rec); } })(); } function maybeFireAutoBan(key: string, rec: AbuseRecord): void { if (rec.autoBanFired) return; const ipClass = ipClassCache.get(key) ?? 'unknown'; const scoreThreshold = scoreThresholdFor(ipClass); const tokenThreshold = tokenDiversityThresholdFor(ipClass); const overScore = rec.score >= scoreThreshold; const overTokenDiversity = rec.distinctTokenHashes.size >= tokenThreshold; if (!overScore && !overTokenDiversity) return; if (!ipClassCache.has(key) && ipClassPending.has(key)) { return; } if (shouldSkipAutoBanForIpClass(ipClass)) { rec.autoBanFired = true; Logger.warn( {ip: key, ipClass, score: rec.score, distinctTokens: rec.distinctTokenHashes.size}, '[abuse-auto-ban] Skipping automatic IP ban because the IP class has high CGNAT blast-radius risk', ); return; } if (overScore && !overTokenDiversity) { const persistentWindows = markScoreThresholdWindow(key, rec, Date.now()); if (persistentWindows < REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN) { return; } } rec.autoBanFired = true; const trigger: TriggerKind = overScore ? overTokenDiversity ? 'score_and_token_diversity' : 'score' : 'token_diversity'; const task = executeAutoBan(key, { lookupIp: rec.lookupIp, ipClass, score: rec.score, scoreThreshold, distinctTokens: rec.distinctTokenHashes.size, tokenDiversityThreshold: tokenThreshold, triggeringReason: rec.triggeringReason ?? 'unknown', trigger, }); pendingAutoBanTasks.add(task); task.finally(() => pendingAutoBanTasks.delete(task)); } async function executeAutoBan( key: string, ctx: { lookupIp: string; ipClass: IpClass; score: number; scoreThreshold: number; distinctTokens: number; tokenDiversityThreshold: number; triggeringReason: string; trigger: TriggerKind; }, ): Promise { if (!isPublicIpAddress(ctx.lookupIp)) { return; } if (isIpBanExempt(ctx.lookupIp)) { return; } try { await adminRepository.banIpTemp(key, AUTO_BAN_TTL_SECONDS); ipBanCache.banTemp(key, AUTO_BAN_TTL_SECONDS); await getCacheService().publish(IP_BAN_REFRESH_CHANNEL, 'refresh'); Logger.warn( { ip: key, ttlSeconds: AUTO_BAN_TTL_SECONDS, windowMs: WINDOW_MS, ...ctx, }, '[abuse-auto-ban] Auto-banning abusive IP', ); } catch (err) { const rec = records.get(key); if (rec) rec.autoBanFired = false; Logger.error({err, ip: key}, '[abuse-auto-ban] Auto-ban write failed; will retry on next signal'); } } export function hashAuthToken(token: string): string { return createHash('sha256').update(token).digest('hex').slice(0, 32); } export function recordAbuseSignal(ip: string | null, reason: string, opts: AbuseSignalOptions = {}): void { const signalIp = normalizeSignalIp(ip); if (!signalIp) return; const weight = opts.weight ?? 1; if (!Number.isFinite(weight) || weight <= 0) return; const now = Date.now(); const rec = getOrResetRecord(signalIp, now, reason); rec.score += weight; let hadToken = false; let queuedTokenHash: string | undefined; if (opts.tokenHash && rec.distinctTokenHashes.size < MAX_TOKEN_HASHES_PER_IP) { hadToken = rec.distinctTokenHashes.has(opts.tokenHash); rec.distinctTokenHashes.add(opts.tokenHash); queuedTokenHash = opts.tokenHash; } queueOutboundDelta(signalIp, weight, queuedTokenHash, hadToken); if (shouldEnsureIpClassLookup(signalIp.banKey, rec)) { ensureIpClassLookup(signalIp.banKey, signalIp.lookupIp); } maybeFireAutoBan(signalIp.banKey, rec); } export function recordHttpClientError(request: Request, status: number, reason = `http_${status}`): void { const weight = clientErrorWeight(status); if (weight <= 0) return; if (recordedClientErrorRequests.has(request)) return; recordedClientErrorRequests.add(request); const clientIp = extractClientIp(request, { trustClientIpHeader: Config.proxy.trust_client_ip_header, clientIpHeaderName: Config.proxy.client_ip_header, }); recordAbuseSignal(clientIp, reason, {weight}); } export const ClientErrorAbuseSignalMiddleware = createMiddleware(async (ctx, next) => { await next(); if (ctx.get('user')) return; recordHttpClientError(ctx.req.raw, ctx.res.status); }); function applyReplicatedTick(tick: ReplicatedTick): void { const [banKey, scoreDelta, tokenHashes, lookupIp] = tick; const signalIp = normalizeSignalIp(lookupIp); if (!signalIp || signalIp.banKey !== banKey) return; const now = Date.now(); const rec = getOrResetRecord(signalIp, now, 'replicated'); rec.score += scoreDelta; for (const tokenHash of tokenHashes) { if (rec.distinctTokenHashes.size >= MAX_TOKEN_HASHES_PER_IP) break; rec.distinctTokenHashes.add(tokenHash); } if (shouldEnsureIpClassLookup(banKey, rec)) { ensureIpClassLookup(banKey, lookupIp); } maybeFireAutoBan(banKey, rec); } async function flushOutbound(): Promise { if (!kvPublisher || outboundDeltas.size === 0) return; const ticks: Array = []; const selectedKeys: Array = []; for (const [key, entry] of outboundDeltas) { const tokenHashes: Array = []; for (const tokenHash of entry.newTokenHashes) { tokenHashes.push(tokenHash); if (tokenHashes.length >= MAX_NEW_TOKENS_PER_TICK) break; } ticks.push([key, entry.scoreDelta, tokenHashes, entry.lookupIp]); selectedKeys.push(key); if (ticks.length >= MAX_BATCH_TICKS) break; } const message: ReplicationMessage = {sender: POD_ID, ticks, ts: Date.now()}; try { await kvPublisher.publish(REPLICATION_CHANNEL, JSON.stringify(message)); for (const key of selectedKeys) { outboundDeltas.delete(key); } } catch (err) { Logger.warn({err, tickCount: ticks.length}, '[abuse-auto-ban] Failed to publish abuse replication batch'); } } function handleReplicationMessage(channel: string, message: string): void { if (channel !== REPLICATION_CHANNEL) return; const msg = parseJsonRecord(message); if (!msg || msg.sender === POD_ID || !Array.isArray(msg.ticks)) return; for (const rawTick of msg.ticks) { if (!Array.isArray(rawTick) || rawTick.length < 4) continue; const [banKey, scoreDelta, rawTokenHashes, lookupIp] = rawTick; if ( typeof banKey !== 'string' || typeof scoreDelta !== 'number' || !Number.isFinite(scoreDelta) || scoreDelta <= 0 || typeof lookupIp !== 'string' ) { continue; } const tokenHashes = Array.isArray(rawTokenHashes) ? rawTokenHashes.filter((tokenHash): tokenHash is string => typeof tokenHash === 'string') : []; applyReplicatedTick([banKey, scoreDelta, tokenHashes, lookupIp]); } } export async function startAbuseReplicationSubscriber(kvClient: IKVProvider | null): Promise { if (!kvClient || kvSubscription) return; kvPublisher = kvClient; const subscription = kvClient.duplicate(); kvSubscription = subscription; messageHandler = handleReplicationMessage; errorHandler = (error: Error) => { Logger.warn({error}, '[abuse-auto-ban] Abuse replication subscription error'); }; try { await subscription.connect(); await subscription.subscribe(REPLICATION_CHANNEL); subscription.on('message', messageHandler); subscription.on('error', errorHandler); flushTimer = setInterval(() => { void flushOutbound(); }, BATCH_FLUSH_MS); if (typeof flushTimer === 'object' && flushTimer && 'unref' in flushTimer) { (flushTimer as {unref(): void}).unref(); } } catch (err) { Logger.error({err}, '[abuse-auto-ban] Failed to start abuse replication subscriber'); kvSubscription = null; messageHandler = null; errorHandler = null; kvPublisher = null; throw err; } } export async function stopAbuseReplicationSubscriber(): Promise { if (flushTimer) { clearInterval(flushTimer); flushTimer = null; } if (kvSubscription && messageHandler) { kvSubscription.off('message', messageHandler); } if (kvSubscription && errorHandler) { kvSubscription.off('error', errorHandler); } if (kvSubscription) { try { await kvSubscription.disconnect(); } catch {} } kvSubscription = null; messageHandler = null; errorHandler = null; kvPublisher = null; } export async function drainAbuseAutoBanTasksForTests(): Promise { await Promise.all([...pendingAutoBanTasks]); } export function resetAbuseTrackingForTests(): void { records.clear(); outboundDeltas.clear(); persistentScoreWindows.clear(); ipClassCache.clear(); ipClassPending.clear(); pendingAutoBanTasks.clear(); }