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 // SPDX-License-Identifier: AGPL-3.0-or-later
import type {MasterConfig} from '@fluxer/config/src/MasterConfig'; 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 {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig'; import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
import type {WorkerTaskName} from './worker/WorkerLaneConfig'; import type {WorkerTaskName} from './worker/WorkerLaneConfig';
@@ -51,6 +52,18 @@ function resolveTrustClientIpHeader(proxyConfig: object): boolean {
return false; 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( function mapPushProviderApps(
apps: apps:
| Array<{ | Array<{
@@ -107,6 +120,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
return { return {
nodeEnv: master.env === 'test' ? 'development' : master.env, nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port, port: master.services.api.port,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
cassandra: { cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '', hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042, port: cassandraSource?.port ?? 9042,
@@ -13,6 +13,7 @@ import type {
BannedUrlRow, BannedUrlRow,
} from '../database/types/AdminArchiveTypes'; } from '../database/types/AdminArchiveTypes';
import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService'; import {isAccountPolicyContactDomainReputationExempt} from '../risk/AccountPolicyService';
import {isIpBanExempt} from '../risk/IpBanExemptions';
import { import {
AdminAuditLogs, AdminAuditLogs,
BannedAvatarHashes, BannedAvatarHashes,
@@ -129,6 +130,9 @@ export class AdminRepository implements IAdminRepository {
} }
async isIpBanned(ip: string): Promise<boolean> { async isIpBanned(ip: string): Promise<boolean> {
if (isIpBanExempt(ip)) {
return false;
}
const candidate = tryParseSingleIp(ip); const candidate = tryParseSingleIp(ip);
if (!candidate) { if (!candidate) {
return false; return false;
@@ -157,6 +161,9 @@ export class AdminRepository implements IAdminRepository {
} }
async banIp(ip: string): Promise<void> { async banIp(ip: string): Promise<void> {
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip); const canonicalIp = canonicalizeBannedIpEntry(ip);
await upsertOne( await upsertOne(
BannedIps.insert({ BannedIps.insert({
@@ -173,6 +180,9 @@ export class AdminRepository implements IAdminRepository {
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) { if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
throw new RangeError('Temporary IP ban TTL must be a positive integer'); throw new RangeError('Temporary IP ban TTL must be a positive integer');
} }
if (isIpBanExempt(ip)) {
return;
}
const canonicalIp = canonicalizeBannedIpEntry(ip); const canonicalIp = canonicalizeBannedIpEntry(ip);
await upsertOne( await upsertOne(
BannedIps.insertWithTtl( BannedIps.insertWithTtl(
@@ -30,6 +30,7 @@ import {
hasHighCgnatBlastRadiusRisk, hasHighCgnatBlastRadiusRisk,
isSingleIpBanCandidate, isSingleIpBanCandidate,
} from '../../risk/IpBanCgnatGuard'; } from '../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../risk/IpBanExemptions';
import type {ISuspiciousIpRepository} from '../../risk/SuspiciousIpRepository'; import type {ISuspiciousIpRepository} from '../../risk/SuspiciousIpRepository';
import {tryParseSingleIp} from '../../utils/IpRangeUtils'; import {tryParseSingleIp} from '../../utils/IpRangeUtils';
import {canonicalizeStoredPhrase} from '../../utils/PhraseBlocklistNormalization'; import {canonicalizeStoredPhrase} from '../../utils/PhraseBlocklistNormalization';
@@ -81,6 +82,17 @@ export class AdminBanManagementService {
) { ) {
const {adminRepository, auditService} = this.deps; const {adminRepository, auditService} = this.deps;
const {cache: cacheService} = this.deps.apiContext.services; 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)) { if (await this.shouldSkipIpBanForCgnat(data.ip)) {
await auditService.createAuditLog({ await auditService.createAuditLog({
adminUserId, adminUserId,
+1
View File
@@ -33,6 +33,7 @@ export type APIGeoipConfig = APIGeoipFilesystemConfig | APIGeoipS3Config;
export interface APIConfig { export interface APIConfig {
nodeEnv: 'development' | 'production'; nodeEnv: 'development' | 'production';
port: number; port: number;
ipBanExemptIps: Array<string>;
cassandra: { cassandra: {
hosts: string; hosts: string;
port: number; port: number;
@@ -20,6 +20,7 @@ import {Logger} from '../../Logger';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware'; import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {GuildBan} from '../../models/GuildBan'; import type {GuildBan} from '../../models/GuildBan';
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard'; import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../risk/IpBanExemptions';
import type {IUserRepository} from '../../user/IUserRepository'; import type {IUserRepository} from '../../user/IUserRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig'; import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
import type {GuildAuditLogService} from '../GuildAuditLogService'; import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -79,7 +80,7 @@ export class GuildModerationService {
days: deleteMessageDays, days: deleteMessageDays,
}); });
} }
const targetIp = targetUser.lastActiveIp || null; const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null;
const targetEmail = targetUser.email?.toLowerCase() || null; const targetEmail = targetUser.email?.toLowerCase() || null;
let expiresAt: Date | null = null; let expiresAt: Date | null = null;
if (banDurationSeconds && banDurationSeconds > 0) { if (banDurationSeconds && banDurationSeconds > 0) {
@@ -216,6 +217,9 @@ export class GuildModerationService {
userIp: string | null | undefined, userIp: string | null | undefined,
bannedIp: string | null | undefined, bannedIp: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true; return true;
} }
@@ -15,6 +15,7 @@ import {guildIdToRoleId} from '../../../BrandedTypes';
import {Logger} from '../../../Logger'; import {Logger} from '../../../Logger';
import type {GuildMember} from '../../../models/GuildMember'; import type {GuildMember} from '../../../models/GuildMember';
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard'; import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../../risk/IpBanExemptions';
import type {IUserRepository} from '../../../user/IUserRepository'; import type {IUserRepository} from '../../../user/IUserRepository';
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate'; import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
@@ -107,6 +108,9 @@ export class GuildMemberValidationService {
userIp: string | null | undefined, userIp: string | null | undefined,
bannedIp: string | null | undefined, bannedIp: string | null | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (isIpBanExempt(userIp)) {
return false;
}
if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) { if (!userIp || !bannedIp || !isSingleIpBanCandidate(bannedIp)) {
return true; return true;
} }
@@ -10,6 +10,7 @@ import {AdminRepository} from '../admin/AdminRepository';
import {Config} from '../Config'; import {Config} from '../Config';
import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan'; import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan';
import {Logger} from '../Logger'; import {Logger} from '../Logger';
import {isIpBanExempt} from '../risk/IpBanExemptions';
import type {HonoEnv} from '../types/HonoEnv'; import type {HonoEnv} from '../types/HonoEnv';
import {parseJsonRecord} from '../utils/JsonBoundaryUtils'; import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
import {ipBanCache} from './IpBanMiddleware'; import {ipBanCache} from './IpBanMiddleware';
@@ -118,6 +119,7 @@ function normalizeSignalIp(ip: string | null): AbuseSignalIp | null {
const parsed = parseIpAddress(ip); const parsed = parseIpAddress(ip);
if (!parsed) return null; if (!parsed) return null;
if (!isPublicIpAddress(parsed.normalized)) return null; if (!isPublicIpAddress(parsed.normalized)) return null;
if (isIpBanExempt(parsed.normalized)) return null;
const banKey = getSameIpDecisionKey(parsed.normalized) ?? parsed.normalized; const banKey = getSameIpDecisionKey(parsed.normalized) ?? parsed.normalized;
return {banKey, lookupIp: parsed.normalized}; return {banKey, lookupIp: parsed.normalized};
} }
@@ -324,6 +326,9 @@ async function executeAutoBan(
if (!isPublicIpAddress(ctx.lookupIp)) { if (!isPublicIpAddress(ctx.lookupIp)) {
return; return;
} }
if (isIpBanExempt(ctx.lookupIp)) {
return;
}
try { try {
await adminRepository.banIpTemp(key, AUTO_BAN_TTL_SECONDS); await adminRepository.banIpTemp(key, AUTO_BAN_TTL_SECONDS);
ipBanCache.banTemp(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 {Config} from '../Config';
import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan'; import {IP_BAN_REFRESH_CHANNEL} from '../constants/IpBan';
import {Logger} from '../Logger'; import {Logger} from '../Logger';
import {isIpBanExempt} from '../risk/IpBanExemptions';
import type {HonoEnv} from '../types/HonoEnv'; import type {HonoEnv} from '../types/HonoEnv';
import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils'; import {parseIpBanEntry, tryParseSingleIp} from '../utils/IpRangeUtils';
@@ -157,6 +158,7 @@ class IpBanCache {
} }
getMatch(ip: string): IpBanMatch | null { getMatch(ip: string): IpBanMatch | null {
if (isIpBanExempt(ip)) return null;
const parsed = tryParseSingleIp(ip); const parsed = tryParseSingleIp(ip);
if (!parsed) return null; if (!parsed) return null;
const sameIpDecisionKey = getSameIpDecisionKey(parsed.canonical); const sameIpDecisionKey = getSameIpDecisionKey(parsed.canonical);
@@ -193,10 +195,12 @@ class IpBanCache {
} }
ban(ip: string): void { ban(ip: string): void {
if (isIpBanExempt(ip)) return;
this.addEntry(ip, PERMANENT_BAN_METADATA); this.addEntry(ip, PERMANENT_BAN_METADATA);
} }
banTemp(ip: string, ttlSeconds: number): void { banTemp(ip: string, ttlSeconds: number): void {
if (isIpBanExempt(ip)) return;
const expiresAt = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? new Date(Date.now() + ttlSeconds * 1000) : null; const expiresAt = Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? new Date(Date.now() + ttlSeconds * 1000) : null;
this.addEntry(ip, {kind: 'temporary_24h', expiresAt}); 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);
}
+1
View File
@@ -91,6 +91,7 @@ function defaultConfig(): MasterConfig {
services: { services: {
api: { api: {
port: 8080, port: 8080,
ip_ban_exempt_ips: [],
presigned_attachment_uploads_enabled: false, presigned_attachment_uploads_enabled: false,
unfurl_ignored_hosts: [], unfurl_ignored_hosts: [],
embeds: { embeds: {
+1
View File
@@ -82,6 +82,7 @@ export interface MasterConfig {
services: { services: {
api: { api: {
port: number; port: number;
ip_ban_exempt_ips: Array<string>;
presigned_attachment_uploads_enabled: boolean; presigned_attachment_uploads_enabled: boolean;
unfurl_ignored_hosts: Array<string>; unfurl_ignored_hosts: Array<string>;
embeds: { embeds: {
@@ -70,6 +70,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']}, FLUXER_NATS_JETSTREAM_URL: {path: ['services', 'nats', 'jetstream_url']},
FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']}, FLUXER_NATS_AUTH_TOKEN: {path: ['services', 'nats', 'auth_token']},
FLUXER_API_PORT: {path: ['services', 'api', 'port'], parse: parseEnvValue}, 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: { FLUXER_API_PRESIGNED_ATTACHMENT_UPLOADS_ENABLED: {
path: ['services', 'api', 'presigned_attachment_uploads_enabled'], path: ['services', 'api', 'presigned_attachment_uploads_enabled'],
parse: parseEnvValue, parse: parseEnvValue,