Stricter validation for custom CSS vars
Some checks are pending
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2025-02-05 13:25:13 +02:00
parent 22e954cb9e
commit aaf865e8d0
8 changed files with 202 additions and 25 deletions

View File

@ -1,21 +1,11 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { CUSTOM_CSS_VAR_COLORS } from "~/constants";
import { Button } from "./Button";
import { Label } from "./Label";
const CUSTOM_COLORS = [
"bg",
"bg-darker",
"bg-lighter",
"text",
"text-lighter",
"theme",
"theme-secondary",
"chat",
] as const;
type CustomColorsRecord = Partial<
Record<(typeof CUSTOM_COLORS)[number], string>
Record<(typeof CUSTOM_CSS_VAR_COLORS)[number], string>
>;
export function CustomizedColorsInput({
@ -33,7 +23,7 @@ export function CustomizedColorsInput({
<Label>{t("custom.colors.title")}</Label>
<input type="hidden" name="css" value={JSON.stringify(colors)} />
<div className="colors__grid">
{CUSTOM_COLORS.map((cssVar) => {
{CUSTOM_CSS_VAR_COLORS.map((cssVar) => {
return (
<React.Fragment key={cssVar}>
<div>{t(`custom.colors.${cssVar}`)}</div>

View File

@ -134,3 +134,14 @@ export const PATCHES = [
// date: "2023-08-30",
// },
];
export const CUSTOM_CSS_VAR_COLORS = [
"bg",
"bg-darker",
"bg-lighter",
"text",
"text-lighter",
"theme",
"theme-secondary",
"chat",
] as const;

View File

@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas.server";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
});
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamProfileAction,
});
const DEFAULT_FIELDS = {
_action: "EDIT",
name: "Team 1",
bio: "",
bsky: "",
} as const;
describe("team page editing", () => {
beforeEach(async () => {
await dbInsertUsers();
await createTeamAction({ name: "Team 1" }, { user: "regular" });
});
afterEach(() => {
dbReset();
});
it("adds valid custom css vars", async () => {
const response = await editTeamProfileAction(
{
css: JSON.stringify({ bg: "#fff" }),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(response.status).toBe(302);
});
it("prevents adding custom css var of unknown property", async () => {
await expect(
editTeamProfileAction(
{
css: JSON.stringify({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),
).rejects.toThrow("status code: 400");
});
it("prevents adding custom css var of unknown value", async () => {
await expect(
editTeamProfileAction(
{
css: JSON.stringify({
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),
).rejects.toThrow("status code: 400");
});
});

View File

@ -12,6 +12,10 @@ const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
});
const DEFAULT_FIELDS = {
bio: null,
} as any;
describe("team creation", () => {
beforeEach(async () => {
await dbInsertUsers();
@ -28,9 +32,7 @@ describe("team creation", () => {
{
_action: "EDIT",
name: "Team 2",
bio: null,
bsky: null,
css: null,
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
@ -46,9 +48,7 @@ describe("team creation", () => {
{
_action: "EDIT",
name: "𝓢𝓲𝓵",
bio: null,
bsky: null,
css: null,
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),

View File

@ -1,5 +1,5 @@
import { z } from "zod";
import { _action, falsyToNull, id, jsonParseable } from "~/utils/zod";
import { _action, customCssVarObject, falsyToNull, id } from "~/utils/zod";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
export const teamParamsSchema = z.object({ customUrl: z.string() });
@ -32,7 +32,7 @@ export const editTeamSchema = z.union([
falsyToNull,
z.string().max(TEAM.BSKY_MAX_LENGTH).nullable(),
),
css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()),
css: customCssVarObject,
}),
]);

View File

@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import {
action as editUserProfileAction,
type userEditActionSchema,
} from "./u.$identifier.edit";
const action = wrappedAction<typeof userEditActionSchema>({
action: editUserProfileAction,
});
const DEFAULT_FIELDS = {
battlefy: null,
bio: null,
commissionsOpen: 1,
commissionText: null,
country: "FI",
customName: null,
customUrl: null,
favoriteBadgeId: null,
inGameNameDiscriminator: null,
inGameNameText: null,
motionSens: null,
showDiscordUniqueName: 1,
stickSens: null,
weapons: JSON.stringify([
{ weaponSplId: 1 as MainWeaponId, isFavorite: 0 },
]) as any,
};
describe("user page editing", () => {
beforeEach(async () => {
await dbInsertUsers();
});
afterEach(() => {
dbReset();
});
it("adds valid custom css vars", async () => {
const response = await action(
{
css: JSON.stringify({ bg: "#fff" }),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { identifier: "2" } },
);
expect(response.status).toBe(302);
});
it("prevents adding custom css var of unknown property", async () => {
const res = await action(
{
css: JSON.stringify({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { identifier: "2" } },
);
expect(res.errors[0]).toBe("Invalid custom CSS var object");
});
it("prevents adding custom css var of unknown value", async () => {
const res = await action(
{
css: JSON.stringify({
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { identifier: "2" } },
);
expect(res.errors[0]).toBe("Invalid custom CSS var object");
});
});

View File

@ -42,10 +42,10 @@ import { FAQ_PAGE, isCustomUrl, userPage } from "~/utils/urls";
import {
actualNumber,
checkboxValueToDbBoolean,
customCssVarObject,
dbBoolean,
falsyToNull,
id,
jsonParseable,
processMany,
safeJSONParse,
undefinedToNull,
@ -56,7 +56,7 @@ import type { UserPageLoaderData } from "./u.$identifier";
import "~/styles/u-edit.css";
import { SendouSwitch } from "~/components/elements/Switch";
const userEditActionSchema = z
export const userEditActionSchema = z
.object({
country: z.preprocess(
falsyToNull,
@ -127,7 +127,7 @@ const userEditActionSchema = z
.refine((val) => /^[0-9a-z]{4,5}$/.test(val))
.nullable(),
),
css: z.preprocess(falsyToNull, z.string().refine(jsonParseable).nullable()),
css: customCssVarObject,
weapons: z.preprocess(
safeJSONParse,
z

View File

@ -1,5 +1,6 @@
import type { ZodType } from "zod";
import { z } from "zod";
import { CUSTOM_CSS_VAR_COLORS } from "~/constants";
import type { abilitiesShort } from "~/modules/in-game-lists";
import { abilities, mainWeaponIds, stageIds } from "~/modules/in-game-lists";
import { FRIEND_CODE_REGEXP } from "../features/sendouq/q-constants";
@ -15,7 +16,8 @@ export const nonEmptyString = z.string().trim().min(1, {
export const dbBoolean = z.coerce.number().min(0).max(1).int();
export const hexCode = z.string().regex(/^#[0-9a-fA-F]{6}$/);
const hexCodeRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/; // https://stackoverflow.com/a/1636354
export const hexCode = z.string().regex(hexCodeRegex);
const abilityNameToType = (val: string) =>
abilities.find((ability) => ability.name === val)?.type;
@ -262,3 +264,27 @@ export const dayMonthYear = z.object({
});
export type DayMonthYear = z.infer<typeof dayMonthYear>;
export const customCssVarObject = z.preprocess(
falsyToNull,
z.string().nullable().refine(validSerializedCustomCssVarObject, {
message: "Invalid custom CSS var object",
}),
);
function validSerializedCustomCssVarObject(value: unknown) {
if (!value) return true;
try {
const parsedValue = JSON.parse(value as string);
for (const [key, value] of Object.entries(parsedValue)) {
if (!CUSTOM_CSS_VAR_COLORS.includes(key as any)) return false;
if (!hexCodeRegex.test(value as string)) return false;
}
return true;
} catch {
return false;
}
}