ban hammer evasion

This commit is contained in:
Devin Zuczek
2026-08-10 10:48:22 -04:00
parent 6bbdf989b9
commit 5c5988c730
10 changed files with 758 additions and 22 deletions
+208
View File
@@ -0,0 +1,208 @@
/**
* Who a ban reaches — the ban itself, plus the accounts that share an identity with a
* banned one. This is the ban-EVASION half of moderation: a ban lives on a `report` row
* (see reports-db) and applies to one account, but a player whose account is banned can
* make another in seconds, so the block has to follow the things that are harder to
* change than an account: the platform identity they log in with, and the network they
* play from.
*
* Three arms, in descending order of how much they prove:
* - ACCOUNT — the caller's own account is banned. Certain.
* - PLATFORM — the caller shares a `platform_account` link (a Steam or Meta identity
* they PROVED to us; see the auth worker's platform-db) with a banned account. Sharp:
* linking only ever happens off a verified proof, so this really is the same person,
* modulo somebody handing over their Steam account.
* - IP — the caller shares a `signupIp`/`lastLoginIp` with a banned account. COARSE,
* and the one that will produce false positives: households, NAT, campus and mobile
* carrier networks put many unrelated players behind one address, so this arm bans a
* banned player's whole household along with them. It is the operator's call whether
* that trade is worth it — hence `BAN_EVASION_MATCH` (see `banEvasionMatch`), which
* narrows or disables the linked arms without touching the direct one.
*
* The direct arm can never be turned off. That is the point of the split: an operator
* dialling back evasion matching still enforces every ban they handed down.
*
* Reads three tables owned by three workers — `report` (api), `account` (auth, via the
* blob) and `platform_account` (auth) — which is why this is its own module rather than
* part of reports-db: it is the POLICY over those tables, not any one table's storage.
* It only ever reads them.
*
* The whole resolution is ONE statement. The alternative — fetch my ips, fetch my links,
* then query bans — is three round trips on a path that runs on every matchmake and every
* token grant. Driving from the (few) banned reports and looking each one's account up by
* its indexed id keeps the work proportional to the number of BANS, not to the number of
* accounts.
*/
import type { ReportRow } from './reports-db'
/** Which arm matched — what the block is actually resting on. */
export type BanVia = 'account' | 'platform' | 'ip'
/** A ban that reaches the caller, and how it reached them. */
export interface BanMatch {
/** The report row carrying the ban (its `reported_player_id` is who was banned). */
ban: ReportRow
via: BanVia
/**
* The banned account. Equal to the caller on a direct ban; on a linked arm it's the
* OTHER account they were matched to — the one worth naming in the operator's log.
*/
bannedAccountId: number
}
/** Which linked arms are enabled. The direct (account) arm is not optional. */
export interface BanMatchArms {
ip: boolean
platform: boolean
}
/** Both linked arms on — what an operator who sets nothing gets. */
export const DEFAULT_BAN_MATCH_ARMS: BanMatchArms = { ip: true, platform: true }
/**
* Read the `BAN_EVASION_MATCH` operator knob: a comma-separated list of the linked arms
* to enforce, out of `ip` and `platform`. Unset (the default) means BOTH — a ban follows
* the player. `off` (or `none`, or an empty list) leaves only the direct arm, so a ban
* applies to exactly the account it was handed to.
*
* Set it to `platform` on a server whose players share networks — student halls, one
* household, a country behind CGNAT — where the IP arm would lock out bystanders. The
* platform arm has no such failure mode: it matches a proven identity.
*
* Unrecognised names are ignored rather than fatal: this is read on a request path, and a
* typo must not take matchmaking or login down with it. `off` wins over anything else in
* the list, so `off,ip` is off.
*/
export function banEvasionMatch(value: string | undefined): BanMatchArms {
if (value === undefined) return DEFAULT_BAN_MATCH_ARMS
const names = value
.split(',')
.map((n) => n.trim().toLowerCase())
.filter((n) => n !== '')
if (names.length === 0 || names.includes('off') || names.includes('none')) {
return { ip: false, platform: false }
}
return { ip: names.includes('ip'), platform: names.includes('platform') }
}
/**
* The identity a request carries, for a caller who has no account yet — a `create_account`
* grant, which must be refused BEFORE it mints anything, or a banned player's next account
* exists (and has burned a signup) before the ban catches up with it.
*/
export interface BanIdentity {
/** The client IP the request came from, if the edge reported one. */
ip?: string | null
/** A VERIFIED platform identity. An unproven one must never be passed here. */
platform?: number | null
platformId?: string | null
}
/** Row shape of the resolution query — a report plus which arm matched it. */
type BanMatchRow = ReportRow & {
via_account: number
via_ip: number
via_platform: number
}
/**
* Every ban in force, tested against the caller's account and against the identity they
* present. `ips` and `ids` gather what the caller is known by: the account's stored IPs
* and platform links (when there is an account) plus the IP/identity this request itself
* carries (when there isn't one yet, or when it differs from what's stored).
*
* A NULL `?1` means "no account yet" — the `me` CTE is then empty and the account arm
* cannot match, leaving the two linked arms to answer for a signup.
*/
const RESOLVE_BAN_SQL = `
WITH me AS (
SELECT
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
FROM account WHERE account_id = ?1
),
ips AS (
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
UNION SELECT ?3 WHERE ?3 IS NOT NULL
),
ids AS (
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
UNION SELECT ?4, ?5 WHERE ?5 IS NOT NULL
)
SELECT * FROM (
SELECT r.*,
(r.reported_player_id = ?1) AS via_account,
(?6 = 1 AND EXISTS (
SELECT 1 FROM account a, ips
WHERE a.account_id = r.reported_player_id
AND a.account_id <> COALESCE(?1, -1)
AND ips.ip IN (
json_extract(a.data, '$.signupIp'),
json_extract(a.data, '$.lastLoginIp')
)
)) AS via_ip,
(?7 = 1 AND EXISTS (
SELECT 1 FROM platform_account p, ids
WHERE p.account_id = r.reported_player_id
AND p.account_id <> COALESCE(?1, -1)
AND p.platform = ids.platform
AND p.platform_id = ids.platform_id
)) AS via_platform
FROM report r
WHERE r.banned = 1 AND (r.ban_expires IS NULL OR r.ban_expires > ?2)
)
WHERE via_account = 1 OR via_ip = 1 OR via_platform = 1
ORDER BY via_account DESC, via_platform DESC, ban_expires IS NOT NULL, ban_expires DESC
LIMIT 1`
/**
* The ban blocking this caller, or null when nothing does.
*
* Pass the `accountId` when there is one (every login after the first, and every
* matchmake) and the request's own `identity` when it adds something the account doesn't
* already carry — on a `create_account` grant there is no account at all, and that is
* exactly the request a ban evader makes.
*
* The strongest match is the one returned: a direct ban ahead of a platform match ahead
* of an IP one, then the longest-lasting ban of those. So the log line names the evidence
* an operator would want to see first, and a player whose own account is banned is never
* told it was their network.
*/
export async function resolveBan(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<BanMatch | null> {
const arms = options.arms ?? DEFAULT_BAN_MATCH_ARMS
const identity = options.identity ?? {}
const row = await db
.prepare(RESOLVE_BAN_SQL)
.bind(
accountId,
(options.now ?? new Date()).toISOString(),
identity.ip || null,
identity.platform ?? 0,
identity.platformId || null,
arms.ip ? 1 : 0,
arms.platform ? 1 : 0
)
.first<BanMatchRow>()
if (!row) return null
// `via_ip` is only stripped off the row here — it's the arm left when neither of the
// other two matched, so nothing reads it.
const { via_account, via_ip: _via_ip, via_platform, ...ban } = row
const via: BanVia = via_account === 1 ? 'account' : via_platform === 1 ? 'platform' : 'ip'
return { ban: ban as ReportRow, via, bannedAccountId: ban.reported_player_id }
}
/** Whether anything blocks this caller — the boolean form of `resolveBan`. */
export async function isPlayerBlocked(
db: D1Database,
accountId: number | null,
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
): Promise<boolean> {
return (await resolveBan(db, accountId, options)) !== null
}
+198
View File
@@ -13,6 +13,8 @@ import {
import '../../api.app'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
@@ -24,6 +26,7 @@ import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
import {
banFromReport,
createReport,
getActiveBan,
getReportsAgainst,
isPlayerBanned,
@@ -104,6 +107,9 @@ beforeAll(async () => {
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Platform identity links (owned by the auth worker) — the sharp arm of the
// ban-evasion resolution matches on them.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -3216,3 +3222,195 @@ describe('openapi', () => {
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
})
})
// A ban follows the player, not just the account row it was written on: an evader makes
// a new account in seconds, so the block also reaches accounts sharing a PROVEN platform
// identity or an IP with a banned one. See bans-db.ts — and note the IP arm is the coarse
// one, which is why `BAN_EVASION_MATCH` can narrow or disable both linked arms.
describe('ban evasion', () => {
/** Seed an account with the IPs it signed up / last logged in from. */
const account = async (id: number, ips: { signupIp?: string; lastLoginIp?: string } = {}) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: id, username: `Evader${id}`, ...ips }))
.run()
}
/** Link a proven platform identity to an account, as a verified login does. */
const link = async (id: number, platform: number, platformId: string) => {
await env.DB.prepare(
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
VALUES (?1, ?2, ?3, ?4)`
)
.bind(id, platform, platformId, new Date().toISOString())
.run()
}
/** File a report against `playerId` and convert it into a ban. */
const ban = async (playerId: number, banExpires: string | null = null) => {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
await banFromReport(env.DB, row.id, { banExpires })
}
test('a banned account is matched directly', async () => {
await account(7001)
await ban(7001)
expect(await resolveBan(env.DB, 7001)).toMatchObject({ via: 'account', bannedAccountId: 7001 })
})
test('an unrelated account is not matched', async () => {
await account(7002, { signupIp: '198.51.100.9' })
await link(7002, 0, 'steam-clean')
expect(await resolveBan(env.DB, 7002)).toBeNull()
})
test('an account sharing a signup IP with a banned account is matched', async () => {
await account(7010, { signupIp: '203.0.113.7' })
await ban(7010)
await account(7011, { signupIp: '203.0.113.7' })
const match = await resolveBan(env.DB, 7011)
expect(match).toMatchObject({ via: 'ip', bannedAccountId: 7010 })
})
// The IPs are compared as SETS: the new account's last-login IP against the banned
// account's signup IP counts, which is the shape evasion actually takes (sign up
// somewhere else, come back to the same connection).
test('a last-login IP matching a banned signup IP is matched', async () => {
await account(7012, { signupIp: '203.0.113.20' })
await ban(7012)
await account(7013, { signupIp: '198.51.100.1', lastLoginIp: '203.0.113.20' })
expect(await resolveBan(env.DB, 7013)).toMatchObject({ via: 'ip', bannedAccountId: 7012 })
})
test('an account sharing a platform identity with a banned account is matched', async () => {
await account(7020)
await link(7020, 0, 'steam-76561')
await ban(7020)
await account(7021)
await link(7021, 0, 'steam-76561')
expect(await resolveBan(env.DB, 7021)).toMatchObject({ via: 'platform', bannedAccountId: 7020 })
})
// The same id on a DIFFERENT platform is a different person — ids are namespaced per
// platform, so the arm matches the pair, not the bare id.
test('the same platform id on another platform is not matched', async () => {
await account(7022)
await link(7022, 0, 'id-collision')
await ban(7022)
await account(7023)
await link(7023, 1, 'id-collision')
expect(await resolveBan(env.DB, 7023)).toBeNull()
})
// Two accounts that merely both lack an IP have nothing in common — "unknown" must
// never match "unknown", or every IP-less account would be banned by the first one.
test('accounts with no IP at all are not matched to each other', async () => {
await account(7030)
await ban(7030)
await account(7031)
expect(await resolveBan(env.DB, 7031)).toBeNull()
// Nor does an empty-string IP, which is what a login outside the CF edge stores.
await account(7032, { signupIp: '', lastLoginIp: '' })
expect(await resolveBan(env.DB, 7032)).toBeNull()
})
test('an expired ban reaches nobody, linked or not', async () => {
await account(7040, { signupIp: '203.0.113.40' })
await link(7040, 0, 'steam-expired')
await ban(7040, '2020-01-01T00:00:00.000Z')
await account(7041, { signupIp: '203.0.113.40' })
await link(7041, 0, 'steam-expired')
expect(await resolveBan(env.DB, 7040)).toBeNull()
expect(await resolveBan(env.DB, 7041)).toBeNull()
})
// The strongest evidence is reported: a player whose own account is banned is told
// that, not that their network was.
test('a direct ban outranks a linked one', async () => {
await account(7050, { signupIp: '203.0.113.50' })
await ban(7050)
await account(7051, { signupIp: '203.0.113.50' })
await ban(7051)
expect(await resolveBan(env.DB, 7051)).toMatchObject({ via: 'account', bannedAccountId: 7051 })
})
test('a platform match outranks an IP one', async () => {
await account(7060, { signupIp: '203.0.113.60' })
await ban(7060)
await account(7061)
await link(7061, 0, 'steam-both')
await ban(7061)
// 7062 shares an IP with 7060 and a platform identity with 7061.
await account(7062, { signupIp: '203.0.113.60' })
await link(7062, 0, 'steam-both')
expect(await resolveBan(env.DB, 7062)).toMatchObject({ via: 'platform', bannedAccountId: 7061 })
})
// A signup has no account yet — the identity the request carries is all there is to
// go on, and refusing it there is what stops the next account being created at all.
test('an identity with no account is matched on its IP and platform id', async () => {
await account(7070, { signupIp: '203.0.113.70' })
await link(7070, 0, 'steam-signup')
await ban(7070)
expect(await resolveBan(env.DB, null, { identity: { ip: '203.0.113.70' } })).toMatchObject({
via: 'ip',
bannedAccountId: 7070,
})
expect(
await resolveBan(env.DB, null, { identity: { platform: 0, platformId: 'steam-signup' } })
).toMatchObject({ via: 'platform', bannedAccountId: 7070 })
// An identity that matches nothing is not blocked.
expect(
await resolveBan(env.DB, null, {
identity: { ip: '198.51.100.200', platform: 0, platformId: 'steam-unknown' },
})
).toBeNull()
// And an identity carrying nothing at all can't be matched to anyone.
expect(await resolveBan(env.DB, null, { identity: {} })).toBeNull()
})
// The arms an operator can turn off — and the one they cannot.
test('BAN_EVASION_MATCH arms narrow the linked matching only', async () => {
await account(7080, { signupIp: '203.0.113.80' })
await link(7080, 0, 'steam-arms')
await ban(7080)
await account(7081, { signupIp: '203.0.113.80' }) // shares the IP only
await account(7082)
await link(7082, 0, 'steam-arms') // shares the identity only
const arms = (value: string | undefined) => ({ arms: banEvasionMatch(value) })
// Default: both arms reach.
expect(await resolveBan(env.DB, 7081, arms(undefined))).toMatchObject({ via: 'ip' })
expect(await resolveBan(env.DB, 7082, arms(undefined))).toMatchObject({ via: 'platform' })
// Platform only: the household bystander is let through, the evader isn't.
expect(await resolveBan(env.DB, 7081, arms('platform'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('platform'))).toMatchObject({ via: 'platform' })
// Off: neither linked arm reaches...
expect(await resolveBan(env.DB, 7081, arms('off'))).toBeNull()
expect(await resolveBan(env.DB, 7082, arms('off'))).toBeNull()
// ...but the ban itself still applies to the account it was handed to.
expect(await resolveBan(env.DB, 7080, arms('off'))).toMatchObject({ via: 'account' })
})
test('banEvasionMatch reads the knob', () => {
expect(banEvasionMatch(undefined)).toEqual({ ip: true, platform: true })
expect(banEvasionMatch('ip,platform')).toEqual({ ip: true, platform: true })
expect(banEvasionMatch(' PLATFORM ')).toEqual({ ip: false, platform: true })
expect(banEvasionMatch('ip')).toEqual({ ip: true, platform: false })
expect(banEvasionMatch('off')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('none')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('')).toEqual({ ip: false, platform: false })
// `off` wins over anything else in the list, and a typo is ignored rather than
// fatal — this is read on the matchmake path.
expect(banEvasionMatch('off,ip')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ipv6')).toEqual({ ip: false, platform: false })
expect(banEvasionMatch('ip,typo')).toEqual({ ip: true, platform: false })
})
})