Safe string schema with check against zalgo text

This commit is contained in:
Kalle 2025-04-05 15:39:44 +03:00
parent 3ac6712091
commit e1331ef4bb
5 changed files with 75 additions and 17 deletions

View File

@ -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([

View File

@ -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({

View File

@ -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(),

View File

@ -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);
});
});

View File

@ -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();