mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
ban hammer evasion
This commit is contained in:
+72
-13
@@ -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 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<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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user