From 1cada77ea85e49dcdae43e230a2d5404520ef3a0 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Mon, 26 May 2025 16:06:17 +0200 Subject: [PATCH] fix: sanitize result filters before storing in LS (@fehmer) (#6583) --- frontend/__tests__/utils/misc.spec.ts | 60 ++++++++++++++++++- .../src/ts/elements/account/result-filters.ts | 8 ++- frontend/src/ts/utils/config.ts | 39 +----------- frontend/src/ts/utils/misc.ts | 51 ++++++++++++++++ 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/frontend/__tests__/utils/misc.spec.ts b/frontend/__tests__/utils/misc.spec.ts index 43e99ea2a..5a0fd732d 100644 --- a/frontend/__tests__/utils/misc.spec.ts +++ b/frontend/__tests__/utils/misc.spec.ts @@ -1,4 +1,10 @@ -import { deepClone, getErrorMessage, isObject } from "../../src/ts/utils/misc"; +import { z } from "zod"; +import { + deepClone, + getErrorMessage, + isObject, + sanitize, +} from "../../src/ts/utils/misc"; import { getLanguageDisplayString, removeLanguageSize, @@ -224,4 +230,56 @@ describe("misc.ts", () => { }); }); }); + describe("sanitize function", () => { + const schema = z.object({ + name: z.string(), + age: z.number().positive(), + tags: z.array(z.string()), + }); + + it("should return the same object if it is valid", () => { + const obj = { name: "Alice", age: 30, tags: ["developer", "coder"] }; + expect(sanitize(schema, obj)).toEqual(obj); + }); + + it("should remove properties with invalid values", () => { + const obj = { name: "Alice", age: -5, tags: ["developer", "coder"] }; + expect(sanitize(schema, obj)).toEqual({ + name: "Alice", + tags: ["developer", "coder"], + age: undefined, + }); + }); + + it("should remove invalid array elements", () => { + const obj = { + name: "Alice", + age: 30, + tags: ["developer", 123, "coder"] as any, + }; + expect(sanitize(schema, obj)).toEqual({ + name: "Alice", + age: 30, + tags: ["developer", "coder"], + }); + }); + + it("should remove entire property if all array elements are invalid", () => { + const obj = { name: "Alice", age: 30, tags: [123, 456] as any }; + expect(sanitize(schema, obj)).toEqual({ + name: "Alice", + age: 30, + tags: undefined, + }); + }); + + it("should remove object properties if they are invalid", () => { + const obj = { name: 123 as any, age: 30, tags: ["developer", "coder"] }; + expect(sanitize(schema, obj)).toEqual({ + age: 30, + tags: ["developer", "coder"], + name: undefined, + }); + }); + }); }); diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index 3524e3fb4..0645274b4 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -55,7 +55,9 @@ const resultFiltersLS = new LocalStorageWithSchema({ if (!Misc.isObject(unknown)) { return defaultResultFilters; } - return mergeWithDefaultFilters(unknown as ResultFilters); + return mergeWithDefaultFilters( + Misc.sanitize(ResultFiltersSchema, unknown as ResultFilters) + ); }, }); @@ -89,6 +91,7 @@ function save(): void { export async function load(): Promise { try { filters = resultFiltersLS.get(); + console.log("###", { filters }); const newTags: Record = { none: false }; Object.keys(defaultResultFilters.tags).forEach((tag) => { @@ -889,7 +892,8 @@ $(".group.presetFilterButtons .filterBtns").on( ); function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters { - const filter = Misc.deepClone(filterIn); + const filter = Misc.sanitize(ResultFiltersSchema, Misc.deepClone(filterIn)); + Object.entries(defaultResultFilters).forEach((entry) => { const key = entry[0] as ResultFiltersGroup; const value = entry[1]; diff --git a/frontend/src/ts/utils/config.ts b/frontend/src/ts/utils/config.ts index 11be340ed..da8e7a188 100644 --- a/frontend/src/ts/utils/config.ts +++ b/frontend/src/ts/utils/config.ts @@ -4,7 +4,7 @@ import { PartialConfig, FunboxName, } from "@monkeytype/contracts/schemas/configs"; -import { typedKeys } from "./misc"; +import { sanitize, typedKeys } from "./misc"; import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs"; import { getDefaultConfig } from "../constants/default-config"; /** @@ -33,42 +33,7 @@ function mergeWithDefaultConfig(config: PartialConfig): Config { function sanitizeConfig( config: ConfigSchemas.PartialConfig ): ConfigSchemas.PartialConfig { - const validate = ConfigSchemas.PartialConfigSchema.safeParse(config); - - if (validate.success) { - return config; - } - - const errors: Map = new Map(); - for (const error of validate.error.errors) { - const element = error.path[0] as string; - let val = errors.get(element); - if (typeof error.path[1] === "number") { - val = [...(val ?? []), error.path[1]]; - } - errors.set(element, val); - } - - return Object.fromEntries( - Object.entries(config).map(([key, value]) => { - if (!errors.has(key)) { - return [key, value]; - } - - const error = errors.get(key); - - if ( - Array.isArray(value) && - error !== undefined && //error is not on the array itself - error.length < value.length //not all items in the array are invalid - ) { - //some items of the array are invalid - return [key, value.filter((_element, index) => !error.includes(index))]; - } else { - return [key, undefined]; - } - }) - ) as ConfigSchemas.PartialConfig; + return sanitize(ConfigSchemas.PartialConfigSchema, config); } export function replaceLegacyValues( diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 1b180521d..3eaebfb85 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -11,6 +11,7 @@ import { CustomTextDataWithTextLen, Result, } from "@monkeytype/contracts/schemas/results"; +import { z } from "zod"; export function whorf(speed: number, wordlen: number): number { return Math.min( @@ -713,4 +714,54 @@ export function promiseWithResolvers(): { return { resolve, reject, promise }; } +/** + * Sanitize object. Remove invalid values based on the schema. + * @param schema zod schema + * @param obj object + * @returns sanitized object + */ +export function sanitize( + schema: T, + obj: z.infer +): z.infer { + const validate = schema.safeParse(obj); + + if (validate.success) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return obj; + } + + const errors: Map = new Map(); + for (const error of validate.error.errors) { + const element = error.path[0] as string; + let val = errors.get(element); + if (typeof error.path[1] === "number") { + val = [...(val ?? []), error.path[1]]; + } + errors.set(element, val); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + if (!errors.has(key)) { + return [key, value]; + } + + const error = errors.get(key); + + if ( + Array.isArray(value) && + error !== undefined && //error is not on the array itself + error.length < value.length //not all items in the array are invalid + ) { + //some items of the array are invalid + return [key, value.filter((_element, index) => !error.includes(index))]; + } else { + return [key, undefined]; + } + }) + ) as z.infer; +} + // DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES