fix(api): widen guild IP ban guard to shared-access networks (#2607)

This commit is contained in:
Hampus
2026-09-08 22:09:39 +02:00
committed by GitHub
parent 63e3be5750
commit 6c36d934f7
5 changed files with 91 additions and 18 deletions
@@ -30,8 +30,8 @@ import {phraseBlocklistCache} from '../../middleware/PhraseBlocklistCache';
import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';
import {urlBlocklistCache} from '../../middleware/UrlBlocklistCache';
import {
getIpBanBlastRadiusVerdict,
getSuspiciousIpSkipReason,
hasHighCgnatBlastRadiusRisk,
isSingleIpBanCandidate,
} from '../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../risk/IpBanExemptions';
@@ -292,7 +292,7 @@ export class AdminBanManagementService {
return false;
}
try {
const highRisk = await hasHighCgnatBlastRadiusRisk(ip, this.deps.ipInfoService, {
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
source: 'admin.ip_ban',
reason: 'pre_write_cgnat_guard',
});
@@ -19,7 +19,7 @@ import type {UserCacheService} from '../../infrastructure/UserCacheService';
import {Logger} from '../../Logger';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {GuildBan} from '../../models/GuildBan';
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../risk/IpBanExemptions';
import type {IUserRepository} from '../../user/IUserRepository';
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
@@ -237,19 +237,20 @@ export class GuildModerationService {
return true;
}
try {
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild IP ban match because IPInfo indicates high CGNAT blast-radius risk',
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
} catch (error) {
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild IP ban');
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
return true;
}
}
@@ -14,7 +14,7 @@ import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import {guildIdToRoleId} from '../../../BrandedTypes';
import {Logger} from '../../../Logger';
import type {GuildMember} from '../../../models/GuildMember';
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
import {isIpBanExempt} from '../../../risk/IpBanExemptions';
import type {IUserRepository} from '../../../user/IUserRepository';
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
@@ -119,14 +119,15 @@ export class GuildMemberValidationService {
return true;
}
try {
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
source: 'guild.member_ip_ban',
reason: 'join_cgnat_guard',
});
const highRisk = cgnat || sharedAccess;
if (highRisk) {
Logger.warn(
{userIp, bannedIp},
'Skipping guild member IP ban match because IPInfo indicates high CGNAT blast-radius risk',
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
);
}
return !highRisk;
+16 -8
View File
@@ -7,9 +7,14 @@ import {isTrustedCommercialPrivacyProvider} from './TrustedPrivacyProviders';
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
interface IpBanBlastRadiusVerdict {
cgnat: boolean;
sharedAccess: boolean;
}
interface CachedVerdict {
expiresAtMs: number;
highRisk: boolean;
verdict: IpBanBlastRadiusVerdict;
}
const verdictCache = new Map<string, CachedVerdict>();
@@ -48,7 +53,7 @@ export function getSuspiciousIpSkipReason(result: IpInfoLookupResult): Suspiciou
return null;
}
function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
if (result.flags.isHosting || isAnonymousAccess(result)) {
return false;
}
@@ -60,29 +65,32 @@ export function isSingleIpBanCandidate(value: string): boolean {
return parseIpBanEntry(value)?.type === 'single';
}
export async function hasHighCgnatBlastRadiusRisk(
export async function getIpBanBlastRadiusVerdict(
ip: string,
ipInfoService: IpInfoService,
context: {
source: string;
reason: string;
},
): Promise<boolean> {
): Promise<IpBanBlastRadiusVerdict> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
const cached = verdictCache.get(cacheKey);
if (cached && cached.expiresAtMs > now) {
return cached.highRisk;
return cached.verdict;
}
const result = await ipInfoService.lookup(ip, {
source: context.source,
reason: context.reason,
metadata: {policy: 'ip_ban_cgnat_guard'},
});
const highRisk = isHighCgnatBlastRadiusRisk(result);
const verdict: IpBanBlastRadiusVerdict = {
cgnat: isHighCgnatBlastRadiusRisk(result),
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
};
verdictCache.set(cacheKey, {
highRisk,
verdict,
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
});
return highRisk;
return verdict;
}
@@ -2,7 +2,11 @@
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
import {describe, expect, it} from 'vitest';
import {isHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../IpBanCgnatGuard';
import {
isHighCgnatBlastRadiusRisk,
isHighSharedAccessBlastRadiusRisk,
isSingleIpBanCandidate,
} from '../IpBanCgnatGuard';
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
return {
@@ -96,4 +100,63 @@ describe('IpBanCgnatGuard', () => {
),
).toBe(false);
});
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
}),
),
).toBe(true);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
),
).toBe(true);
});
it('does not flag ordinary residential networks as shared-access risk', () => {
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
});
it('does not treat shared-access networks as CGNAT risk', () => {
expect(
isHighCgnatBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
}),
),
).toBe(false);
expect(
isHighSharedAccessBlastRadiusRisk(
ipInfoResult({
anonymous: {
isAnonymous: true,
providerName: 'Example VPN',
isVpn: true,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
},
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
}),
),
).toBe(false);
});
});