mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15: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
|
// 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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user