Skip to content
9 changes: 7 additions & 2 deletions .github/workflows/trigger-gitlab-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
26 changes: 26 additions & 0 deletions src/__tests__/custom-rules/custom-rules.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
);
});
});
});
50 changes: 48 additions & 2 deletions src/__tests__/schema-generator/json-to-schema.spec.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<ObjectShape>;
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<ObjectShape>;
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: {
Expand Down
36 changes: 31 additions & 5 deletions src/__tests__/schema-generator/yup-helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) =>
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/utils/file-helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -73,4 +77,38 @@ describe("file-helper", () => {
});
});
});

describe("getTypeFromBase64", () => {
Comment thread
qroll marked this conversation as resolved.
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");
});
});
});
20 changes: 20 additions & 0 deletions src/__tests__/utils/image-helper.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand Down Expand Up @@ -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 });
});
});
});
15 changes: 10 additions & 5 deletions src/custom-rules/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 = () =>
Expand Down
18 changes: 8 additions & 10 deletions src/fields/array-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,19 @@ export const arrayField = (
(value) => {
if (!value) return true;

const errors: Record<string, string>[] = [];
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<unknown, number>();

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);
}
});
});
Expand Down
6 changes: 5 additions & 1 deletion src/fields/file-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ export const fileUpload: IFieldGenerator<IFileUploadSchema> = (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;
Expand Down
22 changes: 18 additions & 4 deletions src/fields/generate-field-configs.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -47,14 +48,21 @@ export const generateFieldConfigs = (sections: TSectionsSchema, generateSchema:

const generateChildrenFieldConfigs = (
childrenSchema: Record<string, TComponentSchema>,
generateSchema: TSchemaGenerator
generateSchema: TSchemaGenerator,
depth = 0
) => {
let config: TFieldsConfig<TFieldSchema | TCustomFieldSchema> = {};

if (isEmpty(childrenSchema) || !isObject(childrenSchema)) {
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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Loading