diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index adfc92a43..55f0b7508 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -1,20 +1,20 @@ import { z } from "zod"; import { _action, - actuallyNonEmptyStringOrNull, customCssVarObject, falsyToNull, id, + safeStringSchema, } from "~/utils/zod"; import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants"; export const teamParamsSchema = z.object({ customUrl: z.string() }); export const createTeamSchema = z.object({ - name: z.preprocess( - actuallyNonEmptyStringOrNull, - z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH), - ), + name: safeStringSchema({ + min: TEAM.NAME_MIN_LENGTH, + max: TEAM.NAME_MAX_LENGTH, + }), }); export const teamProfilePageActionSchema = z.union([ diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index a10fdbaa8..9790b9fe5 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -1,21 +1,20 @@ import { z } from "zod"; import { _action, - actuallyNonEmptyStringOrNull, checkboxValueToBoolean, id, modeShort, optionalId, safeJSONParse, + safeStringSchema, stageId, } from "~/utils/zod"; import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; import { TOURNAMENT } from "./tournament-constants"; -export const teamName = z.preprocess( - actuallyNonEmptyStringOrNull, - z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH), -); +export const teamName = safeStringSchema({ + max: TOURNAMENT.TEAM_NAME_MAX_LENGTH, +}); export const registerSchema = z.union([ z.object({ diff --git a/app/features/user-page/user-page-schemas.server.ts b/app/features/user-page/user-page-schemas.server.ts index 31f59aed4..8bc258944 100644 --- a/app/features/user-page/user-page-schemas.server.ts +++ b/app/features/user-page/user-page-schemas.server.ts @@ -5,7 +5,6 @@ import "~/styles/u-edit.css"; import { isCustomUrl } from "~/utils/urls"; import { actualNumber, - actuallyNonEmptyStringOrNull, checkboxValueToDbBoolean, customCssVarObject, dbBoolean, @@ -13,6 +12,7 @@ import { id, processMany, safeJSONParse, + safeNullableStringSchema, undefinedToNull, weaponSplId, } from "~/utils/zod"; @@ -62,10 +62,7 @@ export const userEditActionSchema = z .transform((val) => val?.toLowerCase()) .nullable(), ), - customName: z.preprocess( - actuallyNonEmptyStringOrNull, - z.string().max(USER.CUSTOM_NAME_MAX_LENGTH).nullable(), - ), + customName: safeNullableStringSchema({ max: USER.CUSTOM_NAME_MAX_LENGTH }), battlefy: z.preprocess( falsyToNull, z.string().max(USER.BATTLEFY_MAX_LENGTH).nullable(), diff --git a/app/utils/zod.test.ts b/app/utils/zod.test.ts index 0cd875a99..8bc407578 100644 --- a/app/utils/zod.test.ts +++ b/app/utils/zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { normalizeFriendCode } from "./zod"; +import { hasZalgo, normalizeFriendCode } from "./zod"; describe("normalizeFriendCode", () => { it("returns well formatted friend code as is", () => { @@ -18,3 +18,25 @@ describe("normalizeFriendCode", () => { expect(normalizeFriendCode("SW-1234-56789012")).toBe("1234-5678-9012"); }); }); + +describe("hasZalgo", () => { + it("returns true for text containing Zalgo characters", () => { + expect(hasZalgo("z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ")).toBe(true); + }); + + it("returns false for text without Zalgo characters", () => { + expect(hasZalgo("normal text")).toBe(false); + }); + + it("returns false for an empty string", () => { + expect(hasZalgo("")).toBe(false); + }); + + it("returns false for text with special but non-Zalgo characters", () => { + expect(hasZalgo("!@#$%^&*()")).toBe(false); + }); + + it("accepts japanese characters", () => { + expect(hasZalgo("こんにちは")).toBe(false); + }); +}); diff --git a/app/utils/zod.ts b/app/utils/zod.ts index 970651f0e..458a30246 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -123,10 +123,50 @@ export function safeJSONParse(value: unknown): unknown { const EMPTY_CHARACTERS = ["\u200B", "\u200C", "\u200D", "\u200E", "\u200F"]; const EMPTY_CHARACTERS_REGEX = new RegExp(EMPTY_CHARACTERS.join("|"), "g"); +const zalgoRe = /%CC%/g; +export const hasZalgo = (txt: string) => zalgoRe.test(encodeURIComponent(txt)); + +/** Non-empty string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ +export const safeStringSchema = ({ min, max }: { min?: number; max: number }) => + z.preprocess( + actuallyNonEmptyStringOrNull, // if this returns null, none of the checks below will run because it's not a string + z + .string() + .min(min ?? 0) + .max(max) + .refine((text) => !hasZalgo(text), { + message: "Includes not allowed characters.", + }), + ); + +/** Nullable string that has the given length (max and optionally min). Prevents z͎͗ͣḁ̵̑l̉̃ͦg̐̓̒o͓̔ͥ text as well as filters out characters that have no width. */ +export const safeNullableStringSchema = ({ + min, + max, +}: { min?: number; max: number }) => + z.preprocess( + actuallyNonEmptyStringOrNull, + z + .string() + .min(min ?? 0) + .max(max) + .nullable() + .refine( + (text) => { + if (typeof text !== "string") return true; + + return !hasZalgo(text); + }, + { + message: "Includes not allowed characters.", + }, + ), + ); + /** * Processes the input value and returns a non-empty string with invisible characters cleaned out or null. */ -export function actuallyNonEmptyStringOrNull(value: unknown) { +function actuallyNonEmptyStringOrNull(value: unknown) { if (typeof value !== "string") return value; const trimmed = value.replace(EMPTY_CHARACTERS_REGEX, "").trim();