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