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