feat(api): add IP ban exemptions (#1448)

This commit is contained in:
Hampus authored and GitHub committed 2026-07-23 18:06:34 +02:00
1 parent 311a1addce
commit fb5fe38d52
12 files changed
+88 -1

No files matched your search

+14
View File
@@ -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<string>): Array<string> {
const normalized = new Set<string>();
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,
@@ -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<boolean> {
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<void> {
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(
@@ -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,
+1
View File
@@ -33,6 +33,7 @@ export type APIGeoipConfig = APIGeoipFilesystemConfig | APIGeoipS3Config;
export interface APIConfig {
nodeEnv: 'development' | 'production';
port: number;
ipBanExemptIps: Array<string>;
cassandra: {
hosts: string;
port: number;
@@ -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<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
@@ -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<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true;
}
@@ -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);
@@ -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});
}
@@ -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<string> | null = null;
function getExemptDecisionKeys(): ReadonlySet<string> {
if (exemptDecisionKeys) {
return exemptDecisionKeys;
}
const keys = new Set<string>();
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);
}