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
+72 -13
View File
@@ -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<App>()
'**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 operators `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<App>()
// 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<App>()
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<App>()
// 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
+12
View File
@@ -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 */
+111
View File
@@ -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<string, string> = {}) => {
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 accounts 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 accounts 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 requests 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
}
})
})