mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(api): add IP ban exemptions (#1448)
This commit is contained in:
1 parent
311a1addce
commit
fb5fe38d52
12 files changed
+88
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user