diff --git a/resources/lang/en.json b/resources/lang/en.json index ed980ff7f..751d387c1 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -669,7 +669,9 @@ "tag": "TAG", "tag_too_short": "Clan tag must be 2-5 alphanumeric characters.", "tag_too_long": "Clan tag cannot exceed 5 characters.", - "tag_invalid_chars": "Clan tag can only contain letters and numbers." + "tag_invalid_chars": "Clan tag can only contain letters and numbers.", + "tag_not_member": "Join the {tag} clan before using its tag.", + "tag_check_failed": "Couldn't verify clan tag. Try again or remove it." }, "host_modal": { "title": "Create Private Lobby", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 9b7f62000..a54d2bec5 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -16,8 +16,10 @@ import { ClanRequestsResponseSchema, JoinClanResponseSchema, } from "../core/ClanApiSchemas"; -import { getApiBase } from "./Api"; +import { getApiBase, getUserMe } from "./Api"; import { getAuthHeader } from "./Auth"; + +const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000; export type { ClanBan, ClanBansResponse, @@ -125,6 +127,46 @@ export async function fetchClanDetail(tag: string): Promise { } } +// Public existence probe (no auth). null = inconclusive (timeout / error / +// unexpected status); the caller decides how to handle it. The tag is +// uppercased to the canonical form so it matches the server's route. +export async function fetchClanExists(tag: string): Promise { + try { + const path = `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`; + const res = await fetch(`${getApiBase()}${path}`, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS), + }); + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; + } +} + +/** + * Client-side mirror of the server's clan-tag ownership rule (resolveClanTag in + * Privilege.ts), for instant inline feedback. Returns the tag to submit (null + * if dropped) and an i18n error key. The server re-checks authoritatively. + */ +export async function checkClanTagOwnership( + tag: string, +): Promise<{ tag: string | null; error: string | null }> { + const me = await getUserMe(); + const myTags = me + ? (me.player.clans ?? []).map((c) => c.tag.toUpperCase()) + : []; + if (myTags.includes(tag.toUpperCase())) { + return { tag, error: null }; + } + + const exists = await fetchClanExists(tag); + if (exists === false) return { tag, error: null }; + if (exists === true) return { tag: null, error: "username.tag_not_member" }; + return { tag: null, error: "username.tag_check_failed" }; +} + export type ClanMemberSort = | "default" | "winsTotal" diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 808ca0ed1..eb806dccf 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -84,6 +84,9 @@ export interface LobbyConfig { cosmetics: PlayerCosmeticRefs; playerName: string; playerClanTag: string | null; + // In-flight clan-tag ownership check; resolves to the tag to submit (null if + // it failed). Runs parallel to the WS handshake — only the join waits on it. + clanTagCheck?: Promise; playerRole: string | null; gameID: GameID; turnstileToken: string | null; @@ -121,7 +124,11 @@ export function joinLobby( let currentGameRunner: ClientGameRunner | null = null; - const onconnect = () => { + const onconnect = async () => { + // Drop the tag if the ownership check failed; the server re-checks anyway. + if (lobbyConfig.clanTagCheck !== undefined) { + lobbyConfig.playerClanTag = await lobbyConfig.clanTagCheck; + } // Always send join - server will detect reconnection via persistentID console.log(`Joining game lobby ${lobbyConfig.gameID}`); transport.joinGame(); diff --git a/src/client/Main.ts b/src/client/Main.ts index bd73b5f46..82c9c38dd 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -842,6 +842,7 @@ class Client { turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getUsername() ?? genAnonUsername(), playerClanTag: this.usernameInput?.getClanTag() ?? null, + clanTagCheck: this.usernameInput?.getClanCheck(), playerRole, gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info, gameRecord: lobby.gameRecord, diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index c5defb899..50c6dba74 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -10,6 +10,7 @@ import { validateClanTag, validateUsername, } from "../core/validations/username"; +import { checkClanTagOwnership } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; interface LangSelectorLike { @@ -27,9 +28,19 @@ export class UsernameInput extends LitElement { @state() private clanTag: string = ""; @property({ type: String }) validationError: string = ""; + // Ownership-check feedback (i18n key) shown inline beneath the tag input. This + // is advisory only — it does not gate play; the tag is stripped on submit and + // the server re-checks authoritatively. + @state() private clanTagOwnershipError: string = ""; + @state() private clanCheckPending: boolean = false; private _isValid: boolean = true; private _lastValidatedLang: string | null = null; + // Latest in-flight ownership check. `clanCheckGen` discards stale results so + // only the most recent keystroke updates the UI / resolves the submit value. + private clanCheckGen = 0; + private clanCheck: Promise = Promise.resolve(null); + // Remove static styles since we're using Tailwind createRenderRoot() { @@ -49,6 +60,32 @@ export class UsernameInput extends LitElement { : null; } + // Resolves to the clan tag to actually submit (null when it should be + // dropped). The join flow awaits this so the ownership check — kicked off on + // input — can run in parallel with the WebSocket handshake. + public getClanCheck(): Promise { + return this.clanCheck; + } + + private startClanCheck() { + const gen = ++this.clanCheckGen; + const tag = this.clanTag; + this.clanTagOwnershipError = ""; + if (tag.length === 0 || !validateClanTag(tag).isValid) { + this.clanCheckPending = false; + this.clanCheck = Promise.resolve(null); + return; + } + this.clanCheckPending = true; + this.clanCheck = checkClanTagOwnership(tag).then((res) => { + if (gen === this.clanCheckGen) { + this.clanTagOwnershipError = res.error ?? ""; + this.clanCheckPending = false; + } + return res.tag; + }); + } + connectedCallback() { super.connectedCallback(); this.loadStoredUsername(); @@ -89,6 +126,7 @@ export class UsernameInput extends LitElement { this.clanTag = localStorage.getItem(clanTagKey) ?? ""; this.baseUsername = storedUsername; this.validateAndStore(); + this.startClanCheck(); } else { this.baseUsername = genAnonUsername(); this.validateAndStore(); @@ -98,15 +136,25 @@ export class UsernameInput extends LitElement { render() { return html`
- +
+ + ${this.clanCheckPending + ? html`` + : null} +
${this.validationError}
` - : null} + : this.clanTagOwnershipError + ? html`
+ ${translateText(this.clanTagOwnershipError, { + tag: this.clanTag, + })} +
` + : null} `; } @@ -151,6 +208,7 @@ export class UsernameInput extends LitElement { } this.clanTag = val; this.validateAndStore(); + this.startClanCheck(); } private handleUsernameChange(e: Event) { diff --git a/src/core/ClanApiSchemas.ts b/src/core/ClanApiSchemas.ts index eb8b01130..b151114fc 100644 --- a/src/core/ClanApiSchemas.ts +++ b/src/core/ClanApiSchemas.ts @@ -3,6 +3,12 @@ import { ClanTagSchema } from "./Schemas"; const RequiredClanTagSchema = ClanTagSchema.unwrap(); +// Response for the game-server endpoint listing every registered clan tag. +export const ReservedClanTagsResponseSchema = z.array(z.string()); +export type ReservedClanTagsResponse = z.infer< + typeof ReservedClanTagsResponseSchema +>; + export const ClanLeaderboardEntrySchema = z.object({ clanTag: RequiredClanTagSchema, games: z.number(), diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 28edfe8cf..2605630dd 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -151,6 +151,33 @@ function censorWithMatcher( return { username: censoredName, clanTag: censoredClanTag }; } +export type ClanTagResolution = { + tag: string | null; + dropped: boolean; +}; + +/** + * The clan-tag ownership rule, shared by every PrivilegeChecker: + * - member of the clan -> keep the tag + * - not a member, tag not reserved -> fictional tag, keep it + * - otherwise -> drop it (impersonation) + * `reservedTags` is every registered tag (uppercase); null means the reserved + * list is unavailable (cosmetics infra still loading), in which case an + * unverifiable tag counts as reserved and is dropped fail-closed. + */ +function decideClanTag( + censoredTag: string | null, + ownedClanTags: string[], + reservedTags: Set | null, +): ClanTagResolution { + if (censoredTag === null) return { tag: null, dropped: false }; + const tag = censoredTag.toUpperCase(); + const isMember = ownedClanTags.some((t) => t.toUpperCase() === tag); + const isReserved = reservedTags === null || reservedTags.has(tag); + if (isMember || !isReserved) return { tag: censoredTag, dropped: false }; + return { tag: null, dropped: true }; +} + type CosmeticResult = | { type: "allowed"; cosmetics: PlayerCosmetics } | { type: "forbidden"; reason: string }; @@ -161,6 +188,15 @@ export interface PrivilegeChecker { username: string, clanTag: string | null, ): { username: string; clanTag: string | null }; + /** + * Decide whether a player may wear the given (already-censored) clan tag. + * Members keep their tag; impersonated or unverifiable tags are dropped. + * `ownedClanTags` are the tags the player belongs to. + */ + resolveClanTag( + censoredTag: string | null, + ownedClanTags: string[], + ): ClanTagResolution; } export class PrivilegeCheckerImpl implements PrivilegeChecker { @@ -170,10 +206,20 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker { private cosmetics: Cosmetics, private b64urlDecode: (base64: string) => Uint8Array, bannedWords: string[], + // Every registered clan tag (uppercase). Polled by PrivilegeRefresher so + // ownership is resolved in memory — no per-join existence probe. + private reservedClanTags: Set = new Set(), ) { this.matcher = createMatcher(bannedWords); } + resolveClanTag( + censoredTag: string | null, + ownedClanTags: string[], + ): ClanTagResolution { + return decideClanTag(censoredTag, ownedClanTags, this.reservedClanTags); + } + isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult { const cosmetics: PlayerCosmetics = {}; if (refs.patternName) { @@ -325,4 +371,14 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker { ): { username: string; clanTag: string | null } { return censorWithMatcher(username, clanTag, defaultMatcher); } + + // No reserved-tag list while cosmetics infra is unavailable (null), so a + // non-member's tag is treated as reserved and dropped fail-closed to block + // impersonation. Members are still known from their own tag list. + resolveClanTag( + censoredTag: string | null, + ownedClanTags: string[], + ): ClanTagResolution { + return decideClanTag(censoredTag, ownedClanTags, null); + } } diff --git a/src/server/PrivilegeRefresher.ts b/src/server/PrivilegeRefresher.ts index 7561c57ec..f26fc022c 100644 --- a/src/server/PrivilegeRefresher.ts +++ b/src/server/PrivilegeRefresher.ts @@ -1,5 +1,6 @@ import { base64url } from "jose"; import { Logger } from "winston"; +import { ReservedClanTagsResponseSchema } from "../core/ClanApiSchemas"; import { CosmeticsSchema } from "../core/CosmeticSchemas"; import { startPolling } from "./PollingLoop"; import { @@ -22,6 +23,7 @@ export class PrivilegeRefresher { private cosmeticsEndpoint: string, private profaneWordsEndpoint: string, private apiKey: string, + private reservedClanTagsEndpoint: string, parentLog: Logger, private refreshInterval: number = 1000 * 60 * 3, ) { @@ -58,9 +60,14 @@ export class PrivilegeRefresher { } }; - const [cosmeticsResponse, profaneWordsResponse] = await Promise.all([ + const [ + cosmeticsResponse, + profaneWordsResponse, + reservedClanTagsResponse, + ] = await Promise.all([ fetchWithTimeout(this.cosmeticsEndpoint), fetchWithTimeout(this.profaneWordsEndpoint), + fetchWithTimeout(this.reservedClanTagsEndpoint), ]); if (!cosmeticsResponse || !cosmeticsResponse.ok) { @@ -76,6 +83,26 @@ export class PrivilegeRefresher { throw new Error(`Invalid cosmetics data: ${result.error.message}`); } + // Reserved clan tags are critical: a missing or malformed list would + // make every non-member tag look fictional and let impersonation + // through. Throw so the previous (good) checker is retained instead. + if (!reservedClanTagsResponse || !reservedClanTagsResponse.ok) { + throw new Error( + `Reserved clan tags HTTP error! status: ${reservedClanTagsResponse?.status ?? "network error"}`, + ); + } + const reservedClanTagsData = await reservedClanTagsResponse.json(); + const reservedClanTagsResult = + ReservedClanTagsResponseSchema.safeParse(reservedClanTagsData); + if (!reservedClanTagsResult.success) { + throw new Error( + `Invalid reserved clan tags data: ${reservedClanTagsResult.error.message}`, + ); + } + const reservedClanTags = new Set( + reservedClanTagsResult.data.map((tag) => tag.toUpperCase()), + ); + let bannedWords: string[] = []; if (profaneWordsResponse && profaneWordsResponse.ok) { try { @@ -96,11 +123,14 @@ export class PrivilegeRefresher { result.data, base64url.decode, bannedWords, + reservedClanTags, ); this.cosmeticFlagUrls = new Set( Object.values(result.data.flags).map((f) => f.url), ); - this.log.info(`Privilege checker loaded successfully`); + this.log.info( + `Privilege checker loaded successfully (${reservedClanTags.size} reserved clan tags)`, + ); } catch (error) { this.log.error(`Failed to load privilege checker:`, error); throw error; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 59626396a..00419bfce 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -73,6 +73,7 @@ export async function startWorker() { ServerEnv.jwtIssuer() + "/cosmetics.json", ServerEnv.jwtIssuer() + "/profane_words_game_server", ServerEnv.apiKey(), + ServerEnv.jwtIssuer() + "/reserved_clan_tags", log, ); privilegeRefresher.start(); @@ -378,6 +379,7 @@ export async function startWorker() { let flares: string[] | undefined; let publicId: string | undefined; let friends: string[] = []; + let ownedClanTags: string[] = []; const allowedFlares = ServerEnv.allowedFlares(); if (claims === null) { @@ -400,6 +402,7 @@ export async function startWorker() { flares = result.response.player.flares; publicId = result.response.player.publicId; friends = result.response.player.friends; + ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? []; if (allowedFlares !== undefined) { const allowed = @@ -415,6 +418,21 @@ export async function startWorker() { } } + // Enforce clan tag ownership: a player can wear a tag only if they're + // a member; a real clan they're not in (or an unverifiable tag) is + // dropped to prevent impersonation. Fictional tags pass through. + const resolution = privilegeRefresher + .get() + .resolveClanTag(censoredClanTag, ownedClanTags); + if (resolution.dropped) { + log.warn("Dropped clan tag: player is not a member", { + persistentID: persistentId, + gameID: clientMsg.gameID, + clanTag: censoredClanTag, + }); + } + const resolvedClanTag = resolution.tag; + const cosmeticResult = privilegeRefresher .get() .isAllowed(flares ?? [], clientMsg.cosmetics ?? {}); @@ -463,7 +481,7 @@ export async function startWorker() { flares, ip, censoredUsername, - censoredClanTag, + resolvedClanTag, ws, cosmeticResult.cosmetics, publicId, diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 44185833c..9b272b3bc 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -1,5 +1,6 @@ import { createMatcher, + FailOpenPrivilegeChecker, PrivilegeCheckerImpl, shadowNames, } from "../src/server/Privilege"; @@ -519,3 +520,68 @@ describe("Skin validation", () => { }); }); }); + +describe("PrivilegeCheckerImpl#resolveClanTag", () => { + // Reserved tags are stored uppercase, exactly as PrivilegeRefresher loads them. + const makeChecker = (reservedTags: string[]) => + new PrivilegeCheckerImpl( + mockCosmetics, + mockDecoder, + bannedWords, + new Set(reservedTags), + ); + + it("passes a null tag through unchanged", () => { + const result = makeChecker(["ABC"]).resolveClanTag(null, []); + expect(result).toEqual({ tag: null, dropped: false }); + }); + + it("accepts a member's tag without consulting the reserved set (case-insensitive)", () => { + const result = makeChecker(["ABC"]).resolveClanTag("ABC", ["abc"]); + expect(result).toEqual({ tag: "ABC", dropped: false }); + }); + + it("drops a reserved tag the player does not belong to (impersonation)", () => { + const result = makeChecker(["ABC"]).resolveClanTag("ABC", ["other"]); + expect(result).toEqual({ tag: null, dropped: true }); + }); + + it("keeps a fictional tag matching no reserved clan", () => { + const result = makeChecker(["OTHER"]).resolveClanTag("ABC", []); + expect(result).toEqual({ tag: "ABC", dropped: false }); + }); + + it("matches the reserved set case-insensitively", () => { + const result = makeChecker(["ABC"]).resolveClanTag("abc", ["other"]); + expect(result).toEqual({ tag: null, dropped: true }); + }); + + it("treats anonymous users as members of no clans", () => { + const result = makeChecker(["ABC"]).resolveClanTag("ABC", []); + expect(result).toEqual({ tag: null, dropped: true }); + }); +}); + +describe("FailOpenPrivilegeChecker#resolveClanTag", () => { + const checker = new FailOpenPrivilegeChecker(); + + it("passes a null tag through unchanged", () => { + const result = checker.resolveClanTag(null, []); + expect(result).toEqual({ tag: null, dropped: false }); + }); + + it("keeps a member's tag (known from owned tags, no lookup needed)", () => { + const result = checker.resolveClanTag("ABC", ["abc"]); + expect(result).toEqual({ tag: "ABC", dropped: false }); + }); + + it("drops a non-member's tag fail-closed (no reserved set while infra is down)", () => { + const result = checker.resolveClanTag("ABC", ["other"]); + expect(result).toEqual({ tag: null, dropped: true }); + }); + + it("drops an anonymous user's tag fail-closed", () => { + const result = checker.resolveClanTag("ABC", []); + expect(result.dropped).toBe(true); + }); +}); diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index f55b3fb90..3a00d2714 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -2,20 +2,45 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../../../src/client/Api", () => ({ getApiBase: vi.fn(() => "http://localhost:3000"), + getUserMe: vi.fn(), })); vi.mock("../../../src/client/Auth", () => ({ getAuthHeader: vi.fn(async () => "Bearer test-token"), })); +import { getUserMe } from "../../../src/client/Api"; import { + checkClanTagOwnership, fetchClanDetail, + fetchClanExists, fetchClanGames, fetchClanLeaderboard, fetchClanMembers, fetchClanRequests, fetchClans, } from "../../../src/client/ClanApi"; +import type { UserMeResponse } from "../../../src/core/ApiSchemas"; + +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: "2024-01-01T00:00:00.000Z", + memberCount: 1, + })), + }, + }) as unknown as UserMeResponse; const okJson = (data: unknown, status = 200) => ({ ok: true, @@ -37,6 +62,109 @@ beforeEach(() => { vi.clearAllMocks(); }); +describe("fetchClanExists", () => { + const status = (s: number) => ({ status: s }); + + it("returns true on HTTP 200", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(200))), + ); + await expect(fetchClanExists("ABC")).resolves.toBe(true); + }); + + it("returns false on HTTP 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(404))), + ); + await expect(fetchClanExists("XYZ")).resolves.toBe(false); + }); + + it("returns null on unexpected status (5xx)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(503))), + ); + await expect(fetchClanExists("ABC")).resolves.toBeNull(); + }); + + it("returns null on transport error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.reject(new Error("offline"))), + ); + await expect(fetchClanExists("ABC")).resolves.toBeNull(); + }); + + it("uppercases and URL-encodes the tag in the request URL", async () => { + const fetchSpy = vi.fn( + (_input: string | URL | Request, _init?: RequestInit) => + Promise.resolve(status(200)), + ); + vi.stubGlobal("fetch", fetchSpy); + await fetchClanExists("abc"); + expect(fetchSpy.mock.calls[0]![0] as string).toContain( + "/public/clan/ABC/exists", + ); + await fetchClanExists("a/b"); + expect(fetchSpy.mock.calls[1]![0] as string).toContain( + "/public/clan/A%2FB/exists", + ); + }); +}); + +describe("checkClanTagOwnership", () => { + const status = (s: number) => ({ status: s }); + + it("accepts a tag the user is a member of without probing existence", async () => { + vi.mocked(getUserMe).mockResolvedValue(userWithClans(["abc"])); + const fetchSpy = vi.fn(() => Promise.resolve(status(200))); + vi.stubGlobal("fetch", fetchSpy); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: "ABC", + error: null, + }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("accepts a fictional tag (clan does not exist)", async () => { + vi.mocked(getUserMe).mockResolvedValue(userWithClans(["other"])); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(404))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: "ABC", + error: null, + }); + }); + + it("rejects a real clan the user does not belong to", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(200))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: null, + error: "username.tag_not_member", + }); + }); + + it("rejects on an inconclusive existence check", async () => { + vi.mocked(getUserMe).mockResolvedValue(false); + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(status(503))), + ); + await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ + tag: null, + error: "username.tag_check_failed", + }); + }); +}); + describe("fetchClanLeaderboard", () => { const leaderboardData = { start: "2024-01-01T00:00:00.000Z",