diff --git a/src/client/ClanTagInput.ts b/src/client/ClanTagInput.ts index 83b833429..98dc33e1b 100644 --- a/src/client/ClanTagInput.ts +++ b/src/client/ClanTagInput.ts @@ -53,7 +53,9 @@ export class ClanTagInput extends LitElement { connectedCallback() { super.connectedCallback(); this.clanTag = localStorage.getItem(clanTagKey) ?? ""; - this.validate(); + // No user input to coalesce on initial mount — fire the ownership check + // immediately instead of paying the debounce delay. + this.validate({ immediate: true }); } disconnectedCallback() { @@ -126,7 +128,7 @@ export class ClanTagInput extends LitElement { this.validate(); } - private validate() { + private validate(options: { immediate?: boolean } = {}) { const tag = this.clanTag; const result = validateClanTag(tag); this.formatError = result.isValid ? "" : (result.error ?? ""); @@ -148,6 +150,9 @@ export class ClanTagInput extends LitElement { this.ownershipError = ""; localStorage.setItem(clanTagKey, ""); this.currentCheck = Promise.resolve(); + } else if (options.immediate) { + // Initial mount / non-typing trigger — no input to coalesce, run now. + this.currentCheck = this.checkOwnership(tag); } else { const debounce = new Promise((resolve) => { this.resolveDebounce = resolve; diff --git a/src/server/ClanTagOwnership.ts b/src/server/ClanTagOwnership.ts new file mode 100644 index 000000000..7f6d6f756 --- /dev/null +++ b/src/server/ClanTagOwnership.ts @@ -0,0 +1,183 @@ +import { + ClanExistsResponseSchema, + clanExistsApiPath, + type UserMeResponse, +} from "../core/ApiSchemas"; + +// Clan-existence probe used by the join-time ownership check. +// +// Only positive results are cached: a fictional tag (clan does not exist +// upstream) could legitimately become real moments later, and we don't want a +// stale "false" to leak a tag past the ownership check until TTL expiry. +// Positive results are safe to cache because a real clan stays real for the +// life of any reasonable cache entry, and a cache hit returns "exists=true", +// which only ever causes the tag to be dropped (fail-closed). + +export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; +export const CLAN_EXISTS_CACHE_TTL_MS = 60_000; +export const CLAN_EXISTS_CACHE_MAX_ENTRIES = 1024; + +interface CacheEntry { + expiresAt: number; +} + +// Insertion-ordered Map gives us a trivial FIFO/LRU: re-set on hit to bump +// freshness, evict from the oldest end when the bound is reached. +const positiveCache = new Map(); + +interface ProbeDeps { + /** Base URL of the upstream auth API (issuer). */ + baseUrl: string; + /** Injected so tests can stub network behavior. */ + fetcher?: typeof fetch; + /** Injected so tests control time. */ + now?: () => number; + /** Logger callback for unexpected statuses / transport errors. */ + onWarn?: (event: string, ctx: Record) => void; + /** Override the shared cache (tests). */ + cache?: Map; + /** Override TTL / bound (tests). */ + ttlMs?: number; + maxEntries?: number; +} + +function getCachedExists( + cache: Map, + key: string, + nowMs: number, +): boolean | undefined { + const entry = cache.get(key); + if (entry === undefined) return undefined; + if (nowMs >= entry.expiresAt) { + cache.delete(key); + return undefined; + } + // Bump LRU recency. + cache.delete(key); + cache.set(key, entry); + return true; +} + +function setCachedExists( + cache: Map, + key: string, + expiresAt: number, + maxEntries: number, +): void { + cache.set(key, { expiresAt }); + while (cache.size > maxEntries) { + const oldest = cache.keys().next(); + if (oldest.done) break; + cache.delete(oldest.value); + } +} + +/** + * Returns true if the tag matches a real clan upstream, false if it does not, + * and null when the result is inconclusive (transport error, timeout, or + * unexpected status). Callers should treat null as fail-closed (drop the tag). + */ +export async function clanExistsByTag( + tag: string, + deps: ProbeDeps, +): Promise { + const cache = deps.cache ?? positiveCache; + const now = deps.now ?? Date.now; + const fetcher = deps.fetcher ?? fetch; + const ttlMs = deps.ttlMs ?? CLAN_EXISTS_CACHE_TTL_MS; + const maxEntries = deps.maxEntries ?? CLAN_EXISTS_CACHE_MAX_ENTRIES; + + const cacheKey = tag.toUpperCase(); + const cached = getCachedExists(cache, cacheKey, now()); + if (cached === true) return true; + + try { + const url = `${deps.baseUrl}${clanExistsApiPath(tag)}`; + const response = await fetcher(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (response.status === 200) { + // Upstream currently has no body; tolerate {exists:false} for forward-compat. + try { + const text = await response.text(); + if (text.length > 0) { + const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); + if (parsed.success && parsed.data?.exists === false) { + return false; + } + } + } catch { + // Forward-compat parsing only; ignore failures. + } + setCachedExists(cache, cacheKey, now() + ttlMs, maxEntries); + return true; + } + if (response.status === 404) { + return false; + } + deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", { + tag: cacheKey, + status: response.status, + }); + return null; + } catch (e) { + deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", { + tag: cacheKey, + error: e instanceof Error ? e.message : String(e), + }); + return null; + } +} + +/** + * Decide whether a player may wear the given (already-censored) clan tag. + * + * - Members of the tag's clan always pass through unchanged. + * - Non-members keep the tag only when the upstream API confirms the clan + * does not exist (fictional tag). + * - Inconclusive existence results drop the tag (fail-closed). + * + * @returns the resolved tag (the original or null) along with a `dropped` flag + * so callers can log impersonation attempts. + */ +export async function resolveClanTag( + censoredTag: string | null, + userMeResponse: UserMeResponse | null, + existsChecker: (tag: string) => Promise, +): Promise<{ + tag: string | null; + dropped: boolean; + reason?: "exists" | "inconclusive"; +}> { + if (censoredTag === null) return { tag: null, dropped: false }; + + const userClanTags = new Set( + userMeResponse + ? (userMeResponse.player.clans ?? []).map((c) => c.tag.toUpperCase()) + : [], + ); + if (userClanTags.has(censoredTag.toUpperCase())) { + return { tag: censoredTag, dropped: false }; + } + + const exists = await existsChecker(censoredTag); + if (exists === false) { + return { tag: censoredTag, dropped: false }; + } + return { + tag: null, + dropped: true, + reason: exists === true ? "exists" : "inconclusive", + }; +} + +/** Exposed for tests so each spec starts with a clean cache. */ +export function __resetClanExistsCacheForTests(): void { + positiveCache.clear(); +} + +/** Exposed for tests/observability. */ +export function __peekClanExistsCacheSize(): number { + return positiveCache.size; +} diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a206..3ba53dea7 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -22,6 +22,20 @@ export class GameManager { return this.games.get(id) ?? null; } + /** + * Returns the existing client's stored identity for a (persistentID, gameID) + * pair without modifying any state. Used to short-circuit re-validation when + * a reconnect sends an unchanged username + clan tag. + */ + public peekClientIdentity( + persistentID: string, + gameID: GameID, + ): { username: string; clanTag: string | null } | null { + const game = this.games.get(gameID); + if (!game) return null; + return game.peekClientIdentity(persistentID); + } + public publicLobbies(): GameServer[] { return Array.from(this.games.values()).filter( (g) => g.phase() === GamePhase.Lobby && g.isPublic(), diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 59673e2fd..96bf95ff4 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -187,6 +187,19 @@ export class GameServer { ); } + // Read-only snapshot of an existing client's identity, or null if no client + // is registered for this persistentID (including kicked clients, which are + // treated as gone for matching purposes). + public peekClientIdentity( + persistentID: string, + ): { username: string; clanTag: string | null } | null { + const clientID = this.getClientIdForPersistentId(persistentID); + if (!clientID) return null; + const client = this.allClients.get(clientID); + if (!client) return null; + return { username: client.username, clanTag: client.clanTag }; + } + // Get existing clientID for this persistentID, or null if new player public getClientIdForPersistentId(persistentID: string): ClientID | null { const clientID = this.persistentIdToClientId.get(persistentID); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index d433aef47..252c3f0b9 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,11 +7,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; -import { - ClanExistsResponseSchema, - clanExistsApiPath, - type UserMeResponse, -} from "../core/ApiSchemas"; +import { type UserMeResponse } from "../core/ApiSchemas"; import { GameEnv } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { @@ -23,6 +19,7 @@ import { import { generateID, replacer } from "../core/Util"; import { CreateGameInputSchema } from "../core/WorkerSchemas"; import { archive, finalizeGameRecord } from "./Archive"; +import { clanExistsByTag, resolveClanTag } from "./ClanTagOwnership"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { registerGamePreviewRoute } from "./GamePreviewRoute"; @@ -43,74 +40,6 @@ const workerId = ServerEnv.workerId() ?? 0; const log = logger.child({ comp: `w_${workerId}` }); const playlist = new MapPlaylist(); -// Clan-existence probe used by the join-time ownership check. -// Caches results briefly so a lobby surge doesn't fan out to the auth API. -// Returns null on transport errors / unexpected statuses so callers fail open. -const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; -const CLAN_EXISTS_CACHE_TTL_MS = 60_000; -const clanExistsCache = new Map< - string, - { result: boolean; expiresAt: number } ->(); - -async function clanExistsByTag(tag: string): Promise { - const cacheKey = tag.toUpperCase(); - const entry = clanExistsCache.get(cacheKey); - if (entry !== undefined) { - if (Date.now() < entry.expiresAt) return entry.result; - clanExistsCache.delete(cacheKey); - } - - try { - const url = `${ServerEnv.jwtIssuer()}${clanExistsApiPath(tag)}`; - const response = await fetch(url, { - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), - }); - if (response.status === 200) { - // Upstream currently has no body; tolerate {exists:false} for forward-compat. - try { - const text = await response.text(); - if (text.length > 0) { - const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text)); - if (parsed.success && parsed.data?.exists === false) { - clanExistsCache.set(cacheKey, { - result: false, - expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, - }); - return false; - } - } - } catch { - // Forward-compat parsing only; ignore failures. - } - clanExistsCache.set(cacheKey, { - result: true, - expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, - }); - return true; - } - if (response.status === 404) { - clanExistsCache.set(cacheKey, { - result: false, - expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS, - }); - return false; - } - log.warn("clanExistsByTag: unexpected status, failing open", { - tag: cacheKey, - status: response.status, - }); - return null; - } catch (e) { - log.warn("clanExistsByTag: fetch failed, failing open", { - tag: cacheKey, - error: e instanceof Error ? e.message : String(e), - }); - return null; - } -} - // Worker setup export async function startWorker() { log.info(`Worker starting...`); @@ -435,6 +364,29 @@ export async function startWorker() { .get() .censor(clientMsg.username, clientMsg.clanTag ?? null); + // Fast-path: if an existing client for this persistentID already wears + // the same (censored) identity, the ownership check was performed at + // join time. Skip getUserMe and the existence probe — they're an auth + // round-trip we don't need. + const existingIdentity = gm.peekClientIdentity( + persistentId, + clientMsg.gameID, + ); + if ( + existingIdentity !== null && + existingIdentity.username === censoredUsername && + existingIdentity.clanTag === censoredClanTag + ) { + if ( + gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { + username: censoredUsername, + clanTag: censoredClanTag, + }) + ) { + return; + } + } + // Fetch user profile up front. Needed here so the clan-tag ownership // check can run before the reconnect fast-path (otherwise a refresh // would let a player swap to an unvalidated tag), and reused below @@ -463,29 +415,24 @@ export async function startWorker() { // Enforce clan tag ownership. A player can wear a tag only if they're // a member; if they're not and the tag belongs to a real clan, drop it // to prevent impersonation. Fictional tags pass through. - let resolvedClanTag = censoredClanTag; - if (resolvedClanTag !== null) { - const userClanTags = new Set( - userMeResponse - ? (userMeResponse.player.clans ?? []).map((c) => - c.tag.toUpperCase(), - ) - : [], - ); - if (!userClanTags.has(resolvedClanTag.toUpperCase())) { - // Fail closed: inconclusive (null) means drop, not keep. - const exists = await clanExistsByTag(resolvedClanTag); - if (exists !== false) { - log.warn("Dropped clan tag: player is not a member", { - persistentID: persistentId, - gameID: clientMsg.gameID, - clanTag: resolvedClanTag, - existsResult: exists, - }); - resolvedClanTag = null; - } - } + const resolution = await resolveClanTag( + censoredClanTag, + userMeResponse, + (tag) => + clanExistsByTag(tag, { + baseUrl: ServerEnv.jwtIssuer(), + onWarn: (event, ctx) => log.warn(event, ctx), + }), + ); + if (resolution.dropped) { + log.warn("Dropped clan tag: player is not a member", { + persistentID: persistentId, + gameID: clientMsg.gameID, + clanTag: censoredClanTag, + reason: resolution.reason, + }); } + const resolvedClanTag = resolution.tag; // Try to reconnect an existing client (e.g. page refresh). Pre-game, // username and clan tag pick up the latest validated values from this diff --git a/tests/server/ClanTagOwnership.test.ts b/tests/server/ClanTagOwnership.test.ts new file mode 100644 index 000000000..4443694b5 --- /dev/null +++ b/tests/server/ClanTagOwnership.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { UserMeResponse } from "../../src/core/ApiSchemas"; +import { + clanExistsByTag, + resolveClanTag, +} from "../../src/server/ClanTagOwnership"; + +const okResponse = (status: number, body = ""): Response => + ({ + status, + text: async () => body, + }) as unknown as Response; + +const userWithClans = (tags: string[]): UserMeResponse => + ({ + user: {}, + player: { + publicId: "p1", + adfree: false, + flares: [], + achievements: { singleplayerMap: [] }, + friends: [], + subscription: null, + clans: tags.map((tag) => ({ + tag, + name: tag, + role: "member" as const, + joinedAt: new Date().toISOString(), + memberCount: 1, + })), + }, + }) as UserMeResponse; + +describe("clanExistsByTag", () => { + let cache: Map; + let now: number; + + beforeEach(() => { + cache = new Map(); + now = 1_000_000; + }); + + it("returns true on HTTP 200", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + const result = await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(result).toBe(true); + }); + + it("returns false on HTTP 404 without caching it", async () => { + const fetcher = vi.fn(async () => okResponse(404)); + const result = await clanExistsByTag("XYZ", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(result).toBe(false); + // Negative results must not poison the cache — a clan can be created + // moments after a 404 and a stale "false" would briefly let non-members + // wear the tag. + expect(cache.size).toBe(0); + }); + + it("returns null on unexpected status (fail-closed) and does not cache", async () => { + const fetcher = vi.fn(async () => okResponse(503)); + const result = await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(result).toBeNull(); + expect(cache.size).toBe(0); + }); + + it("returns null on transport error (fail-closed)", async () => { + const fetcher = vi.fn(async () => { + throw new Error("offline"); + }); + const result = await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(result).toBeNull(); + }); + + it("uppercases the tag in the request URL", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + await clanExistsByTag("abc", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + const calledUrl = (fetcher.mock.calls[0] as unknown[])[0] as string; + expect(calledUrl).toContain("/public/clan/ABC/exists"); + }); + + it("serves positive results from cache without re-fetching", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("re-fetches positive entries after TTL expiry", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + ttlMs: 1000, + }); + now += 2000; + await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + ttlMs: 1000, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("evicts the oldest entry when maxEntries is exceeded", async () => { + const fetcher = vi.fn(async () => okResponse(200)); + const deps = { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + maxEntries: 2, + }; + await clanExistsByTag("A", deps); + await clanExistsByTag("B", deps); + await clanExistsByTag("C", deps); + expect(cache.size).toBe(2); + expect(cache.has("A")).toBe(false); + expect(cache.has("B")).toBe(true); + expect(cache.has("C")).toBe(true); + }); + + it("treats body {exists:false} as false on 200 without caching", async () => { + const fetcher = vi.fn(async () => + okResponse(200, JSON.stringify({ exists: false })), + ); + const result = await clanExistsByTag("ABC", { + baseUrl: "https://auth.example", + fetcher: fetcher as unknown as typeof fetch, + cache, + now: () => now, + }); + expect(result).toBe(false); + expect(cache.size).toBe(0); + }); +}); + +describe("resolveClanTag", () => { + it("passes a null tag through unchanged", async () => { + const probe = vi.fn(); + const result = await resolveClanTag(null, null, probe); + expect(result).toEqual({ tag: null, dropped: false }); + expect(probe).not.toHaveBeenCalled(); + }); + + it("accepts a tag when the user is a member (case-insensitive)", async () => { + const probe = vi.fn(); + const me = userWithClans(["abc"]); + const result = await resolveClanTag("ABC", me, probe); + expect(result).toEqual({ tag: "ABC", dropped: false }); + expect(probe).not.toHaveBeenCalled(); + }); + + it("drops a tag belonging to a real clan the user does not belong to", async () => { + const probe = vi.fn(async () => true); + const me = userWithClans(["other"]); + const result = await resolveClanTag("ABC", me, probe); + expect(result).toEqual({ tag: null, dropped: true, reason: "exists" }); + }); + + it("keeps a tag that does not match any real clan (fictional)", async () => { + const probe = vi.fn(async () => false); + const result = await resolveClanTag("ABC", null, probe); + expect(result).toEqual({ tag: "ABC", dropped: false }); + }); + + it("drops the tag on inconclusive existence check (fail-closed)", async () => { + const probe = vi.fn(async () => null); + const result = await resolveClanTag("ABC", null, probe); + expect(result).toEqual({ + tag: null, + dropped: true, + reason: "inconclusive", + }); + }); + + it("treats anonymous users as members of no clans", async () => { + const probe = vi.fn(async () => true); + const result = await resolveClanTag("ABC", null, probe); + expect(result.tag).toBeNull(); + expect(result.dropped).toBe(true); + expect(probe).toHaveBeenCalledWith("ABC"); + }); +});