mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
refactor(ban): drop ipinfo cgnat blast-radius guard (#3062)
This commit is contained in:
1 parent
ca719e7b5e
commit
af49cd6cc4
45 files changed
+10
-2153
No files matched your search
@@ -41,7 +41,6 @@ import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlem
|
||||
import type {UserService} from '@app/api/user/services/UserService';
|
||||
import type {VoiceRepository} from '@app/api/voice/VoiceRepository';
|
||||
import type {SendSystemDmResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export class AdminService {
|
||||
@@ -81,7 +80,6 @@ export class AdminService {
|
||||
private readonly applicationRepository: IApplicationRepository,
|
||||
private readonly stripe: Stripe | null = null,
|
||||
private readonly jobLedger: IJobLedgerRepository,
|
||||
private readonly ipInfoService: IpInfoService,
|
||||
private readonly storeEntitlementService: StoreEntitlementService,
|
||||
) {
|
||||
const {users, gateway, worker, snowflake} = this.apiContext.services;
|
||||
@@ -94,7 +92,6 @@ export class AdminService {
|
||||
apiContext: this.apiContext,
|
||||
adminRepository: this.adminRepository,
|
||||
auditService: this.auditService,
|
||||
ipInfoService: this.ipInfoService,
|
||||
});
|
||||
this.userService = new AdminUserService({
|
||||
apiContext: this.apiContext,
|
||||
|
||||
@@ -338,7 +338,7 @@ export function BanAdminController(app: HonoApp) {
|
||||
tags: ['Admin'],
|
||||
requestSchema: AdminBlocklistEntryCreateRequest,
|
||||
description:
|
||||
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list, or that IPInfo reports as a high blast-radius carrier NAT, is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
|
||||
'Add a value to a blocklist. The request body is the shape the blocklist named by list_type accepts, and the value is validated and canonicalized for that blocklist. Adding an IP address that is on the instance exemption list is refused with 400 IP_BAN_DECLINED and recorded in the audit log.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '@app/api/ban/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '@app/api/ban/IpBanExemptions';
|
||||
import {
|
||||
BANNED_AVATAR_HASHES_REFRESH_CHANNEL,
|
||||
@@ -18,7 +17,6 @@ import {
|
||||
} from '@app/api/constants/ContentModeration';
|
||||
import {IP_BAN_REFRESH_CHANNEL} from '@app/api/constants/IpBan';
|
||||
import type {BannedProfileSubstringScope} from '@app/api/database/types/AdminArchiveTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {bannedAvatarHashCache} from '@app/api/middleware/BannedAvatarHashCache';
|
||||
import {fileShaCache} from '@app/api/middleware/FileShaCache';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
@@ -34,13 +32,11 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {NotFoundError} from '@fluxer/errors/src/domains/core/NotFoundError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {AdminBlocklistListType} from '@fluxer/schema/src/domains/admin/AdminBlocklistSchemas';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
|
||||
interface AdminBanManagementServiceDeps {
|
||||
apiContext: ApiContext;
|
||||
adminRepository: IAdminRepository;
|
||||
auditService: AdminAuditService;
|
||||
ipInfoService: IpInfoService;
|
||||
}
|
||||
|
||||
interface AdminBlocklistEntry {
|
||||
@@ -146,20 +142,6 @@ export class AdminBanManagementService {
|
||||
message: 'This IP address is on the instance exemption list',
|
||||
});
|
||||
}
|
||||
if (await this.shouldSkipIpBanForCgnat(data.ip)) {
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'ip',
|
||||
targetId: BigInt(0),
|
||||
action: 'ban_ip_skipped_cgnat',
|
||||
auditLogReason,
|
||||
metadata: new Map([['ip', data.ip]]),
|
||||
});
|
||||
throw new BadRequestError({
|
||||
code: APIErrorCodes.IP_BAN_DECLINED,
|
||||
message: 'This IP address is a high blast-radius carrier network',
|
||||
});
|
||||
}
|
||||
await adminRepository.banIp(data.ip);
|
||||
ipBanCache.ban(data.ip);
|
||||
await cacheService.publish(IP_BAN_REFRESH_CHANNEL, 'refresh');
|
||||
@@ -200,25 +182,6 @@ export class AdminBanManagementService {
|
||||
return {banned};
|
||||
}
|
||||
|
||||
private async shouldSkipIpBanForCgnat(ip: string): Promise<boolean> {
|
||||
if (!isSingleIpBanCandidate(ip)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
|
||||
source: 'admin.ip_ban',
|
||||
reason: 'pre_write_cgnat_guard',
|
||||
});
|
||||
if (highRisk) {
|
||||
Logger.warn({ip}, 'Skipping IP ban because IPInfo indicates high CGNAT blast-radius risk');
|
||||
}
|
||||
return highRisk;
|
||||
} catch (error) {
|
||||
Logger.warn({error, ip}, 'IPInfo CGNAT guard failed while adding IP ban');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async banEmail(
|
||||
data: {
|
||||
email: string;
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {IAdminRepository} from '@app/api/admin/IAdminRepository';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {resetIpBanExemptionsForTesting} from '@app/api/ban/IpBanExemptions';
|
||||
import {getConfig} from '@app/api/Config';
|
||||
import {ipBanCache} from '@app/api/middleware/IpBanMiddleware';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {BadRequestError} from '@fluxer/errors/src/domains/core/BadRequestError';
|
||||
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const ADMIN_ID = createUserID(42n);
|
||||
const EXEMPT_IP = '10.0.0.1';
|
||||
const CARRIER_IP = '198.51.100.7';
|
||||
const LOOKUP_FAILURE_IP = '203.0.113.9';
|
||||
|
||||
interface AuditCall {
|
||||
action: string;
|
||||
metadata: Map<string, string> | undefined;
|
||||
}
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip: CARRIER_IP,
|
||||
available: true,
|
||||
note: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test Carrier',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createBanManagementService(lookup: (ip: string) => Promise<IpInfoLookupResult>) {
|
||||
const bannedIps: Array<string> = [];
|
||||
const auditCalls: Array<AuditCall> = [];
|
||||
const adminRepository = {
|
||||
banIp: async (ip: string) => {
|
||||
bannedIps.push(ip);
|
||||
},
|
||||
};
|
||||
const auditService = {
|
||||
createAuditLog: async ({action, metadata}: AuditCall) => {
|
||||
auditCalls.push({action, metadata});
|
||||
},
|
||||
};
|
||||
const ipInfoService = {lookup: (ip: string) => lookup(ip)};
|
||||
const apiContext = {
|
||||
services: {
|
||||
cache: {
|
||||
publish: async () => {},
|
||||
},
|
||||
},
|
||||
};
|
||||
const service = new AdminBanManagementService({
|
||||
apiContext: apiContext as unknown as ApiContext,
|
||||
adminRepository: adminRepository as unknown as IAdminRepository,
|
||||
auditService: auditService as unknown as AdminAuditService,
|
||||
ipInfoService: ipInfoService as unknown as IpInfoService,
|
||||
});
|
||||
return {service, bannedIps, auditCalls};
|
||||
}
|
||||
|
||||
describe('AdminBanManagementService banIp guards', () => {
|
||||
let originalExemptIps: Array<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
const config = getConfig();
|
||||
originalExemptIps = config.ipBanExemptIps;
|
||||
config.ipBanExemptIps = [EXEMPT_IP];
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ipBanCache.unban(LOOKUP_FAILURE_IP);
|
||||
getConfig().ipBanExemptIps = originalExemptIps;
|
||||
resetIpBanExemptionsForTesting();
|
||||
});
|
||||
|
||||
it('refuses an exempt address with IP_BAN_DECLINED and writes no ban row', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () => ipInfoResult());
|
||||
|
||||
const error = await service.banIp({ip: EXEMPT_IP}, ADMIN_ID, null).then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
|
||||
expect((error as BadRequestError).status).toBe(400);
|
||||
expect(bannedIps).toEqual([]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_exempt']);
|
||||
expect(auditCalls[0].metadata?.get('ip')).toBe(EXEMPT_IP);
|
||||
});
|
||||
|
||||
it('refuses a high blast-radius carrier address with IP_BAN_DECLINED and writes no ban row', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () =>
|
||||
ipInfoResult({
|
||||
mobile: {name: 'Example Mobile', mcc: '001', mnc: '01'},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: true, isSatellite: false},
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await service.banIp({ip: CARRIER_IP}, ADMIN_ID, null).then(
|
||||
() => null,
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestError);
|
||||
expect((error as BadRequestError).code).toBe(APIErrorCodes.IP_BAN_DECLINED);
|
||||
expect((error as BadRequestError).status).toBe(400);
|
||||
expect(bannedIps).toEqual([]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip_skipped_cgnat']);
|
||||
expect(auditCalls[0].metadata?.get('ip')).toBe(CARRIER_IP);
|
||||
});
|
||||
|
||||
it('still writes the ban when the IPInfo lookup fails', async () => {
|
||||
const {service, bannedIps, auditCalls} = createBanManagementService(async () => {
|
||||
throw new Error('ipinfo is unreachable');
|
||||
});
|
||||
|
||||
await expect(service.banIp({ip: LOOKUP_FAILURE_IP}, ADMIN_ID, null)).resolves.toBeUndefined();
|
||||
|
||||
expect(bannedIps).toEqual([LOOKUP_FAILURE_IP]);
|
||||
expect(auditCalls.map((call) => call.action)).toEqual(['ban_ip']);
|
||||
});
|
||||
});
|
||||
@@ -10,83 +10,24 @@ import {
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {setInjectedIpInfoService} from '@app/api/middleware/ServiceMiddleware';
|
||||
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
function createUniqueTestIp(): string {
|
||||
return `198.51.${randomInt(0, 256)}.${randomInt(1, 255)}`;
|
||||
}
|
||||
|
||||
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: true,
|
||||
note: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Admin Deletion Queue', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
return ipInfoResult(ip);
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
setInjectedIpInfoService(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
test('admin scheduling queues deletion and rescheduling replaces the old Cassandra row', async () => {
|
||||
|
||||
Reference in new issue
Block a user