diff --git a/resources/lang/en.json b/resources/lang/en.json index b48625c30..13e735459 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -661,7 +661,8 @@ "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." }, "host_modal": { "title": "Create Private Lobby", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 9b7f62000..71eb62a45 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -125,6 +125,23 @@ export async function fetchClanDetail(tag: string): Promise { } } +// Lightweight existence probe. Public endpoint, no auth required — used to +// detect clan-tag ownership conflicts when a user types a tag into the input. +// Returns null on unexpected statuses so callers can fail open. +export async function fetchClanExists(tag: string): Promise { + try { + const res = await fetch( + `${getApiBase()}/public/clan/${encodeURIComponent(tag)}/exists`, + { headers: { Accept: "application/json" } }, + ); + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; + } catch { + return null; + } +} + export type ClanMemberSort = | "default" | "winsTotal" diff --git a/src/client/UsernameInput.ts b/src/client/UsernameInput.ts index c5defb899..23d5c72b5 100644 --- a/src/client/UsernameInput.ts +++ b/src/client/UsernameInput.ts @@ -10,8 +10,12 @@ import { validateClanTag, validateUsername, } from "../core/validations/username"; +import { getUserMe } from "./Api"; +import { fetchClanExists } from "./ClanApi"; import { crazyGamesSDK } from "./CrazyGamesSDK"; +const CLAN_OWNERSHIP_DEBOUNCE_MS = 400; + interface LangSelectorLike { currentLang?: string; translations?: Record; @@ -29,6 +33,11 @@ export class UsernameInput extends LitElement { @property({ type: String }) validationError: string = ""; private _isValid: boolean = true; private _lastValidatedLang: string | null = null; + private syncValidationError: string = ""; + private syncIsValid: boolean = true; + private clanTagAsyncError: string = ""; + private clanTagCheckCounter: number = 0; + private clanTagCheckTimer: ReturnType | null = null; // Remove static styles since we're using Tailwind @@ -179,22 +188,108 @@ export class UsernameInput extends LitElement { const clanTagResult = validateClanTag(this.clanTag); if (!clanTagResult.isValid) { - this._isValid = false; - this.validationError = clanTagResult.error ?? ""; - return; + this.syncIsValid = false; + this.syncValidationError = clanTagResult.error ?? ""; + } else { + const result = validateUsername(trimmedBase); + this.syncIsValid = result.isValid; + if (result.isValid) { + localStorage.setItem(usernameKey, trimmedBase); + // clanTag is persisted by scheduleClanTagOwnershipCheck (or its async + // continuation) so we never store a tag the server would reject. + this.syncValidationError = ""; + } else { + this.syncValidationError = result.error ?? ""; + } } - const result = validateUsername(trimmedBase); - this._isValid = result.isValid; - if (result.isValid) { - localStorage.setItem(usernameKey, trimmedBase); - localStorage.setItem(clanTagKey, this.getClanTag() ?? ""); - this.validationError = ""; - } else { - this.validationError = result.error ?? ""; + this.scheduleClanTagOwnershipCheck(); + this.updateValidationState(); + } + + private persistClanTag(tag: string) { + if (this.syncIsValid) { + localStorage.setItem(clanTagKey, tag); } } + private updateValidationState() { + if (!this.syncIsValid) { + this._isValid = false; + this.validationError = this.syncValidationError; + return; + } + if (this.clanTagAsyncError) { + this._isValid = false; + this.validationError = this.clanTagAsyncError; + return; + } + this._isValid = true; + this.validationError = ""; + } + + private scheduleClanTagOwnershipCheck() { + if (this.clanTagCheckTimer !== null) { + clearTimeout(this.clanTagCheckTimer); + this.clanTagCheckTimer = null; + } + const tag = this.clanTag; + if ( + tag.length < MIN_CLAN_TAG_LENGTH || + tag.length > MAX_CLAN_TAG_LENGTH || + !validateClanTag(tag).isValid + ) { + // Bump the counter so any in-flight check is discarded. + this.clanTagCheckCounter++; + if (this.clanTagAsyncError) { + this.clanTagAsyncError = ""; + } + // No async check needed — persist the (empty/short) value so clearing + // the tag is remembered across reloads. + this.persistClanTag(this.getClanTag() ?? ""); + return; + } + this.clanTagCheckTimer = setTimeout(() => { + this.clanTagCheckTimer = null; + void this.runClanTagOwnershipCheck(tag); + }, CLAN_OWNERSHIP_DEBOUNCE_MS); + } + + private async runClanTagOwnershipCheck(expectedTag: string) { + const checkId = ++this.clanTagCheckCounter; + const stillCurrent = () => + checkId === this.clanTagCheckCounter && this.clanTag === expectedTag; + + const me = await getUserMe(); + if (!stillCurrent()) return; + if (me) { + const myTags = (me.player.clans ?? []).map((c) => c.tag.toUpperCase()); + if (myTags.includes(expectedTag.toUpperCase())) { + this.setClanTagAsyncError(""); + this.persistClanTag(expectedTag); + return; + } + } + + const exists = await fetchClanExists(expectedTag); + if (!stillCurrent()) return; + if (exists === true) { + this.setClanTagAsyncError( + translateText("username.tag_not_member", { tag: expectedTag }), + ); + } else { + this.setClanTagAsyncError(""); + this.persistClanTag(expectedTag); + } + } + + private setClanTagAsyncError(error: string) { + if (this.clanTagAsyncError === error) return; + this.clanTagAsyncError = error; + this.updateValidationState(); + this.requestUpdate(); + } + public isValid(): boolean { return this._isValid; } diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 72065a206..a18e002c5 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -42,7 +42,7 @@ export class GameManager { persistentID: string, gameID: GameID, lastTurn: number = 0, - identityUpdate?: { username: string; clanTag: string | null }, + identityUpdate?: { username: string; clanTag?: string | null }, ): boolean { const game = this.games.get(gameID); if (!game) return false; diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 59673e2fd..237470877 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -286,7 +286,7 @@ export class GameServer { ws: WebSocket, persistentID: string, lastTurn: number = 0, - identityUpdate?: { username: string; clanTag: string | null }, + identityUpdate?: { username: string; clanTag?: string | null }, ): boolean { const clientID = this.getClientIdForPersistentId(persistentID); if (!clientID) return false; @@ -308,7 +308,12 @@ export class GameServer { this.activeClients.push(client); if (identityUpdate && !this.hasStarted()) { client.username = identityUpdate.username; - client.clanTag = identityUpdate.clanTag; + // clanTag is only updated when explicitly provided. The reconnect + // fast-path omits it so a refreshed client can't swap to a tag the + // initial join didn't validate. + if (identityUpdate.clanTag !== undefined) { + client.clanTag = identityUpdate.clanTag; + } } client.lastPing = Date.now(); this.markClientDisconnected(client.clientID, false); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 59626396a..8534e3b4c 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -21,7 +21,7 @@ import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { registerGamePreviewRoute } from "./GamePreviewRoute"; -import { getUserMe, verifyClientToken } from "./jwt"; +import { clanExistsByTag, getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; @@ -357,19 +357,18 @@ export async function startWorker() { } // Normalize username and clan tag before any rejoin/join handling. - // If this connection maps to an existing lobby client, we still want - // the latest pre-join identity to be reflected. const { clanTag: censoredClanTag, username: censoredUsername } = privilegeRefresher .get() .censor(clientMsg.username, clientMsg.clanTag ?? null); - // Try to reconnect an existing client (e.g., page refresh) - // If successful, skip all authorization + // Try to reconnect an existing client (e.g., page refresh). + // Username may have changed since initial join; clanTag is intentionally + // omitted so the reconnect can't swap to a tag that wasn't validated on + // the original join. To change clan tag, the player must fully rejoin. if ( gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, { username: censoredUsername, - clanTag: censoredClanTag, }) ) { return; @@ -378,6 +377,7 @@ export async function startWorker() { let flares: string[] | undefined; let publicId: string | undefined; let friends: string[] = []; + let userClanTags: Set = new Set(); const allowedFlares = ServerEnv.allowedFlares(); if (claims === null) { @@ -400,6 +400,11 @@ export async function startWorker() { flares = result.response.player.flares; publicId = result.response.player.publicId; friends = result.response.player.friends; + userClanTags = new Set( + (result.response.player.clans ?? []).map((c) => + c.tag.toUpperCase(), + ), + ); if (allowedFlares !== undefined) { const allowed = @@ -454,6 +459,26 @@ 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. Runs after + // turnstile so we don't burn an API call on rejected bot joins. + let resolvedClanTag = censoredClanTag; + if ( + resolvedClanTag !== null && + !userClanTags.has(resolvedClanTag.toUpperCase()) + ) { + const exists = await clanExistsByTag(resolvedClanTag); + if (exists === true) { + log.warn("Dropped clan tag: player is not a member", { + persistentID: persistentId, + gameID: clientMsg.gameID, + clanTag: resolvedClanTag, + }); + resolvedClanTag = null; + } + } + // Create client and add to game const client = new Client( generateID(), @@ -463,7 +488,7 @@ export async function startWorker() { flares, ip, censoredUsername, - censoredClanTag, + resolvedClanTag, ws, cosmeticResult.cosmetics, publicId, diff --git a/src/server/jwt.ts b/src/server/jwt.ts index 3f699b813..135466150 100644 --- a/src/server/jwt.ts +++ b/src/server/jwt.ts @@ -98,3 +98,18 @@ export async function getUserMe( }; } } + +// Best-effort check: does a clan with this tag exist? +// Returns null on transport errors or unexpected statuses so callers can +// fail open — the goal is impersonation prevention, not availability blocker. +export async function clanExistsByTag(tag: string): Promise { + try { + const url = `${ServerEnv.jwtIssuer()}/public/clan/${encodeURIComponent(tag)}/exists`; + const response = await fetch(url); + if (response.status === 200) return true; + if (response.status === 404) return false; + return null; + } catch { + return null; + } +}