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" diff --git a/src/__tests__/custom-rules/custom-rules.spec.ts b/src/__tests__/custom-rules/custom-rules.spec.ts index 42643e5..f77bdd0 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,29 @@ 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 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); + expect(TestHelper.getError(() => schema.validateSync({ field: oversizedValue })).message).toBe( + ERROR_MESSAGE + ); + }); + }); }); 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/__tests__/schema-generator/yup-helper.spec.ts b/src/__tests__/schema-generator/yup-helper.spec.ts index b43685d..f07fb66 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,36 @@ 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 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 }, + ]); + 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/__tests__/utils/file-helper.spec.ts b/src/__tests__/utils/file-helper.spec.ts index c0a1002..270dbd0 100644 --- a/src/__tests__/utils/file-helper.spec.ts +++ b/src/__tests__/utils/file-helper.spec.ts @@ -1,4 +1,8 @@ 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"); describe("file-helper", () => { describe("extensionsToSentence", () => { @@ -73,4 +77,38 @@ 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 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); + 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..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 = ({ @@ -120,5 +121,24 @@ 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 })); + + // 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..393637d 100644 --- a/src/custom-rules/values.ts +++ b/src/custom-rules/values.ts @@ -2,7 +2,8 @@ import isEmpty from "lodash/isEmpty"; import isEqual from "lodash/isEqual"; import isBoolean from "lodash/isBoolean"; import { IDaysRangeRule, IWhitespaceRule, addRule } from "../schema-generator"; -import { DateTimeHelper, ValueHelper } from "../utils"; +import { MAX_MATCHES_INPUT_LENGTH } from "../shared"; +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)); @@ -12,12 +13,16 @@ 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]+)?/); - const parsedRegex = new RegExp(matches[1], matches[2]); - return !parsedRegex.test(value); + 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; + } + return !pattern.test(value); }); /** @deprecated use `whitespace` */ export const noWhitespaceOnly = () => diff --git a/src/fields/array-field.ts b/src/fields/array-field.ts index 54949c5..4f4ccaf 100644 --- a/src/fields/array-field.ts +++ b/src/fields/array-field.ts @@ -49,21 +49,19 @@ export const arrayField = ( (value) => { if (!value) return true; - const errors: Record[] = []; let hasError = false; - uniqueRule.unique.forEach(({ field, errorMessage }) => { - const fieldValues = value.map((item) => item?.[field]); + uniqueRule.unique.forEach(({ 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) { - errors[idx] = { - ...errors[idx], - [field]: errorMessage || ERROR_MESSAGES.ARRAY_FIELD.UNIQUE, - }; + if (seenAtIndex.has(val)) { hasError = true; + } else { + seenAtIndex.set(val, idx); } }); }); diff --git a/src/fields/file-upload.ts b/src/fields/file-upload.ts index 0545514..627597f 100644 --- a/src/fields/file-upload.ts +++ b/src/fields/file-upload.ts @@ -91,8 +91,12 @@ 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); + const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.maxSizeInKb); const validFileType = fileTypeRule.fileType?.length ? fileTypeRule.fileType?.includes(fileType.ext) : true; 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/fields/image-upload.ts b/src/fields/image-upload.ts index fc410f3..f9f83de 100644 --- a/src/fields/image-upload.ts +++ b/src/fields/image-upload.ts @@ -1,8 +1,8 @@ import * as Yup from "yup"; import { IFieldSchemaBase, IValidationRule } from "../schema-generator"; import { IFieldGenerator } from "./types"; -import { ERROR_MESSAGES } from "../shared"; -import { FileHelper, ImageHelper } from "../utils"; +import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; +import { FileHelper, ImageHelper, RegexHelper } from "../utils"; type TImageUploadAcceptedFileType = "jpg" | "gif" | "png" | "heic" | "heif" | "webp"; type TImageUploadOutputFileType = "jpg" | "png"; @@ -84,8 +84,12 @@ 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); + const fileType = await FileHelper.getTypeFromBase64(base64, maxFileSizeRule?.["maxSizeInKb"]); const validFileType = fileType.ext === outputType; if (!validFileType) { isValid = false; @@ -109,7 +113,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 ); @@ -121,15 +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); - return value.every((file) => 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/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/schema-generator/yup-helper.ts b/src/schema-generator/yup-helper.ts index 799d8a1..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 } from "../shared"; +import { ERROR_MESSAGES, MAX_MATCHES_INPUT_LENGTH } from "../shared"; +import { RegexHelper } from "../utils"; import { CONDITIONS, IConditionalValidationRule, @@ -85,15 +86,23 @@ export namespace YupHelper { break; case !!rule.matches: { - const matches = rule.matches.match(/\/(.*)\/([a-z]+)?/); - try { - yupSchema = (yupSchema as Yup.StringSchema).matches(new RegExp(matches[1], matches[2]), { - excludeEmptyString: true, - message: rule.errorMessage, - }); - } catch (error) { + // 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 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/shared/constants.ts b/src/shared/constants.ts new file mode 100644 index 0000000..2ae0f14 --- /dev/null +++ b/src/shared/constants.ts @@ -0,0 +1,8 @@ +// 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; + +// caps recursion depth over nested schema config (children / option.children) to prevent stack exhaustion +export const MAX_SCHEMA_NESTING_DEPTH = 50; 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; 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; + } + }; +}