From 5c5988c730f36a5f934c63889f8b672b5eaa25c7 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 10:48:22 -0400 Subject: [PATCH] ban hammer evasion --- .env.example | 14 ++ apps/api/src/bans-db.ts | 208 ++++++++++++++++++++ apps/api/src/test/integration/api.test.ts | 198 +++++++++++++++++++ apps/auth/src/auth.app.ts | 85 ++++++-- apps/auth/src/context.ts | 12 ++ apps/auth/src/test/integration/api.test.ts | 111 +++++++++++ apps/match/src/context.ts | 12 ++ apps/match/src/match.app.ts | 35 +++- apps/match/src/test/integration/api.test.ts | 100 ++++++++++ apps/www/src/auth-messages.ts | 5 + 10 files changed, 758 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/bans-db.ts diff --git a/.env.example b/.env.example index 5a22e48..d154577 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,20 @@ RECFLARE_DOMAIN=rec.example.com # RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3 # RECFLARE_MAX_ACCOUNTS_PER_IP=3 +# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a +# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means +# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a +# banned one, and refuses a signup from either. Without that, an evader is back in the +# game with a new account in under a minute. +# ...`platform` matches a Steam/Meta identity the player PROVED — sharp, no false +# positives worth the name. +# ...`ip` matches the signup/last-login address — coarse. A household, dorm, campus or +# mobile carrier shares one address, so this arm bans the banned player's housemates +# along with them, and locks them out of signing up at all. Set BAN_EVASION_MATCH=platform +# to keep the sharp arm only, or off to make a ban apply to just the banned account. +# A ban ALWAYS applies to the account it was handed to, whatever this is set to. +# RECFLARE_BAN_EVASION_MATCH=ip,platform + # How many rooms one account may create (`rooms`) and how many clubs (`clubs`). # Enforced on creation only — lowering either never touches what players already have, # it just stops new ones. Set either to 0 to turn that cap off. diff --git a/apps/api/src/bans-db.ts b/apps/api/src/bans-db.ts new file mode 100644 index 0000000..7ffeea9 --- /dev/null +++ b/apps/api/src/bans-db.ts @@ -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 { + 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() + 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 { + return (await resolveBan(db, accountId, options)) !== null +} diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d810636..e54affb 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 }) + }) +}) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 63f0507..d1e786e 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -26,7 +26,7 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo // The account-wide ban lives on a `report` row, whose table the api worker owns; its db // module is plain D1 queries with no runtime deps, so it imports cleanly here. -import { getActiveBan } from '../../api/src/reports-db' +import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' import { verifyMetaNonce } from './meta-nonce' import { CachedLogin, @@ -69,6 +69,15 @@ const TOKEN_SCOPE = */ const BANNED_DESCRIPTION = 'this account is banned' +/** + * The refusal when it is not THIS account that is banned but one it shares an identity + * with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the + * account being refused may be an innocent housemate of a banned player, so telling them + * "this account is banned" would be a lie, and naming the account we matched them to + * would hand out somebody else's moderation record. + */ +const BLOCKED_DESCRIPTION = 'this device or network is blocked' + /** * The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded * build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded @@ -561,8 +570,16 @@ const app = new Hono() '**Bans.** Once the grant has resolved an account, a BANNED account is refused a', 'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a', '`report` row with `banned` set (the `api` worker owns that table); it lifts on its', - 'own when `ban_expires` passes, and never if that is null. The ban belongs to the', - 'account, so it does not stop the player creating a new one — only the signup caps do.', + 'own when `ban_expires` passes, and never if that is null.', + '', + 'The refusal follows the player, not just the account: it also catches an account', + 'that shares a PROVEN platform identity (a `platform_account` link) or an IP', + '(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned', + 'one, and a `create_account` carrying either is refused BEFORE it mints anything.', + 'Those two arms are the operator’s `BAN_EVASION_MATCH` knob (`ip`, `platform`, or', + '`off`); the ban on the account itself is always enforced. A linked match answers a', + 'deliberately vaguer description than a direct one — the account refused may belong', + 'to a housemate of the banned player rather than to them.', ].join('\n'), requestBody: form( TokenRequest, @@ -718,6 +735,35 @@ const app = new Hono() // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { + // A banned player's next move is a new account, so the ban is checked BEFORE + // one is minted — against the only identity a signup has, the IP it came from + // and the platform identity it just proved. Refusing after the fact (as the + // shared check below would) still refuses the token, but leaves the account + // row behind and burns a slot off both signup caps, so the evader gets to keep + // making them. + // + // Nothing here can match the account arm (there is no account yet), so this is + // purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open — + // which is the honest default position: a server that won't accept the IP arm's + // false positives is choosing to let evaders re-register. + const blocked = await resolveBan(c.env.DB, null, { + identity: { + ip: clientIp, + platform: verifiedPlatform, + platformId: verifiedPlatformId, + }, + arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), + }) + if (blocked) { + logger.info('signup refused: player banned', { + via: blocked.via, + bannedAccountId: blocked.bannedAccountId, + ip: clientIp, + platformId: verifiedPlatformId, + }) + return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400) + } + // Signup caps. Checked before minting anything, so a rejected signup leaves no // account behind. Each arm is skipped when it's disabled (var <= 0) or when its // identity is unknown (no verified platform id / no client IP) — an unattributable @@ -887,8 +933,8 @@ const app = new Hono() await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp }) } - // A banned account gets no token — and with no token every other worker is shut - // to it, so this is the outer wall of a ban; matchmaking's refusal is the inner + // A banned player gets no token — and with no token every other worker is shut to + // them, so this is the outer wall of a ban; matchmaking's refusal is the inner // one, which still has to exist because a token issued before the ban stays valid // until it expires. // @@ -898,18 +944,31 @@ const app = new Hono() // a wrong password is still "invalid account_id or password", so this can't be // used to probe whether an account exists or is banned without knowing it. // - // It is per-account, and the ban is the account's, not the person's: nothing here - // stops a banned player creating a new account and playing on. Refusing that is a - // signup-cap/platform-identity problem, not this check's. - const ban = await getActiveBan(c.env.DB, Number(accountId)) + // The request's own IP and proven identity are passed alongside the account, so a + // ban also reaches an old, clean account logged into from the banned player's + // device or network — the stored ips alone would only catch that on the SECOND + // login. create_account was already refused before it minted anything (above); + // this still runs for it, so a signup that raced one is refused too. + const ban = await resolveBan(c.env.DB, Number(accountId), { + identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId }, + arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), + }) if (ban) { - logger.info('token refused: account banned', { + logger.info('token refused: player banned', { accountId, grantType, - reportId: ban.id, - banExpires: ban.ban_expires, + via: ban.via, + bannedAccountId: ban.bannedAccountId, + reportId: ban.ban.id, + banExpires: ban.ban.ban_expires, }) - return c.json({ error: 'invalid_grant', error_description: BANNED_DESCRIPTION }, 400) + return c.json( + { + error: 'invalid_grant', + error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION, + }, + 400 + ) } // Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 3af0d31..1509a89 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -26,6 +26,18 @@ export type Env = SharedHonoEnv & { // read them through `intVar`, never as a bare number. MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number MAX_ACCOUNTS_PER_IP?: string | number + /** + * Which linked arms a ban is enforced through, as a comma-separated list out of `ip` + * and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts + * that share a proven platform identity or an IP with the banned one, and refuses a + * signup from either, which is what stops an evader simply making a new account. + * + * The `ip` arm is coarse (households, NAT, campus and carrier networks share one + * address), so `platform` alone is the setting for a server whose players share + * networks. Whatever this says, a ban always applies to the account it was handed to. + * Read through `banEvasionMatch`; the `match` worker reads the same knob. + */ + BAN_EVASION_MATCH?: string } /** Variables can be extended */ diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index ef90dd1..7751a7d 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1267,3 +1267,114 @@ describe('banned accounts', () => { expect(created.status).toBe(200) }) }) + +// The ban follows the player past the account it was written on: a login from an account +// that shares a proven platform identity or an IP with a banned one is refused, and a +// signup carrying either is refused before it mints anything. See the api worker's +// bans-db.ts for the arms and the BAN_EVASION_MATCH knob. +describe('ban evasion at the token endpoint', () => { + /** Seed a loginable account carrying the IPs it signed up / last logged in from. */ + const account = async (id: number, name: string, ips: Record = {}) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: id, + username: name, + passwordHash: await hashPassword(LOGIN_PASSWORD), + ...ips, + }) + ) + .run() + } + + const login = (id: number, ip?: string) => + postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip) + + test('an account sharing a banned account’s platform identity cannot log in', async () => { + await account(6301, 'EvaderOne') + await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader') + await banAccount(6301) + await account(6302, 'EvaderTwo') + await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader') + + const res = await login(6302) + expect(res.status).toBe(400) + // A vaguer sentence than a direct ban: this account may belong to somebody else. + expect(res.json.error_description).toBe('this device or network is blocked') + }) + + test('an account sharing a banned account’s IP cannot log in', async () => { + await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' }) + await banAccount(6303) + await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' }) + + const res = await login(6304) + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('this device or network is blocked') + }) + + // The address the request arrives from counts, so an account that never logged in + // from the banned network before is caught on the first attempt rather than the second. + test('the request’s own IP is matched even when the account has none stored', async () => { + await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' }) + await banAccount(6305) + await account(6306, 'CleanElsewhere') + + expect((await login(6306, '203.0.113.31')).status).toBe(400) + // The same account from any other network signs in normally. + expect((await login(6306, '198.51.100.31')).status).toBe(200) + }) + + test('an unrelated account signs in normally', async () => { + await account(6307, 'Unrelated', { signupIp: '198.51.100.7' }) + await banAccount(6307 + 1000) // a ban on somebody else entirely + expect((await login(6307)).status).toBe(200) + }) + + // The point of checking before minting: a refused signup must leave nothing behind, + // or the evader keeps the account (and burns a slot off the signup caps) anyway. + test('create_account from a banned IP is refused and creates no account', async () => { + await account(6310, 'BannedSignupSource', { signupIp: '203.0.113.40' }) + await banAccount(6310) + + const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>() + const res = await postToken('grant_type=create_account', '203.0.113.40') + expect(res.status).toBe(400) + expect(res.json.error_description).toBe('this device or network is blocked') + const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>() + expect(after?.n).toBe(before?.n) + }) + + test('create_account from an unrelated IP still works', async () => { + const res = await postToken('grant_type=create_account', '198.51.100.99') + expect(res.status).toBe(200) + }) + + // The knob an operator reaches for when the IP arm locks out real players. + test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => { + const original = env.BAN_EVASION_MATCH + await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' }) + await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader') + await banAccount(6320) + await account(6321, 'KnobHousemate', { signupIp: '203.0.113.50' }) + await account(6322, 'KnobEvader') + await linkPlatformIdentity(env.DB, 6322, 0, 'steam-knobevader') + + try { + env.BAN_EVASION_MATCH = 'platform' + expect((await login(6321)).status).toBe(200) + expect((await login(6322)).status).toBe(400) + // And signup from that network is open again. + expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200) + + env.BAN_EVASION_MATCH = 'off' + expect((await login(6322)).status).toBe(200) + // The banned account itself is refused whatever the knob says. + const banned = await login(6320) + expect(banned.status).toBe(400) + expect(banned.json.error_description).toBe('this account is banned') + } finally { + env.BAN_EVASION_MATCH = original + } + }) +}) diff --git a/apps/match/src/context.ts b/apps/match/src/context.ts index 2c71322..b1b6707 100644 --- a/apps/match/src/context.ts +++ b/apps/match/src/context.ts @@ -31,6 +31,18 @@ export type Env = SharedHonoEnv & { * touching the client. See `roomRedirects` in match.app.ts. */ ROOM_REDIRECTS?: string + /** + * Which linked arms a ban is enforced through, as a comma-separated list out of `ip` + * and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts + * that share a proven platform identity or an IP with the banned one, which is what + * stops an evader simply making a new account. + * + * The `ip` arm is coarse (households, NAT, campus and carrier networks share one + * address), so `platform` alone is the setting for a server whose players share + * networks. Whatever this says, a ban always applies to the account it was handed to. + * Read through `banEvasionMatch`; the `auth` worker reads the same knob. + */ + BAN_EVASION_MATCH?: string } /** Variables can be extended */ diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index a487bdf..bc2508b 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -41,7 +41,7 @@ import { validateAndGetAccountId } from '@repo/jwt' // The account-wide ban lives on a `report` row, whose table the api worker owns; its // db module is plain D1 queries with no runtime deps, so it imports cleanly here (the // same way econ reads api's inventions-db). -import { isPlayerBanned } from '../../api/src/reports-db' +import { banEvasionMatch, resolveBan } from '../../api/src/bans-db' // Value import of the notify worker's NotificationType enum (its bundle has no runtime // deps), so /invite sends a typed MessageReceived frame instead of a magic number. import { NotificationType } from '../../notify/src/notification-types' @@ -700,13 +700,19 @@ const app = new Hono() })(c, next) ) - // A banned account goes nowhere. Room bans are per-room and checked per route (they - // depend on which room you're entering); an ACCOUNT ban isn't about a room at all, so - // it's enforced once here, across every matchmake — by room, by subroom, by instance, - // into a club's clubhouse, following a friend, and into their own dorm. A gate rather - // than six copies of the same check: a route added later inherits it, and there is no + // A banned player goes nowhere. Room bans are per-room and checked per route (they + // depend on which room you're entering); a BAN isn't about a room at all, so it's + // enforced once here, across every matchmake — by room, by subroom, by instance, into + // a club's clubhouse, following a friend, and into their own dorm. A gate rather than + // six copies of the same check: a route added later inherits it, and there is no // matchmake left that hands a banned player Photon coordinates. // + // `resolveBan` matches the caller's own account AND the accounts they share a proven + // platform identity or an IP with, so a ban survives the evader making a new account + // (see bans-db.ts; the operator narrows the linked arms with BAN_EVASION_MATCH). The + // arm that matched is logged, because "banned" and "shares a network with somebody + // banned" are very different things to be looking at in a log. + // // It answers the same BannedFromRoom the room bans do. The code is per-room in name // only — it's the one refusal the client renders as "you are banned" instead of a room // that mysteriously fails to load, and it's what the enum offers. @@ -715,9 +721,20 @@ const app = new Hono() // 401, which mustn't turn into "banned" just because the token was missing. .use('/matchmake/*', async (c, next) => { const id = await authedId(c) - if (id !== null && (await isPlayerBanned(c.env.DB, id))) { - logger.info('matchmake refused: account banned', { accountId: id, path: c.req.path }) - return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null }) + if (id !== null) { + const match = await resolveBan(c.env.DB, id, { + identity: { ip: c.req.header('cf-connecting-ip') }, + arms: banEvasionMatch(c.env.BAN_EVASION_MATCH), + }) + if (match) { + logger.info('matchmake refused: player banned', { + accountId: id, + via: match.via, + bannedAccountId: match.bannedAccountId, + path: c.req.path, + }) + return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null }) + } } await next() }) diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 0b816e9..595f5e7 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -26,6 +26,7 @@ import { createReport, SCHEMA_DDL as REPORTS_SCHEMA_DDL, } from '../../../../api/src/reports-db' +import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db' import { scheduled } from '../../match.app' import type { Env } from '../../context' @@ -171,6 +172,9 @@ beforeAll(async () => { // Report table (owned by the api worker) — an account-wide ban is a report row with // `banned` set, and every matchmake is refused for a player who has one. for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Platform identity links (owned by the auth worker) — a ban also reaches the + // accounts sharing a proven identity with the banned one. + for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) /** @@ -1879,3 +1883,99 @@ describe('account bans', () => { expect(res.status).toBe(200) }) }) + +// The ban follows the player past the account it was written on: a new account sharing a +// proven platform identity or an IP with a banned one is refused the same way. See +// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob. +describe('ban evasion at matchmake', () => { + const matchmake = async (player: string, ip?: string) => + (await ( + await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { + method: 'POST', + headers: { ...(await bearer(player)), ...(ip ? { 'CF-Connecting-IP': ip } : {}) }, + }) + ).json()) as { errorCode: number; roomInstance: unknown } + + /** Seed an account row carrying the IPs it signed up / last logged in from. */ + const account = async (id: number, ips: Record = {}) => { + await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') + .bind(JSON.stringify({ accountId: id, username: `Player${id}`, ...ips })) + .run() + } + + 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() + } + + test('a new account sharing a banned account’s platform identity is refused', async () => { + await account(6201) + await link(6201, 0, 'steam-evader') + await banAccount(6201) + // The replacement account: different id, same headset. + await account(6202) + await link(6202, 0, 'steam-evader') + + expect(await matchmake('6202')).toEqual({ errorCode: 55, roomInstance: null }) + }) + + test('a new account sharing a banned account’s signup IP is refused', async () => { + await account(6203, { signupIp: '203.0.113.203' }) + await banAccount(6203) + await account(6204, { signupIp: '203.0.113.203' }) + + expect(await matchmake('6204')).toEqual({ errorCode: 55, roomInstance: null }) + }) + + // The address the request arrives from counts too, so an account that has never + // logged in from the banned network before is caught on the first matchmake. + test('the request’s own IP is matched even when the account has none stored', async () => { + await account(6205, { signupIp: '203.0.113.205' }) + await banAccount(6205) + await account(6206) + + expect(await matchmake('6206', '203.0.113.205')).toEqual({ errorCode: 55, roomInstance: null }) + // From anywhere else, that same account plays. + expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0) + }) + + test('an unrelated account is unaffected', async () => { + await account(6207, { signupIp: '203.0.113.207' }) + await banAccount(6207) + await account(6208, { signupIp: '198.51.100.208' }) + await link(6208, 0, 'steam-innocent') + + expect((await matchmake('6208')).errorCode).toBe(0) + }) + + // BAN_EVASION_MATCH is the operator's answer to the IP arm's false positives: the + // housemate of a banned player gets back in, the evader on the same headset does not. + test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => { + const original = env.BAN_EVASION_MATCH + await account(6210, { signupIp: '203.0.113.210' }) + await link(6210, 0, 'steam-knob') + await banAccount(6210) + await account(6211, { signupIp: '203.0.113.210' }) // housemate + await account(6212) + await link(6212, 0, 'steam-knob') // same headset + + try { + env.BAN_EVASION_MATCH = 'platform' + expect((await matchmake('6211')).errorCode).toBe(0) + expect(await matchmake('6212')).toEqual({ errorCode: 55, roomInstance: null }) + // The banned account itself is still refused, whatever the knob says. + expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null }) + + env.BAN_EVASION_MATCH = 'off' + expect((await matchmake('6211')).errorCode).toBe(0) + expect((await matchmake('6212')).errorCode).toBe(0) + expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null }) + } finally { + env.BAN_EVASION_MATCH = original + } + }) +}) diff --git a/apps/www/src/auth-messages.ts b/apps/www/src/auth-messages.ts index 3e13f3d..f648712 100644 --- a/apps/www/src/auth-messages.ts +++ b/apps/www/src/auth-messages.ts @@ -36,6 +36,11 @@ const AUTH_MESSAGES: Record = { // every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry // here to quote. 'this account is banned': 'This account is banned and cannot be signed in to.', + // Not this account, but one it shares a device or network with. Phrased for BOTH the + // person evading a ban and the housemate of one — the IP arm cannot tell them apart — + // and for both forms, since signup and sign-in send the same description. + 'this device or network is blocked': + 'This device or network is blocked. If you think that is a mistake, contact the server operator.', } /** Fallbacks when nothing above matched, so a player never reads an OAuth code. */