This commit is contained in:
Ryan Barlow
2026-05-29 00:15:30 +01:00
parent b043dc6c15
commit e9f3d9ebd5
11 changed files with 529 additions and 45 deletions
+44 -1
View File
@@ -1,3 +1,4 @@
import { clanExistsApiPath } from "../core/ApiSchemas";
import {
type ClanBansResponse,
ClanBansResponseSchema,
@@ -16,8 +17,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 +128,46 @@ export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
}
}
// Public existence probe (no auth). null = inconclusive (timeout / error /
// unexpected status); the caller decides how to handle it.
export async function fetchClanExists(tag: string): Promise<boolean | null> {
try {
const res = await fetch(`${getApiBase()}${clanExistsApiPath(tag)}`, {
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 (see
* resolveClanTag in Privilege.ts): members keep their tag, non-members keep a
* fictional tag, and a real clan they don't belong to — or anything we can't
* verify — is rejected. Resolves to the tag to submit (null when dropped) plus
* an i18n error key for inline feedback. 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"
+12 -1
View File
@@ -80,6 +80,11 @@ export interface LobbyConfig {
cosmetics: PlayerCosmeticRefs;
playerName: string;
playerClanTag: string | null;
// In-flight clan-tag ownership check (kicked off as the player types). When
// present, the join is gated on it: it resolves to the tag to actually
// submit (null when dropped), and runs in parallel with the WS handshake so
// only the joinGame() send waits on it.
clanTagCheck?: Promise<string | null>;
playerRole: string | null;
gameID: GameID;
turnstileToken: string | null;
@@ -116,7 +121,13 @@ export function joinLobby(
let currentGameRunner: ClientGameRunner | null = null;
const onconnect = () => {
const onconnect = async () => {
// Gate the join on the clan-tag ownership check. The WS handshake already
// ran in parallel; only the submit waits. Strip the tag if it didn't pass —
// the server re-checks authoritatively regardless.
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();
+1
View File
@@ -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,
+69 -10
View File
@@ -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<string | null> = Promise.resolve(null);
// Remove static styles since we're using Tailwind
createRenderRoot() {
@@ -49,6 +60,33 @@ 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<string | null> {
return this.clanCheck;
}
private startClanCheck() {
const gen = ++this.clanCheckGen;
const tag = this.clanTag;
if (tag.length === 0 || !validateClanTag(tag).isValid) {
this.clanTagOwnershipError = "";
this.clanCheckPending = false;
this.clanCheck = Promise.resolve(null);
return;
}
this.clanTagOwnershipError = "";
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 +127,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 +137,25 @@ export class UsernameInput extends LitElement {
render() {
return html`
<div class="flex items-center w-full h-full gap-2">
<input
type="text"
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<div class="relative flex items-center shrink-0">
<input
type="text"
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
aria-busy=${this.clanCheckPending ? "true" : "false"}
aria-invalid=${this.clanTagOwnershipError ? "true" : "false"}
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
${this.clanCheckPending
? html`<span
class="absolute right-1 top-1/2 -translate-y-1/2 w-3 h-3 border-2 border-white/30 border-t-white/80 rounded-full animate-spin pointer-events-none"
aria-hidden="true"
></span>`
: null}
</div>
<input
type="text"
.value=${this.baseUsername}
@@ -124,7 +173,16 @@ export class UsernameInput extends LitElement {
>
${this.validationError}
</div>`
: null}
: this.clanTagOwnershipError
? html`<div
id="clan-tag-validation-error"
class="absolute top-full left-0 z-50 mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg whitespace-nowrap"
>
${translateText(this.clanTagOwnershipError, {
tag: this.clanTag,
})}
</div>`
: null}
`;
}
@@ -151,6 +209,7 @@ export class UsernameInput extends LitElement {
}
this.clanTag = val;
this.validateAndStore();
this.startClanCheck();
}
private handleUsernameChange(e: Event) {
+6
View File
@@ -21,6 +21,12 @@ export const RefreshResponseSchema = z.object({
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
// Existence-probe path (200 = exists, 404 = not); uppercased to match the
// canonical tag form. Shared so the client probe and server enforcement agree.
export function clanExistsApiPath(tag: string): string {
return `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`;
}
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
+81
View File
@@ -11,6 +11,7 @@ import {
} from "obscenity";
import countries from "resources/countries.json";
import { clanExistsApiPath, type UserMeResponse } from "../core/ApiSchemas";
import { Cosmetics } from "../core/CosmeticSchemas";
import { decodePatternData } from "../core/PatternDecoder";
import {
@@ -151,6 +152,86 @@ function censorWithMatcher(
return { username: censoredName, clanTag: censoredClanTag };
}
export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
interface ClanProbeDeps {
/** Base URL of the upstream auth API (issuer). */
baseUrl: string;
/** Injected so tests can stub network behavior. */
fetcher?: typeof fetch;
/** Logger callback for unexpected statuses / transport errors. */
onWarn?: (event: string, ctx: Record<string, unknown>) => void;
}
/**
* 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 treat null as fail-closed (drop the tag).
*/
export async function clanExistsByTag(
tag: string,
deps: ClanProbeDeps,
): Promise<boolean | null> {
const fetcher = deps.fetcher ?? fetch;
try {
const response = await fetcher(`${deps.baseUrl}${clanExistsApiPath(tag)}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
});
if (response.status === 200) return true;
if (response.status === 404) return false;
deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", {
tag: tag.toUpperCase(),
status: response.status,
});
return null;
} catch (e) {
deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", {
tag: tag.toUpperCase(),
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 pass through unchanged.
* - Non-members keep the tag only when the API confirms no such clan exists
* (a fictional tag).
* - A real clan the player isn't in, or an inconclusive check, drops the tag
* (fail-closed) — `reason` lets callers log the impersonation attempt.
*/
export async function resolveClanTag(
censoredTag: string | null,
userMeResponse: UserMeResponse | null,
existsChecker: (tag: string) => Promise<boolean | null>,
): 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",
};
}
type CosmeticResult =
| { type: "allowed"; cosmetics: PlayerCosmetics }
| { type: "forbidden"; reason: string };
+59 -32
View File
@@ -7,6 +7,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { WebSocket, WebSocketServer } from "ws";
import { z } from "zod";
import { type UserMeResponse } from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import {
@@ -27,6 +28,7 @@ import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
import { startPolling } from "./PollingLoop";
import { clanExistsByTag, resolveClanTag } from "./Privilege";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { ServerEnv } from "./ServerEnv";
import { applyStaticAssetCacheControl } from "./StaticAssetCache";
@@ -357,29 +359,17 @@ 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
if (
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: censoredClanTag,
})
) {
return;
}
let flares: string[] | undefined;
let publicId: string | undefined;
let friends: string[] = [];
// Fetch the user profile up front. It's needed here so the clan-tag
// ownership check can run *before* the reconnect path below — otherwise
// a page refresh would let a player swap to an unvalidated tag — and is
// reused for flares/cosmetics on new joins.
const allowedFlares = ServerEnv.allowedFlares();
let userMeResponse: UserMeResponse | null = null;
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
@@ -397,21 +387,58 @@ export async function startWorker() {
ws.close(1002, "Unauthorized: user me fetch failed");
return;
}
flares = result.response.player.flares;
publicId = result.response.player.publicId;
friends = result.response.player.friends;
userMeResponse = result.response;
}
if (allowedFlares !== undefined) {
const allowed =
allowedFlares.length === 0 ||
allowedFlares.some((f) => flares?.includes(f));
if (!allowed) {
log.warn(
"Forbidden: player without an allowed flare attempted to join game",
);
ws.close(1002, "Forbidden");
return;
}
// Enforce clan tag ownership. A player can wear a tag only if they're a
// member; if they aren't and the tag belongs to a real clan, drop it to
// prevent impersonation. Fictional tags pass through.
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
// connection.
if (
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username: censoredUsername,
clanTag: resolvedClanTag,
})
) {
return;
}
// New client — finish the join checks.
const flares = userMeResponse?.player.flares;
const publicId = userMeResponse?.player.publicId;
const friends = userMeResponse?.player.friends ?? [];
if (userMeResponse !== null && allowedFlares !== undefined) {
const allowed =
allowedFlares.length === 0 ||
allowedFlares.some((f) => flares?.includes(f));
if (!allowed) {
log.warn(
"Forbidden: player without an allowed flare attempted to join game",
);
ws.close(1002, "Forbidden");
return;
}
}
@@ -463,7 +490,7 @@ export async function startWorker() {
flares,
ip,
censoredUsername,
censoredClanTag,
resolvedClanTag,
ws,
cosmeticResult.cosmetics,
publicId,