From 58c7cdd46f7cecee11abca56c1517767f2e7c11d Mon Sep 17 00:00:00 2001
From: Danny <21205085+Lavodan@users.noreply.github.com>
Date: Mon, 15 Dec 2025 19:07:15 +0100
Subject: [PATCH] Task: Unify username validation and remove username
sanitation (#2622)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description:
This PR centralizes all username validation using UsernameSchema with a
set maximum, minimum, and a regex pattern,
It also removes sanitization, as all places where the username would be
sanitized on the server have been gatekept, so no unvalidated usernames
can get onto the server past the ClientMessageSchema safeParse in
GameServer's on message func.
Here is how the errors look if that happens, Note that if the client is
funtioning correctly and the user doesn't manually send a WS message,
they should never see this. The screenshots are from a debug build where
client uname validation was disabled.
Profanity sanitization was not changed.
Additionally, the censor tests were updated to reflect the new
expectations.
Jose was added to the jest config as an allowed transform pattern, as it
didn't make sense to me to mock a zod schema.
The UsernameSchema pattern was set to `^[a-zA-Z0-9_ \[\]üÜ]+$`, I
personally think either we should allow all latin characters (regex has
a pattern for this, `\p{L}` or `\p{sc=Latin}`) and then we'd use some
kind of library to normalize all latin characters into regular ascii for
name filtering, or we should only keep ascii letters.
## Please complete the following:
- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
## Please put your Discord username so you can be contacted if a bug or
regression is found:
Lavodan
(I just realized sanitization isn't a word, it's supposed to be
sanitation, sorry.)
---
jest.config.ts | 2 +-
src/core/GameRunner.ts | 6 +--
src/core/Schemas.ts | 6 ++-
src/core/game/PlayerImpl.ts | 3 +-
src/core/validations/username.ts | 76 ++++++++++++++------------------
tests/Censor.test.ts | 23 ----------
6 files changed, 43 insertions(+), 73 deletions(-)
diff --git a/jest.config.ts b/jest.config.ts
index b69aa5338..b7f770614 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -15,7 +15,7 @@ export default {
"^.+\\.js$": ["@swc/jest"],
},
transformIgnorePatterns: [
- "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js)/)",
+ "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose)/)",
],
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
coverageThreshold: {
diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts
index 6a8a4042f..d7c74307a 100644
--- a/src/core/GameRunner.ts
+++ b/src/core/GameRunner.ts
@@ -29,7 +29,7 @@ import {
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
import { PseudoRandom } from "./PseudoRandom";
import { ClientID, GameStartInfo, Turn } from "./Schemas";
-import { sanitize, simpleHash } from "./Util";
+import { simpleHash } from "./Util";
import { censorNameWithClanTag } from "./validations/username";
export async function createGameRunner(
@@ -48,9 +48,7 @@ export async function createGameRunner(
const humans = gameStart.players.map((p) => {
return new PlayerInfo(
- p.clientID === clientID
- ? sanitize(p.username)
- : censorNameWithClanTag(p.username),
+ p.clientID === clientID ? p.username : censorNameWithClanTag(p.username),
PlayerType.Human,
p.clientID,
random.nextID(),
diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts
index 74b999b13..5ba0b04a5 100644
--- a/src/core/Schemas.ts
+++ b/src/core/Schemas.ts
@@ -209,7 +209,11 @@ export const ID = z
export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema);
-export const UsernameSchema = SafeString;
+export const UsernameSchema = z
+ .string()
+ .regex(/^[a-zA-Z0-9_ [\]üÜ]+$/u)
+ .min(3)
+ .max(27);
const countryCodes = countries.filter((c) => !c.restricted).map((c) => c.code);
export const QuickChatKeySchema = z.enum(
diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts
index 856dfa77a..9d9d7ecf1 100644
--- a/src/core/game/PlayerImpl.ts
+++ b/src/core/game/PlayerImpl.ts
@@ -9,7 +9,6 @@ import {
toInt,
within,
} from "../Util";
-import { sanitizeUsername } from "../validations/username";
import { AttackImpl } from "./AttackImpl";
import {
Alliance,
@@ -111,7 +110,7 @@ export class PlayerImpl implements Player {
startTroops: number,
private readonly _team: Team | null,
) {
- this._name = sanitizeUsername(playerInfo.name);
+ this._name = playerInfo.name;
this._troops = toInt(startTroops);
this._gold = 0n;
this._displayName = this._name;
diff --git a/src/core/validations/username.ts b/src/core/validations/username.ts
index 2096c0ae1..f9534411c 100644
--- a/src/core/validations/username.ts
+++ b/src/core/validations/username.ts
@@ -8,7 +8,8 @@ import {
skipNonAlphabeticTransformer,
} from "obscenity";
import { translateText } from "../../client/Utils";
-import { getClanTagOriginalCase, sanitize, simpleHash } from "../Util";
+import { UsernameSchema } from "../Schemas";
+import { getClanTagOriginalCase, simpleHash } from "../Util";
const matcher = new RegExpMatcher({
...englishDataset.build(),
@@ -22,8 +23,6 @@ const matcher = new RegExpMatcher({
export const MIN_USERNAME_LENGTH = 3;
export const MAX_USERNAME_LENGTH = 27;
-const validPattern = /^[a-zA-Z0-9_[\] üÜ]+$/u; // Don't allow more than in UserNameSchema
-
const shadowNames = [
"NicePeopleOnly",
"BeKindPlz",
@@ -58,7 +57,6 @@ export function isProfaneUsername(username: string): boolean {
*
* Examples:
* - "GoodName" -> "GoodName"
- * - "Good$Name" -> "GoodName"
* - "BadName" -> "Censored"
* - "[CLAN]GoodName" -> "[CLAN]GoodName"
* - "[CLaN]BadName" -> "[CLaN] Censored"
@@ -66,14 +64,12 @@ export function isProfaneUsername(username: string): boolean {
* - "[BAD]BadName" -> "Censored"
*/
export function censorNameWithClanTag(username: string): string {
- const sanitizedUsername = sanitize(username);
-
// Don't use getClanTag because that returns upperCase and if original isn't, str replace `[{$clanTag}]` won't match
- const clanTag = getClanTagOriginalCase(sanitizedUsername);
+ const clanTag = getClanTagOriginalCase(username);
const nameWithoutClan = clanTag
- ? sanitizedUsername.replace(`[${clanTag}]`, "").trim()
- : sanitizedUsername;
+ ? username.replace(`[${clanTag}]`, "").trim()
+ : username;
const clanTagIsProfane = clanTag ? isProfaneUsername(clanTag) : false;
const usernameIsProfane = isProfaneUsername(nameWithoutClan);
@@ -87,7 +83,7 @@ export function censorNameWithClanTag(username: string): string {
if (usernameIsProfane) {
return `[${clanTag}] ${censoredNameWithoutClan}`;
}
- return sanitizedUsername;
+ return username;
}
// Don't restore profane or nonexistent clan tag
@@ -98,43 +94,39 @@ export function validateUsername(username: string): {
isValid: boolean;
error?: string;
} {
- if (typeof username !== "string") {
- return { isValid: false, error: translateText("username.not_string") };
- }
+ const parsed = UsernameSchema.safeParse(username);
- if (username.length < MIN_USERNAME_LENGTH) {
- return {
- isValid: false,
- error: translateText("username.too_short", {
- min: MIN_USERNAME_LENGTH,
- }),
- };
- }
+ if (!parsed.success) {
+ const errType = parsed.error.issues[0].code;
- if (username.length > MAX_USERNAME_LENGTH) {
- return {
- isValid: false,
- error: translateText("username.too_long", {
- max: MAX_USERNAME_LENGTH,
- }),
- };
- }
+ if (errType === "invalid_type") {
+ return { isValid: false, error: translateText("username.not_string") };
+ }
- if (!validPattern.test(username)) {
- return {
- isValid: false,
- error: translateText("username.invalid_chars"),
- };
+ if (errType === "too_small") {
+ return {
+ isValid: false,
+ error: translateText("username.too_short", {
+ min: MIN_USERNAME_LENGTH,
+ }),
+ };
+ }
+
+ if (errType === "too_big") {
+ return {
+ isValid: false,
+ error: translateText("username.too_long", {
+ max: MAX_USERNAME_LENGTH,
+ }),
+ };
+ }
+
+ // Invalid regex, or any other issue
+ else {
+ return { isValid: false, error: translateText("username.invalid_chars") };
+ }
}
// All checks passed
return { isValid: true };
}
-
-export function sanitizeUsername(str: string): string {
- const sanitized = Array.from(str)
- .filter((ch) => validPattern.test(ch))
- .join("")
- .slice(0, MAX_USERNAME_LENGTH);
- return sanitized.padEnd(MIN_USERNAME_LENGTH, "x");
-}
diff --git a/tests/Censor.test.ts b/tests/Censor.test.ts
index a937d4e71..42b168cb5 100644
--- a/tests/Censor.test.ts
+++ b/tests/Censor.test.ts
@@ -35,8 +35,6 @@ import {
fixProfaneUsername,
isProfaneUsername,
MAX_USERNAME_LENGTH,
- MIN_USERNAME_LENGTH,
- sanitizeUsername,
validateUsername,
} from "../src/core/validations/username";
@@ -110,25 +108,4 @@ describe("username.ts functions", () => {
expect(res.isValid).toBe(true);
});
});
-
- describe("sanitizeUsername", () => {
- test.each([
- { input: "GoodName", expected: "GoodName" },
- { input: "a!", expected: "axx" },
- { input: "a$%b", expected: "abx" },
- {
- input: "abc".repeat(10),
- expected: "abc"
- .repeat(Math.floor(MAX_USERNAME_LENGTH / 3))
- .slice(0, MAX_USERNAME_LENGTH),
- },
- { input: "", expected: "xxx" },
- { input: "Ünicode Test!", expected: "Ünicode Test" },
- ])('sanitizeUsername("%s") → "%s"', ({ input, expected }) => {
- const out = sanitizeUsername(input);
- expect(out).toBe(expected);
- expect(out.length).toBeGreaterThanOrEqual(MIN_USERNAME_LENGTH);
- expect(out.length).toBeLessThanOrEqual(MAX_USERNAME_LENGTH);
- });
- });
});