enforce clantag

This commit is contained in:
Ryan Barlow
2026-05-24 00:59:43 +01:00
parent db501c68d2
commit 08528b7cfa
7 changed files with 180 additions and 22 deletions
+2 -1
View File
@@ -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",
+17
View File
@@ -125,6 +125,23 @@ export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
}
}
// 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<boolean | null> {
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"
+106 -11
View File
@@ -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<string, string>;
@@ -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<typeof setTimeout> | 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;
}
+1 -1
View File
@@ -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;
+7 -2
View File
@@ -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);
+32 -7
View File
@@ -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<string> = 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,
+15
View File
@@ -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<boolean | null> {
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;
}
}