diff --git a/fluxer_api/src/api/Config.ts b/fluxer_api/src/api/Config.ts index 15c911039..6fc813abd 100644 --- a/fluxer_api/src/api/Config.ts +++ b/fluxer_api/src/api/Config.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; +import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress'; import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup'; import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig'; import type {WorkerTaskName} from './worker/WorkerLaneConfig'; @@ -51,6 +52,18 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean { return false; } +function normalizeIpBanExemptIps(values: Array): Array { + const normalized = new Set(); + for (const value of values) { + const parsed = parseIpAddress(value); + if (!parsed) { + throw new Error(`FLUXER_API_IP_BAN_EXEMPT_IPS contains an invalid IP address: ${value}`); + } + normalized.add(parsed.normalized); + } + return Array.from(normalized); +} + function mapPushProviderApps( apps: | Array<{ @@ -107,6 +120,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig { return { nodeEnv: master.env === 'test' ? 'development' : master.env, port: master.services.api.port, + ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips), cassandra: { hosts: cassandraSource?.hosts.join(',') ?? '', port: cassandraSource?.port ?? 9042, diff --git a/fluxer_api/src/api/admin/AdminRepository.ts b/fluxer_api/src/api/admin/AdminRepository.ts index 6c58b1239..418a6f520 100644 --- a/fluxer_api/src/api/admin/AdminRepository.ts +++ b/fluxer_api/src/api/admin/AdminRepository.ts @@ -13,6 +13,7 @@ import type { BannedUrlRow, } from '../database/types/AdminArchiveTypes'; import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService'; +import {isIpBanExempt} from '../risk/IpBanExemptions'; import { AdminAuditLogs, BannedAvatarHashes, @@ -129,6 +130,9 @@ export class AdminRepository implements IAdminRepository { } async isIpBanned(ip: string): Promise { + if (isIpBanExempt(ip)) { + return false; + } const candidate = tryParseSingleIp(ip); if (!candidate) { return false; @@ -157,6 +161,9 @@ export class AdminRepository implements IAdminRepository { } async banIp(ip: string): Promise { + if (isIpBanExempt(ip)) { + return; + } const canonicalIp = canonicalizeBannedIpEntry(ip); await upsertOne( BannedIps.insert({ @@ -173,6 +180,9 @@ export class AdminRepository implements IAdminRepository { if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { throw new RangeError('Temporary IP ban TTL must be a positive integer'); } + if (isIpBanExempt(ip)) { + return; + } const canonicalIp = canonicalizeBannedIpEntry(ip); await upsertOne( BannedIps.insertWithTtl( diff --git a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts index 267c2d4f9..210039c6e 100644 --- a/fluxer_api/src/api/admin/services/AdminBanManagementService.ts +++ b/fluxer_api/src/api/admin/services/AdminBanManagementService.ts @@ -30,6 +30,7 @@ import { hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate, } from '../../risk/IpBanCgnatGuard'; +import {isIpBanExempt} from '../../risk/IpBanExemptions'; import type {ISuspiciousIpRepository} from '../../risk/SuspiciousIpRepository'; import {tryParseSingleIp} from '../../utils/IpRangeUtils'; import {canonicalizeStoredPhrase} from '../../utils/PhraseBlocklistNormalization'; @@ -81,6 +82,17 @@ export class AdminBanManagementService { ) { const {adminRepository, auditService} = this.deps; const {cache: cacheService} = this.deps.apiContext.services; + if (isIpBanExempt(data.ip)) { + await auditService.createAuditLog({ + adminUserId, + targetType: 'ip', + targetId: BigInt(0), + action: 'ban_ip_skipped_exempt', + auditLogReason, + metadata: new Map([['ip', data.ip]]), + }); + return; + } if (await this.shouldSkipIpBanForCgnat(data.ip)) { await auditService.createAuditLog({ adminUserId, diff --git a/fluxer_api/src/api/config/APIConfig.ts b/fluxer_api/src/api/config/APIConfig.ts index f85a080ab..0c7f2afcc 100644 --- a/fluxer_api/src/api/config/APIConfig.ts +++ b/fluxer_api/src/api/config/APIConfig.ts @@ -33,6 +33,7 @@ export type APIGeoipConfig = APIGeoipFilesystemConfig | APIGeoipS3Config; export interface APIConfig { nodeEnv: 'development' | 'production'; port: number; + ipBanExemptIps: Array; cassandra: { hosts: string; port: number; diff --git a/fluxer_api/src/api/guild/services/GuildModerationService.ts b/fluxer_api/src/api/guild/services/GuildModerationService.ts index e6c325bae..cbc80122e 100644 --- a/fluxer_api/src/api/guild/services/GuildModerationService.ts +++ b/fluxer_api/src/api/guild/services/GuildModerationService.ts @@ -20,6 +20,7 @@ import {Logger} from '../../Logger'; import type {RequestCache} from '../../middleware/RequestCacheMiddleware'; import type {GuildBan} from '../../models/GuildBan'; import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard'; +import {isIpBanExempt} from '../../risk/IpBanExemptions'; import type {IUserRepository} from '../../user/IUserRepository'; import type {WorkerTaskName} from '../../worker/WorkerLaneConfig'; import type {GuildAuditLogService} from '../GuildAuditLogService'; @@ -79,7 +80,7 @@ export class GuildModerationService { days: deleteMessageDays, }); } - const targetIp = targetUser.lastActiveIp || null; + const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null; const targetEmail = targetUser.email?.toLowerCase() || null; let expiresAt: Date | null = null; if (banDurationSeconds && banDurationSeconds > 0) { @@ -216,6 +217,9 @@ export class GuildModerationService { userIp: string | null | undefined, bannedIp: string | null | undefined, ): Promise { + if (isIpBanExempt(userIp)) { + return false; + } if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { return true; } diff --git a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts index 5aea00c02..a4858c2e8 100644 --- a/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts +++ b/fluxer_api/src/api/guild/services/member/GuildMemberValidationService.ts @@ -15,6 +15,7 @@ import {guildIdToRoleId} from '../../../BrandedTypes'; import {Logger} from '../../../Logger'; import type {GuildMember} from '../../../models/GuildMember'; import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard'; +import {isIpBanExempt} from '../../../risk/IpBanExemptions'; import type {IUserRepository} from '../../../user/IUserRepository'; import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate'; @@ -107,6 +108,9 @@ export class GuildMemberValidationService { userIp: string | null | undefined, bannedIp: string | null | undefined, ): Promise { + if (isIpBanExempt(userIp)) { + return false; + } if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { return true; } diff --git a/fluxer_api/src/api/middleware/AbusiveIpAutoBanner.ts b/fluxer_api/src/api/middleware/AbusiveIpAutoBanner.ts index cc32f0c6e..6c490e0e0 100644 --- a/fluxer_api/src/api/middleware/AbusiveIpAutoBanner.ts +++ b/fluxer_api/src/api/middleware/AbusiveIpAutoBanner.ts @@ -10,6 +10,7 @@ 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'; @@ -118,6 +119,7 @@ function normalizeSignalIp(ip: string | null): AbuseSignalIp | 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}; } @@ -324,6 +326,9 @@ async function executeAutoBan( 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); diff --git a/fluxer_api/src/api/middleware/IpBanMiddleware.ts b/fluxer_api/src/api/middleware/IpBanMiddleware.ts index 96060bc11..d4c4fda4a 100644 --- a/fluxer_api/src/api/middleware/IpBanMiddleware.ts +++ b/fluxer_api/src/api/middleware/IpBanMiddleware.ts @@ -10,6 +10,7 @@ import type {BannedIpEntry, BannedIpKind} from '../admin/IAdminRepository'; 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 {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils'; @@ -157,6 +158,7 @@ class IpBanCache { } getMatch(ip: string): IpBanMatch | null { + if (isIpBanExempt(ip)) return null; const parsed = tryParseSingleIp(ip); if (!parsed) return null; const sameIpDecisionKey = getSameIpDecisionKey(parsed.canonical); @@ -193,10 +195,12 @@ class IpBanCache { } ban(ip: string): void { + if (isIpBanExempt(ip)) return; this.addEntry(ip, PERMANENT_BAN_METADATA); } banTemp(ip: string, ttlSeconds: number): void { + if (isIpBanExempt(ip)) return; const expiresAt = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? new Date(Date.now() + ttlSeconds * 1000) : null; this.addEntry(ip, {kind: 'temporary_24h', expiresAt}); } diff --git a/fluxer_api/src/api/risk/IpBanExemptions.ts b/fluxer_api/src/api/risk/IpBanExemptions.ts new file mode 100644 index 000000000..f895124b4 --- /dev/null +++ b/fluxer_api/src/api/risk/IpBanExemptions.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress'; +import {Config} from '../Config'; + +let exemptDecisionKeys: ReadonlySet | null = null; + +function getExemptDecisionKeys(): ReadonlySet { + if (exemptDecisionKeys) { + return exemptDecisionKeys; + } + const keys = new Set(); + for (const ip of Config.ipBanExemptIps) { + const key = getSameIpDecisionKey(ip); + if (!key) { + throw new Error(`Invalid IP ban exemption in API config: ${ip}`); + } + keys.add(key); + } + exemptDecisionKeys = keys; + return keys; +} + +export function isIpBanExempt(ip: string | null | undefined): boolean { + if (!ip) { + return false; + } + const key = getSameIpDecisionKey(ip); + return key !== null && getExemptDecisionKeys().has(key); +} diff --git a/packages/config/src/ConfigLoader.ts b/packages/config/src/ConfigLoader.ts index 320a3862e..0ec4dd90f 100644 --- a/packages/config/src/ConfigLoader.ts +++ b/packages/config/src/ConfigLoader.ts @@ -91,6 +91,7 @@ function defaultConfig(): MasterConfig { services: { api: { port: 8080, + ip_ban_exempt_ips: [], presigned_attachment_uploads_enabled: false, unfurl_ignored_hosts: [], embeds: { diff --git a/packages/config/src/MasterConfig.ts b/packages/config/src/MasterConfig.ts index 1e6b6ce77..f02b4c378 100644 --- a/packages/config/src/MasterConfig.ts +++ b/packages/config/src/MasterConfig.ts @@ -82,6 +82,7 @@ export interface MasterConfig { services: { api: { port: number; + ip_ban_exempt_ips: Array; presigned_attachment_uploads_enabled: boolean; unfurl_ignored_hosts: Array; embeds: { diff --git a/packages/config/src/config_loader/EnvironmentOverrides.ts b/packages/config/src/config_loader/EnvironmentOverrides.ts index 686024ab2..5289dbd4f 100644 --- a/packages/config/src/config_loader/EnvironmentOverrides.ts +++ b/packages/config/src/config_loader/EnvironmentOverrides.ts @@ -70,6 +70,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record = { FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']}, FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']}, FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseEnvValue}, + FLUXER_API_IP_BAN_EXEMPT_IPS: {path: ['services', 'api', 'ip_ban_exempt_ips'], parse: parseCsv}, FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: { path: ['services', 'api', 'presigned_attachment_uploads_enabled'], parse: parseEnvValue,