mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -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
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
+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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<App>()
|
||||
})(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<App>()
|
||||
// 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()
|
||||
})
|
||||
|
||||
@@ -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<string, string> = {}) => {
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,6 +36,11 @@ const AUTH_MESSAGES: Record<string, string> = {
|
||||
// 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. */
|
||||
|
||||
Reference in New Issue
Block a user