From 8f42277e2b1f355d2f6ef5ef298a060096ecf1ef Mon Sep 17 00:00:00 2001 From: Wei Li Date: Mon, 14 Sep 2026 18:54:08 +0800 Subject: [PATCH 1/9] [MISC][WL] add max length and size checks --- .../custom-rules/custom-rules.spec.ts | 21 +++++++++++++ .../schema-generator/yup-helper.spec.ts | 21 ++++++++++--- src/__tests__/utils/file-helper.spec.ts | 31 +++++++++++++++++++ src/__tests__/utils/image-helper.spec.ts | 13 ++++++++ src/custom-rules/values.ts | 6 +++- src/fields/file-upload.ts | 2 +- src/fields/image-upload.ts | 17 +++++++--- src/schema-generator/yup-helper.ts | 14 +++++++-- src/shared/constants.ts | 5 +++ src/shared/index.ts | 1 + src/utils/file-helper.ts | 16 ++++++++-- src/utils/image-helper.ts | 13 ++++++-- 12 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 src/shared/constants.ts diff --git a/src/__tests__/custom-rules/custom-rules.spec.ts b/src/__tests__/custom-rules/custom-rules.spec.ts index 42643e5..ebefa61 100644 --- a/src/__tests__/custom-rules/custom-rules.spec.ts +++ b/src/__tests__/custom-rules/custom-rules.spec.ts @@ -1,5 +1,6 @@ import { LocalDate } from "@js-joda/core"; import { IWhitespaceRule, jsonToSchema } from "../../schema-generator"; +import { MAX_MATCHES_INPUT_LENGTH } from "../../shared"; import { TestHelper } from "../../utils"; const ERROR_MESSAGE = "test error message"; @@ -226,4 +227,24 @@ describe("custom-rules", () => { expect(() => schema.validateSync({ field: invalidValues })).toThrowError() ); }); + + describe("notMatches", () => { + const buildSchema = (notMatches: string) => + jsonToSchema({ + section: { + uiType: "section", + children: { + field: { uiType: "text-field", validation: [{ notMatches, errorMessage: ERROR_MESSAGE }] }, + }, + }, + }); + + it("should reject values longer than the max supported length", () => { + const schema = buildSchema("/^hello/"); + const oversizedValue = "a".repeat(MAX_MATCHES_INPUT_LENGTH + 1); + expect(TestHelper.getError(() => schema.validateSync({ field: oversizedValue })).message).toBe( + ERROR_MESSAGE + ); + }); + }); }); diff --git a/src/__tests__/schema-generator/yup-helper.spec.ts b/src/__tests__/schema-generator/yup-helper.spec.ts index b43685d..2b8621d 100644 --- a/src/__tests__/schema-generator/yup-helper.spec.ts +++ b/src/__tests__/schema-generator/yup-helper.spec.ts @@ -2,6 +2,7 @@ import isEqual from "lodash/isEqual"; import * as Yup from "yup"; import { TYupSchemaType, addRule } from "../../schema-generator"; import { YupHelper } from "../../schema-generator/yup-helper"; +import { MAX_MATCHES_INPUT_LENGTH } from "../../shared"; import { TestHelper } from "../../utils"; import { ERROR_MESSAGE, ERROR_MESSAGE_2 } from "../common"; @@ -59,11 +60,21 @@ describe("YupHelper", () => { ); }); - it("should ignore matches validation for empty string (excludeEmptyString)", () => { - const schema = YupHelper.mapRules(YupHelper.mapSchemaType("string"), [ - { matches: "/^hello/", errorMessage: ERROR_MESSAGE }, - ]); - expect(() => schema.validateSync("")).not.toThrowError(); + describe("matches", () => { + it("should ignore empty string (excludeEmptyString)", () => { + const schema = YupHelper.mapRules(YupHelper.mapSchemaType("string"), [ + { matches: "/^hello/", errorMessage: ERROR_MESSAGE }, + ]); + expect(() => schema.validateSync("")).not.toThrowError(); + }); + + it("should reject values longer than the max supported length", () => { + const schema = YupHelper.mapRules(YupHelper.mapSchemaType("string"), [ + { matches: "/^hello/", errorMessage: ERROR_MESSAGE }, + ]); + const oversizedValue = `hello${"a".repeat(MAX_MATCHES_INPUT_LENGTH)}`; + expect(TestHelper.getError(() => schema.validateSync(oversizedValue)).message).toBe(ERROR_MESSAGE); + }); }); const generateConditionalSchema = (type: TYupSchemaType, is: any) => diff --git a/src/__tests__/utils/file-helper.spec.ts b/src/__tests__/utils/file-helper.spec.ts index c0a1002..cad3671 100644 --- a/src/__tests__/utils/file-helper.spec.ts +++ b/src/__tests__/utils/file-helper.spec.ts @@ -1,5 +1,8 @@ import { FileHelper } from "../../utils"; +// minimal JPEG header magic-bytes.js needs to identify the file type +const JPG_HEADER_BASE64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]).toString("base64"); + describe("file-helper", () => { describe("extensionsToSentence", () => { describe("setBothJpegAndJpgIfEitherExists", () => { @@ -73,4 +76,32 @@ describe("file-helper", () => { }); }); }); + + describe("getTypeFromBase64", () => { + it("should derive file type from the buffer's magic bytes", async () => { + const result = await FileHelper.getTypeFromBase64(JPG_HEADER_BASE64); + expect(result.ext).toBe("jpg"); + }); + + it("should return an unknown type instead of throwing for malformed base64", async () => { + const result = await FileHelper.getTypeFromBase64("not-valid-base64!!!"); + expect(result).toEqual({ mime: undefined, ext: undefined }); + }); + + it("should return an unknown type instead of throwing for an empty base64 string", async () => { + const result = await FileHelper.getTypeFromBase64(""); + expect(result).toEqual({ mime: undefined, ext: undefined }); + }); + + it("should return an unknown type when base64 length exceeds the provided maxSizeInKb cap", async () => { + // cap of ~0 bytes rejects any non-empty payload + const result = await FileHelper.getTypeFromBase64(JPG_HEADER_BASE64, 0.0001); + expect(result).toEqual({ mime: undefined, ext: undefined }); + }); + + it("should still derive file type when within the provided maxSizeInKb cap", async () => { + const result = await FileHelper.getTypeFromBase64(JPG_HEADER_BASE64, 1); + expect(result.ext).toBe("jpg"); + }); + }); }); diff --git a/src/__tests__/utils/image-helper.spec.ts b/src/__tests__/utils/image-helper.spec.ts index d815fe7..e5a4bf0 100644 --- a/src/__tests__/utils/image-helper.spec.ts +++ b/src/__tests__/utils/image-helper.spec.ts @@ -120,5 +120,18 @@ describe("image-helper", () => { expect(ImageHelper.getDimensionsFromBase64(base64)).toEqual({ width: 32, height: 16 }); }); + + it("should return undefined when the payload exceeds the provided maxSizeInKb cap", () => { + const dataUrl = toBase64DataUrl("image/png", buildPngBuffer({ width: 100, height: 50 })); + + // cap of ~0 bytes rejects any non-empty payload + expect(ImageHelper.getDimensionsFromBase64(dataUrl, 0.0001)).toBeUndefined(); + }); + + it("should still parse dimensions when within the provided maxSizeInKb cap", () => { + const dataUrl = toBase64DataUrl("image/png", buildPngBuffer({ width: 100, height: 50 })); + + expect(ImageHelper.getDimensionsFromBase64(dataUrl, 1)).toEqual({ width: 100, height: 50 }); + }); }); }); diff --git a/src/custom-rules/values.ts b/src/custom-rules/values.ts index 8b61230..0714617 100644 --- a/src/custom-rules/values.ts +++ b/src/custom-rules/values.ts @@ -2,6 +2,7 @@ import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import isBoolean from "lodash/isBoolean"; import { IDaysRangeRule, IWhitespaceRule, addRule } from "../schema-generator"; +import { MAX_MATCHES_INPUT_LENGTH } from "../shared"; import { DateTimeHelper, ValueHelper } from "../utils"; export const filled = () => addRule("mixed", "filled", (value) => !ValueHelper.isEmpty(value)); @@ -15,7 +16,10 @@ export const notMatches = () => if (ValueHelper.isEmpty(value)) { return true; } - const matches = regex.match(/\/(.*)\/([a-z]+)?/); + // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) + if (value.length > MAX_MATCHES_INPUT_LENGTH) { + return false; + } const parsedRegex = new RegExp(matches[1], matches[2]); return !parsedRegex.test(value); }); diff --git a/src/fields/file-upload.ts b/src/fields/file-upload.ts index 0545514..d0dcc53 100644 --- a/src/fields/file-upload.ts +++ b/src/fields/file-upload.ts @@ -92,7 +92,7 @@ export const fileUpload: IFieldGenerator = (id, { uploadOnAdd let isValid = true; for (const file of value) { const base64 = file.dataURL.split(";base64,").pop(); - const fileType = await FileHelper.getTypeFromBase64(base64); + const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.maxSizeInKb); const validFileType = fileTypeRule.fileType?.length ? fileTypeRule.fileType?.includes(fileType.ext) : true; diff --git a/src/fields/image-upload.ts b/src/fields/image-upload.ts index fc410f3..9429bf0 100644 --- a/src/fields/image-upload.ts +++ b/src/fields/image-upload.ts @@ -1,7 +1,7 @@ import * as Yup from "yup"; import { IFieldSchemaBase, IValidationRule } from "../schema-generator"; import { IFieldGenerator } from "./types"; -import { ERROR_MESSAGES } from "../shared"; +import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; import { FileHelper, ImageHelper } from "../utils"; type TImageUploadAcceptedFileType = "jpg" | "gif" | "png" | "heic" | "heif" | "webp"; @@ -85,7 +85,7 @@ export const imageUpload: IFieldGenerator = ( let isValid = true; for (const file of value) { const base64 = file.dataURL.split(";base64,").pop(); - const fileType = await FileHelper.getTypeFromBase64(base64); + const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.["maxSizeInKb"]); const validFileType = fileType.ext === outputType; if (!validFileType) { isValid = false; @@ -109,7 +109,10 @@ export const imageUpload: IFieldGenerator = ( return true; return value.every((file) => { - const fileDimensions = ImageHelper.getDimensionsFromBase64(file.dataURL); + const fileDimensions = ImageHelper.getDimensionsFromBase64( + file.dataURL, + maxFileSizeRule?.["maxSizeInKb"] + ); return ( fileDimensions?.width <= dimensions.width && fileDimensions?.height <= dimensions.height ); @@ -126,7 +129,13 @@ export const imageUpload: IFieldGenerator = ( const pattern = parsed ? new RegExp(parsed[1], parsed[2] || "") : new RegExp(matchesRule.matches); - return value.every((file) => pattern.test(file.fileName)); + // cap tested filename length to bound worst-case regex backtracking cost (ReDoS mitigation) + return value.every( + (file) => + typeof file.fileName === "string" && + file.fileName.length <= MAX_MATCHES_INPUT_LENGTH && + pattern.test(file.fileName) + ); } catch { return true; } diff --git a/src/schema-generator/yup-helper.ts b/src/schema-generator/yup-helper.ts index 799d8a1..d5bc4a5 100644 --- a/src/schema-generator/yup-helper.ts +++ b/src/schema-generator/yup-helper.ts @@ -1,5 +1,5 @@ import * as Yup from "yup"; -import { ERROR_MESSAGES } from "../shared"; +import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; import { CONDITIONS, IConditionalValidationRule, @@ -87,9 +87,17 @@ export namespace YupHelper { { const matches = rule.matches.match(/\/(.*)\/([a-z]+)?/); try { - yupSchema = (yupSchema as Yup.StringSchema).matches(new RegExp(matches[1], matches[2]), { - excludeEmptyString: true, + const pattern = matches ? new RegExp(matches[1], matches[2]) : new RegExp(rule.matches); + yupSchema = (yupSchema as Yup.StringSchema).test({ + name: "matches", message: rule.errorMessage, + params: { regex: pattern }, + // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) + test: (value: string) => + value == null || + (value === "" + ? true + : value.length <= MAX_MATCHES_INPUT_LENGTH && pattern.test(value)), }); } catch (error) { console.error(`error applying "${condition}" condition to ${yupSchema.type} schema`); diff --git a/src/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..727ba43 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,5 @@ +// caps the string length tested against a config-supplied regex to bound worst-case backtracking cost (ReDoS mitigation) +export const MAX_MATCHES_INPUT_LENGTH = 1000; + +// default cap on base64 payload length when no maxSizeInKb validation rule is configured (~100MB decoded) +export const DEFAULT_MAX_BASE64_LENGTH = 100 * 1024 * 1024; diff --git a/src/shared/index.ts b/src/shared/index.ts index 2b3a22b..72f812c 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -1 +1,2 @@ +export * from "./constants"; export * from "./error-messages"; diff --git a/src/utils/file-helper.ts b/src/utils/file-helper.ts index 2efc464..01132d3 100644 --- a/src/utils/file-helper.ts +++ b/src/utils/file-helper.ts @@ -1,4 +1,5 @@ import getFileInfo from "magic-bytes.js"; +import { DEFAULT_MAX_BASE64_LENGTH } from "../shared/constants"; export namespace FileHelper { /** @@ -51,9 +52,20 @@ export namespace FileHelper { /** * reliably derive file type by checking magic number of the buffer + * @param maxSizeInKb optional cap on the decoded file size, derived from the field's maxSizeInKb validation rule; defaults to ~100MB */ - export const getTypeFromBase64 = async (base64: string) => { - const binaryString = atob(base64); + export const getTypeFromBase64 = async (base64: string, maxSizeInKb?: number) => { + // base64 encoding inflates size by ~4/3, so convert the decoded-byte cap to a base64 character cap + const maxBase64Length = maxSizeInKb > 0 ? Math.ceil(((maxSizeInKb * 1024) / 3) * 4) : DEFAULT_MAX_BASE64_LENGTH; + if (!base64 || base64.length > maxBase64Length) { + return { mime: undefined, ext: undefined }; + } + let binaryString: string; + try { + binaryString = atob(base64); + } catch (error) { + return { mime: undefined, ext: undefined }; + } const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { diff --git a/src/utils/image-helper.ts b/src/utils/image-helper.ts index 413c621..231c9ce 100644 --- a/src/utils/image-helper.ts +++ b/src/utils/image-helper.ts @@ -1,3 +1,5 @@ +import { DEFAULT_MAX_BASE64_LENGTH } from "../shared/constants"; + interface IImageDimensions { width: number; height: number; @@ -49,8 +51,15 @@ const getJpgDimensions = (buffer: Buffer): IImageDimensions | undefined => { }; export namespace ImageHelper { - export const getDimensionsFromBase64 = (base64: string): IImageDimensions | undefined => { - const buffer = Buffer.from(base64.split(";base64,").pop(), "base64"); + /** + * @param maxSizeInKb optional cap on the decoded file size, derived from the field's maxSizeInKb validation rule; defaults to ~100MB + */ + export const getDimensionsFromBase64 = (base64: string, maxSizeInKb?: number): IImageDimensions | undefined => { + // base64 encoding inflates size by ~4/3, so convert the decoded-byte cap to a base64 character cap + const maxBase64Length = maxSizeInKb > 0 ? Math.ceil(((maxSizeInKb * 1024) / 3) * 4) : DEFAULT_MAX_BASE64_LENGTH; + const payload = base64?.split(";base64,").pop(); + if (!payload || payload.length > maxBase64Length) return undefined; + const buffer = Buffer.from(payload, "base64"); if (isPng(buffer)) return getPngDimensions(buffer); if (isJpg(buffer)) return getJpgDimensions(buffer); return undefined; From 9e9f88c6bdd585fc8f2a6a5d2949bae2204f7737 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Mon, 14 Sep 2026 19:14:28 +0800 Subject: [PATCH 2/9] [MISC][WL] tighten type, existence and regex checks --- src/__tests__/custom-rules/custom-rules.spec.ts | 5 +++++ src/__tests__/schema-generator/yup-helper.spec.ts | 15 +++++++++++++++ src/custom-rules/values.ts | 7 ++++++- src/fields/file-upload.ts | 4 ++++ src/fields/image-upload.ts | 4 ++++ src/schema-generator/yup-helper.ts | 7 ++++++- 6 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/__tests__/custom-rules/custom-rules.spec.ts b/src/__tests__/custom-rules/custom-rules.spec.ts index ebefa61..f77bdd0 100644 --- a/src/__tests__/custom-rules/custom-rules.spec.ts +++ b/src/__tests__/custom-rules/custom-rules.spec.ts @@ -239,6 +239,11 @@ describe("custom-rules", () => { }, }); + it("should pass when the regex config is malformed", () => { + const schema = buildSchema("not-a-delimited-regex"); + expect(() => schema.validateSync({ field: "anything" })).not.toThrowError(); + }); + it("should reject values longer than the max supported length", () => { const schema = buildSchema("/^hello/"); const oversizedValue = "a".repeat(MAX_MATCHES_INPUT_LENGTH + 1); diff --git a/src/__tests__/schema-generator/yup-helper.spec.ts b/src/__tests__/schema-generator/yup-helper.spec.ts index 2b8621d..f07fb66 100644 --- a/src/__tests__/schema-generator/yup-helper.spec.ts +++ b/src/__tests__/schema-generator/yup-helper.spec.ts @@ -68,6 +68,14 @@ describe("YupHelper", () => { expect(() => schema.validateSync("")).not.toThrowError(); }); + it("should fall back to treating a non-delimited config as a bare pattern", () => { + const schema = YupHelper.mapRules(YupHelper.mapSchemaType("string"), [ + { matches: "^hello", errorMessage: ERROR_MESSAGE }, + ]); + expect(() => schema.validateSync("hello world")).not.toThrowError(); + expect(TestHelper.getError(() => schema.validateSync("hi there")).message).toBe(ERROR_MESSAGE); + }); + it("should reject values longer than the max supported length", () => { const schema = YupHelper.mapRules(YupHelper.mapSchemaType("string"), [ { matches: "/^hello/", errorMessage: ERROR_MESSAGE }, @@ -75,6 +83,13 @@ describe("YupHelper", () => { const oversizedValue = `hello${"a".repeat(MAX_MATCHES_INPUT_LENGTH)}`; expect(TestHelper.getError(() => schema.validateSync(oversizedValue)).message).toBe(ERROR_MESSAGE); }); + + it("should skip the condition when applied to a non-string schema", () => { + const schema = YupHelper.mapRules(YupHelper.mapSchemaType("array"), [ + { matches: "/^hello/", errorMessage: ERROR_MESSAGE }, + ]); + expect(() => schema.validateSync(["hello"])).not.toThrowError(); + }); }); const generateConditionalSchema = (type: TYupSchemaType, is: any) => diff --git a/src/custom-rules/values.ts b/src/custom-rules/values.ts index 0714617..3f27b5b 100644 --- a/src/custom-rules/values.ts +++ b/src/custom-rules/values.ts @@ -13,7 +13,12 @@ export const notEquals = () => addRule("mixed", "notEquals", (value, match) => !ValueHelper.isEmpty(value) && !isEqual(value, match)); export const notMatches = () => addRule("string", "notMatches", (value: string, regex: string) => { - if (ValueHelper.isEmpty(value)) { + if (ValueHelper.isEmpty(value) || typeof regex !== "string") { + return true; + } + const matches = regex.match(/^\/(.*)\/([a-z]+)?$/); + if (!matches) { + console.error(`invalid "notMatches" regex config: ${regex}`); return true; } // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) diff --git a/src/fields/file-upload.ts b/src/fields/file-upload.ts index d0dcc53..627597f 100644 --- a/src/fields/file-upload.ts +++ b/src/fields/file-upload.ts @@ -91,6 +91,10 @@ export const fileUpload: IFieldGenerator = (id, { uploadOnAdd return true; let isValid = true; for (const file of value) { + if (!file?.dataURL) { + isValid = false; + break; + } const base64 = file.dataURL.split(";base64,").pop(); const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.maxSizeInKb); const validFileType = fileTypeRule.fileType?.length diff --git a/src/fields/image-upload.ts b/src/fields/image-upload.ts index 9429bf0..9ad94f3 100644 --- a/src/fields/image-upload.ts +++ b/src/fields/image-upload.ts @@ -84,6 +84,10 @@ export const imageUpload: IFieldGenerator = ( if (!value || !Array.isArray(value)) return true; let isValid = true; for (const file of value) { + if (!file?.dataURL) { + isValid = false; + break; + } const base64 = file.dataURL.split(";base64,").pop(); const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.["maxSizeInKb"]); const validFileType = fileType.ext === outputType; diff --git a/src/schema-generator/yup-helper.ts b/src/schema-generator/yup-helper.ts index d5bc4a5..d78cd9c 100644 --- a/src/schema-generator/yup-helper.ts +++ b/src/schema-generator/yup-helper.ts @@ -85,7 +85,12 @@ export namespace YupHelper { break; case !!rule.matches: { - const matches = rule.matches.match(/\/(.*)\/([a-z]+)?/); + // against non-string schemas instead of relying on a thrown/caught type error + if (yupSchema.type !== "string") { + console.error(`error applying "${condition}" condition to ${yupSchema.type} schema`); + break; + } + const matches = rule.matches.match(/^\/(.*)\/([a-z]+)?$/); try { const pattern = matches ? new RegExp(matches[1], matches[2]) : new RegExp(rule.matches); yupSchema = (yupSchema as Yup.StringSchema).test({ From 342d727502bd6898086c9e07f7437a63eadd5eec Mon Sep 17 00:00:00 2001 From: Wei Li Date: Mon, 14 Sep 2026 19:19:25 +0800 Subject: [PATCH 3/9] [MISC][WL] guard notMatches rule against invalid regex errors --- src/custom-rules/values.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/custom-rules/values.ts b/src/custom-rules/values.ts index 3f27b5b..46f607e 100644 --- a/src/custom-rules/values.ts +++ b/src/custom-rules/values.ts @@ -25,8 +25,13 @@ export const notMatches = () => if (value.length > MAX_MATCHES_INPUT_LENGTH) { return false; } - const parsedRegex = new RegExp(matches[1], matches[2]); - return !parsedRegex.test(value); + try { + const parsedRegex = new RegExp(matches[1], matches[2]); + return !parsedRegex.test(value); + } catch (error) { + console.error(`error applying "notMatches" rule: ${error}`); + return true; + } }); /** @deprecated use `whitespace` */ export const noWhitespaceOnly = () => From 6828c361b5ec086ea201720c68df8a29acfa37a9 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Mon, 14 Sep 2026 19:19:34 +0800 Subject: [PATCH 4/9] [MISC][WL] optimize array-field unique validation to linear time --- src/fields/array-field.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/fields/array-field.ts b/src/fields/array-field.ts index 54949c5..fecc22c 100644 --- a/src/fields/array-field.ts +++ b/src/fields/array-field.ts @@ -53,17 +53,20 @@ export const arrayField = ( let hasError = false; uniqueRule.unique.forEach(({ field, errorMessage }) => { - const fieldValues = value.map((item) => item?.[field]); + // single pass dedup instead of nested findIndex to avoid O(n^2) over submitted array length + const seenAtIndex = new Map(); - fieldValues.forEach((val, idx) => { + value.forEach((item, idx) => { + const val = item?.[field]; if (!val) return; - const isDuplicate = fieldValues.findIndex((v) => v === val) !== idx; - if (isDuplicate) { + if (seenAtIndex.has(val)) { errors[idx] = { ...errors[idx], [field]: errorMessage || ERROR_MESSAGES.ARRAY_FIELD.UNIQUE, }; hasError = true; + } else { + seenAtIndex.set(val, idx); } }); }); From 7ac23be3d26e9dd0bf28d13a92ea07ad206f64fd Mon Sep 17 00:00:00 2001 From: Wei Li Date: Mon, 14 Sep 2026 19:34:01 +0800 Subject: [PATCH 5/9] [MISC][WL] limit schema config recursion depth --- .../schema-generator/json-to-schema.spec.ts | 50 ++++++++++++++++++- src/fields/generate-field-configs.ts | 22 ++++++-- src/schema-generator/json-to-schema.ts | 13 ++++- src/shared/constants.ts | 3 ++ 4 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/__tests__/schema-generator/json-to-schema.spec.ts b/src/__tests__/schema-generator/json-to-schema.spec.ts index 00676e9..da7f812 100644 --- a/src/__tests__/schema-generator/json-to-schema.spec.ts +++ b/src/__tests__/schema-generator/json-to-schema.spec.ts @@ -1,8 +1,8 @@ import { LocalDate } from "@js-joda/core"; import { ObjectSchema } from "yup"; import { ObjectShape } from "yup/lib/object"; -import { TSectionsSchema, jsonToSchema } from "../../schema-generator"; -import { ERROR_MESSAGES } from "../../shared"; +import { TComponentSchema, TSectionsSchema, jsonToSchema } from "../../schema-generator"; +import { ERROR_MESSAGES, MAX_SCHEMA_NESTING_DEPTH } from "../../shared"; import { TestHelper } from "../../utils"; import { ERROR_MESSAGE, ERROR_MESSAGE_2, ERROR_MESSAGE_3, ERROR_MESSAGE_4 } from "../common"; @@ -61,6 +61,52 @@ describe("json-to-schema", () => { expect(error.inner[2].message).toBe(ERROR_MESSAGE_4); }); + it("should not exceed the call stack when schema config is nested beyond the max depth", () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + + let node: TComponentSchema = { + uiType: "text-field", + validation: [{ required: true, errorMessage: ERROR_MESSAGE }], + }; + for (let i = 0; i < MAX_SCHEMA_NESTING_DEPTH + 10; i++) { + node = { uiType: "div", children: { child: node } }; + } + + let schema: ObjectSchema; + expect(() => { + schema = jsonToSchema({ section: { uiType: "section", children: { root: node } } }); + }).not.toThrow(); + + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("schema nesting depth exceeded")); + expect(schema.describe().fields).toEqual({}); + }); + + it("should not exceed the call stack when checkbox/radio options are nested beyond the max depth", () => { + jest.spyOn(console, "error").mockImplementation(() => undefined); + + const totalLevels = MAX_SCHEMA_NESTING_DEPTH + 10; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deeply recursive fixture, typing it as TComponentSchema blows up TS's type-checker + let children: any = { + leaf: { uiType: "text-field", validation: [{ required: true, errorMessage: ERROR_MESSAGE }] }, + }; + for (let i = 0; i < totalLevels; i++) { + children = { + [`level${i}`]: { uiType: "checkbox", options: [{ label: "opt", value: "opt", children }] }, + }; + } + + let schema: ObjectSchema; + expect(() => { + schema = jsonToSchema({ section: { uiType: "section", children } }); + }).not.toThrow(); + + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("schema nesting depth exceeded")); + const fields = schema.describe().fields; + // the outermost level (processed at depth 0) generates a field, the innermost leaf (beyond the cap) does not + expect(fields[`level${totalLevels - 1}`]).toBeDefined(); + expect(fields.leaf).toBeUndefined(); + }); + it("should throw error if there are unknown fields", () => { const schema = jsonToSchema({ section: { diff --git a/src/fields/generate-field-configs.ts b/src/fields/generate-field-configs.ts index bb4a1eb..bd44563 100644 --- a/src/fields/generate-field-configs.ts +++ b/src/fields/generate-field-configs.ts @@ -1,6 +1,7 @@ import isEmpty from "lodash/isEmpty"; import isObject from "lodash/isObject"; import type { TComponentSchema, TCustomFieldSchema, TFieldSchema, TSectionsSchema } from "../schema-generator/types"; +import { MAX_SCHEMA_NESTING_DEPTH } from "../shared"; import { arrayField } from "./array-field"; import { checkbox } from "./checkbox"; import { chips } from "./chips"; @@ -47,7 +48,8 @@ export const generateFieldConfigs = (sections: TSectionsSchema, generateSchema: const generateChildrenFieldConfigs = ( childrenSchema: Record, - generateSchema: TSchemaGenerator + generateSchema: TSchemaGenerator, + depth = 0 ) => { let config: TFieldsConfig = {}; @@ -55,6 +57,12 @@ const generateChildrenFieldConfigs = ( return config; } + // bail out of runaway/malicious nesting depth to prevent call stack exhaustion + if (depth > MAX_SCHEMA_NESTING_DEPTH) { + console.error(`schema nesting depth exceeded ${MAX_SCHEMA_NESTING_DEPTH}, skipping remaining children`); + return config; + } + Object.entries(childrenSchema).forEach(([id, componentSchema]) => { if ("referenceKey" in componentSchema) { const customComponentSchema = componentSchema as TCustomFieldSchema; @@ -75,7 +83,10 @@ const generateChildrenFieldConfigs = ( config = { ...config, ...checkbox(id, componentSchema) }; componentSchema.options.forEach((option) => { if (!isEmpty(option.children) && isObject(option.children)) { - config = { ...config, ...generateChildrenFieldConfigs(option.children, generateSchema) }; + config = { + ...config, + ...generateChildrenFieldConfigs(option.children, generateSchema, depth + 1), + }; } }); break; @@ -128,7 +139,10 @@ const generateChildrenFieldConfigs = ( config = { ...config, ...radio(id, componentSchema) }; componentSchema.options.forEach((option) => { if (!isEmpty(option.children) && isObject(option.children)) { - config = { ...config, ...generateChildrenFieldConfigs(option.children, generateSchema) }; + config = { + ...config, + ...generateChildrenFieldConfigs(option.children, generateSchema, depth + 1), + }; } }); break; @@ -170,7 +184,7 @@ const generateChildrenFieldConfigs = ( case "accordion": case "grid": if (!isEmpty(children) && isObject(children)) { - config = { ...config, ...generateChildrenFieldConfigs(children, generateSchema) }; + config = { ...config, ...generateChildrenFieldConfigs(children, generateSchema, depth + 1) }; } break; } diff --git a/src/schema-generator/json-to-schema.ts b/src/schema-generator/json-to-schema.ts index 313e8a6..cd3bbdc 100644 --- a/src/schema-generator/json-to-schema.ts +++ b/src/schema-generator/json-to-schema.ts @@ -6,6 +6,7 @@ import { ObjectShape } from "yup/lib/object"; import { generateFieldConfigs } from "../fields/generate-field-configs"; import type { IFieldConfig, TFieldsConfig } from "../fields/types"; import { ObjectHelper } from "../utils/object-helper"; +import { MAX_SCHEMA_NESTING_DEPTH } from "../shared"; import { parseConditionalRenders } from "./conditional-render"; import { ISectionSchema, @@ -47,10 +48,17 @@ export const jsonToSchema = ( export const overrideSchema = ( schema: TSectionsSchema | Record, - overrides: RecursivePartial> + overrides: RecursivePartial>, + depth = 0 ) => { if (isEmpty(overrides) || typeof schema === "string") return schema; + // bail out of runaway/malicious nesting depth to prevent call stack exhaustion + if (depth > MAX_SCHEMA_NESTING_DEPTH) { + console.error(`schema nesting depth exceeded ${MAX_SCHEMA_NESTING_DEPTH}, skipping remaining overrides`); + return schema; + } + const overriddenSchema = cloneDeep(schema); Object.keys(overriddenSchema).forEach((childId) => { const overrideEntry = ObjectHelper.getNestedValueByKey(overrides, childId, { @@ -61,7 +69,8 @@ export const overrideSchema = ( if (overriddenSchema[childId]?.children) { overriddenSchema[childId].children = overrideSchema( overriddenSchema[childId].children as Record, - overrides + overrides, + depth + 1 ); } }); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 727ba43..2ae0f14 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -3,3 +3,6 @@ export const MAX_MATCHES_INPUT_LENGTH = 1000; // default cap on base64 payload length when no maxSizeInKb validation rule is configured (~100MB decoded) export const DEFAULT_MAX_BASE64_LENGTH = 100 * 1024 * 1024; + +// caps recursion depth over nested schema config (children / option.children) to prevent stack exhaustion +export const MAX_SCHEMA_NESTING_DEPTH = 50; From bb5e70e57ed8d49dc1cfbfc2e7ddbb973c7f28eb Mon Sep 17 00:00:00 2001 From: Wei Li Date: Tue, 15 Sep 2026 20:53:37 +0800 Subject: [PATCH 6/9] [MISC][WL] use env vars for untrusted GitHub Actions context values in trigger-gitlab-pipeline --- .github/workflows/trigger-gitlab-pipeline.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/trigger-gitlab-pipeline.yml b/.github/workflows/trigger-gitlab-pipeline.yml index 569f55a..bb9910a 100644 --- a/.github/workflows/trigger-gitlab-pipeline.yml +++ b/.github/workflows/trigger-gitlab-pipeline.yml @@ -15,10 +15,15 @@ jobs: steps: - name: Print Configs + env: + HEAD_REF: ${{ github.head_ref }} + REF_NAME: ${{ github.ref_name }} + PR_TITLE: ${{ github.event.pull_request.title }} + HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }} run: | - [[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="${{ github.head_ref }}" || BRANCH_NAME="${{ github.ref_name }}" + [[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="$HEAD_REF" || BRANCH_NAME="$REF_NAME" - [[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="${{ github.event.pull_request.title }}" || COMMIT_MSG=$(echo -e "${{ github.event.head_commit.message }}" | head -n 1) + [[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="$PR_TITLE" || COMMIT_MSG=$(echo -e "$HEAD_COMMIT_MSG" | head -n 1) PIPELINE_PROJECT_URL="github.com/$GITHUB_REPOSITORY.git" From d2ccafde9023b9782ce89145d88e8ac64eeb6139 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Tue, 15 Sep 2026 22:05:22 +0800 Subject: [PATCH 7/9] [MISC][WL] extract matches/notMatches regex parsing into RegexHelper.compile --- src/custom-rules/values.ts | 17 ++++------------- src/fields/image-upload.ts | 26 ++++++++++---------------- src/schema-generator/yup-helper.ts | 30 +++++++++++++----------------- src/utils/index.ts | 1 + src/utils/regex-helper.ts | 16 ++++++++++++++++ 5 files changed, 44 insertions(+), 46 deletions(-) create mode 100644 src/utils/regex-helper.ts diff --git a/src/custom-rules/values.ts b/src/custom-rules/values.ts index 46f607e..393637d 100644 --- a/src/custom-rules/values.ts +++ b/src/custom-rules/values.ts @@ -3,7 +3,7 @@ import isEqual from "lodash/isEqual"; import isBoolean from "lodash/isBoolean"; import { IDaysRangeRule, IWhitespaceRule, addRule } from "../schema-generator"; import { MAX_MATCHES_INPUT_LENGTH } from "../shared"; -import { DateTimeHelper, ValueHelper } from "../utils"; +import { DateTimeHelper, RegexHelper, ValueHelper } from "../utils"; export const filled = () => addRule("mixed", "filled", (value) => !ValueHelper.isEmpty(value)); export const empty = () => addRule("mixed", "empty", (value) => ValueHelper.isEmpty(value)); @@ -16,22 +16,13 @@ export const notMatches = () => if (ValueHelper.isEmpty(value) || typeof regex !== "string") { return true; } - const matches = regex.match(/^\/(.*)\/([a-z]+)?$/); - if (!matches) { - console.error(`invalid "notMatches" regex config: ${regex}`); - return true; - } + const pattern = RegexHelper.compile(regex); + if (!pattern) return true; // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) if (value.length > MAX_MATCHES_INPUT_LENGTH) { return false; } - try { - const parsedRegex = new RegExp(matches[1], matches[2]); - return !parsedRegex.test(value); - } catch (error) { - console.error(`error applying "notMatches" rule: ${error}`); - return true; - } + return !pattern.test(value); }); /** @deprecated use `whitespace` */ export const noWhitespaceOnly = () => diff --git a/src/fields/image-upload.ts b/src/fields/image-upload.ts index 9ad94f3..f9f83de 100644 --- a/src/fields/image-upload.ts +++ b/src/fields/image-upload.ts @@ -2,7 +2,7 @@ import * as Yup from "yup"; import { IFieldSchemaBase, IValidationRule } from "../schema-generator"; import { IFieldGenerator } from "./types"; import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; -import { FileHelper, ImageHelper } from "../utils"; +import { FileHelper, ImageHelper, RegexHelper } from "../utils"; type TImageUploadAcceptedFileType = "jpg" | "gif" | "png" | "heic" | "heif" | "webp"; type TImageUploadOutputFileType = "jpg" | "png"; @@ -128,21 +128,15 @@ export const imageUpload: IFieldGenerator = ( matchesRule?.errorMessage || ERROR_MESSAGES.UPLOAD("photo").INVALID_FILE_NAME, (value) => { if (!value || !Array.isArray(value) || !matchesRule?.matches) return true; - try { - const parsed = matchesRule.matches.match(/^\/(.+)\/([gimsuy]*)$/); - const pattern = parsed - ? new RegExp(parsed[1], parsed[2] || "") - : new RegExp(matchesRule.matches); - // cap tested filename length to bound worst-case regex backtracking cost (ReDoS mitigation) - return value.every( - (file) => - typeof file.fileName === "string" && - file.fileName.length <= MAX_MATCHES_INPUT_LENGTH && - pattern.test(file.fileName) - ); - } catch { - return true; - } + const pattern = RegexHelper.compile(matchesRule.matches); + if (!pattern) return true; + // cap tested filename length to bound worst-case regex backtracking cost (ReDoS mitigation) + return value.every( + (file) => + typeof file.fileName === "string" && + file.fileName.length <= MAX_MATCHES_INPUT_LENGTH && + pattern.test(file.fileName) + ); } ), validation, diff --git a/src/schema-generator/yup-helper.ts b/src/schema-generator/yup-helper.ts index d78cd9c..40b336e 100644 --- a/src/schema-generator/yup-helper.ts +++ b/src/schema-generator/yup-helper.ts @@ -1,5 +1,6 @@ import * as Yup from "yup"; import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; +import { RegexHelper } from "../utils"; import { CONDITIONS, IConditionalValidationRule, @@ -90,23 +91,18 @@ export namespace YupHelper { console.error(`error applying "${condition}" condition to ${yupSchema.type} schema`); break; } - const matches = rule.matches.match(/^\/(.*)\/([a-z]+)?$/); - try { - const pattern = matches ? new RegExp(matches[1], matches[2]) : new RegExp(rule.matches); - yupSchema = (yupSchema as Yup.StringSchema).test({ - name: "matches", - message: rule.errorMessage, - params: { regex: pattern }, - // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) - test: (value: string) => - value == null || - (value === "" - ? true - : value.length <= MAX_MATCHES_INPUT_LENGTH && pattern.test(value)), - }); - } catch (error) { - console.error(`error applying "${condition}" condition to ${yupSchema.type} schema`); - } + const pattern = RegexHelper.compile(rule.matches); + if (!pattern) break; + yupSchema = (yupSchema as Yup.StringSchema).test({ + name: "matches", + message: rule.errorMessage, + params: { regex: pattern }, + // cap tested value length to bound worst-case regex backtracking cost (ReDoS mitigation) + test: (value: unknown) => { + if (value == null || typeof value !== "string" || value === "") return true; + return value.length <= MAX_MATCHES_INPUT_LENGTH && pattern.test(value); + }, + }); } break; case !!rule.when: diff --git a/src/utils/index.ts b/src/utils/index.ts index dd865c5..e87bc84 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,5 +2,6 @@ export * from "./date-time-helper"; export * from "./file-helper"; export * from "./image-helper"; export * from "./object-helper"; +export * from "./regex-helper"; export * from "./test-helper"; export * from "./value-helper"; diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts new file mode 100644 index 0000000..a46795f --- /dev/null +++ b/src/utils/regex-helper.ts @@ -0,0 +1,16 @@ +export namespace RegexHelper { + /** + * Compiles a "matches"/"notMatches" rule pattern string into a RegExp. + * Accepts either a delimited `/pattern/flags` form or a bare pattern string. + * @returns the compiled RegExp, or undefined (with a logged error) if the config is not a valid pattern + */ + export const compile = (pattern: string): RegExp | undefined => { + const parsed = pattern.match(/^\/(.*)\/([a-z]*)$/); + try { + return parsed ? new RegExp(parsed[1], parsed[2]) : new RegExp(pattern); + } catch (error) { + console.error(`invalid regex pattern: ${pattern}`); + return undefined; + } + }; +} From e53201f3ec9b965ba9be455aa1cebd1442dcc1b2 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Tue, 15 Sep 2026 22:11:28 +0800 Subject: [PATCH 8/9] [MISC][WL] test: cover base64 payloads exceeding default max length --- src/__tests__/utils/file-helper.spec.ts | 7 +++++++ src/__tests__/utils/image-helper.spec.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/__tests__/utils/file-helper.spec.ts b/src/__tests__/utils/file-helper.spec.ts index cad3671..270dbd0 100644 --- a/src/__tests__/utils/file-helper.spec.ts +++ b/src/__tests__/utils/file-helper.spec.ts @@ -1,4 +1,5 @@ import { FileHelper } from "../../utils"; +import { DEFAULT_MAX_BASE64_LENGTH } from "../../shared/constants"; // minimal JPEG header magic-bytes.js needs to identify the file type const JPG_HEADER_BASE64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]).toString("base64"); @@ -93,6 +94,12 @@ describe("file-helper", () => { expect(result).toEqual({ mime: undefined, ext: undefined }); }); + it("should return an unknown type when base64 length exceeds the default max length", async () => { + const oversizedBase64 = "a".repeat(DEFAULT_MAX_BASE64_LENGTH + 1); + const result = await FileHelper.getTypeFromBase64(oversizedBase64); + expect(result).toEqual({ mime: undefined, ext: undefined }); + }); + it("should return an unknown type when base64 length exceeds the provided maxSizeInKb cap", async () => { // cap of ~0 bytes rejects any non-empty payload const result = await FileHelper.getTypeFromBase64(JPG_HEADER_BASE64, 0.0001); diff --git a/src/__tests__/utils/image-helper.spec.ts b/src/__tests__/utils/image-helper.spec.ts index e5a4bf0..efa5389 100644 --- a/src/__tests__/utils/image-helper.spec.ts +++ b/src/__tests__/utils/image-helper.spec.ts @@ -1,4 +1,5 @@ import { ImageHelper, PNG_SIGNATURE } from "../../utils"; +import { DEFAULT_MAX_BASE64_LENGTH } from "../../shared/constants"; // builds a minimal PNG buffer with width/height at the byte offsets the parser reads const buildPngBuffer = ({ @@ -121,6 +122,12 @@ describe("image-helper", () => { expect(ImageHelper.getDimensionsFromBase64(base64)).toEqual({ width: 32, height: 16 }); }); + it("should return undefined when the payload exceeds the default max length", () => { + const oversizedBase64 = "a".repeat(DEFAULT_MAX_BASE64_LENGTH + 1); + + expect(ImageHelper.getDimensionsFromBase64(oversizedBase64)).toBeUndefined(); + }); + it("should return undefined when the payload exceeds the provided maxSizeInKb cap", () => { const dataUrl = toBase64DataUrl("image/png", buildPngBuffer({ width: 100, height: 50 })); From 1dfb82c1522c4292b80fda4ba1705cfb336246e9 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Tue, 15 Sep 2026 22:43:53 +0800 Subject: [PATCH 9/9] [MISC][WL] remove unused error from array-field --- src/fields/array-field.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/fields/array-field.ts b/src/fields/array-field.ts index fecc22c..4f4ccaf 100644 --- a/src/fields/array-field.ts +++ b/src/fields/array-field.ts @@ -49,10 +49,9 @@ export const arrayField = ( (value) => { if (!value) return true; - const errors: Record[] = []; let hasError = false; - uniqueRule.unique.forEach(({ field, errorMessage }) => { + uniqueRule.unique.forEach(({ field }) => { // single pass dedup instead of nested findIndex to avoid O(n^2) over submitted array length const seenAtIndex = new Map(); @@ -60,10 +59,6 @@ export const arrayField = ( const val = item?.[field]; if (!val) return; if (seenAtIndex.has(val)) { - errors[idx] = { - ...errors[idx], - [field]: errorMessage || ERROR_MESSAGES.ARRAY_FIELD.UNIQUE, - }; hasError = true; } else { seenAtIndex.set(val, idx);