From 64267854b95aed2a7c7f6d76052dd33d2c3fcdf9 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 14:21:50 +0800 Subject: [PATCH 01/23] [MOL-22453][SX] CI: harden GitHub Actions workflow against injection via env vars --- .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 babc714f3..1d4f2d638 100644 --- a/.github/workflows/trigger-gitlab-pipeline.yml +++ b/.github/workflows/trigger-gitlab-pipeline.yml @@ -16,10 +16,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=$(printf '%s\n' "$HEAD_COMMIT_MSG" | awk 'NR>1{exit};1') PIPELINE_PROJECT_URL="github.com/$GITHUB_REPOSITORY.git" From fe96edf00058ef1f22e2eaa1d1ad0bac92715abb Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 16:39:03 +0800 Subject: [PATCH 02/23] [MOL-22453][SX] Add RegexHelper: shared regex parsing + safe cap, update all callers --- .../fields/masked-field/masked-field.spec.tsx | 93 ++++++++++++++++++- .../yup/custom-conditions.spec.ts | 22 +++++ .../frontend-engine/yup/yup-helper.spec.ts | 17 ++++ src/__tests__/utils/regex-helper.spec.ts | 49 ++++++++++ .../image-manager/image-manager.ts | 13 +-- .../fields/masked-field/masked-field.tsx | 56 ++++++++--- src/components/shared/error-messages.tsx | 3 + .../yup/custom-conditions/index.ts | 12 ++- src/context-providers/yup/helper.ts | 40 +++----- src/utils/index.ts | 1 + src/utils/regex-helper.ts | 21 +++++ 11 files changed, 272 insertions(+), 55 deletions(-) create mode 100644 src/__tests__/utils/regex-helper.spec.ts create mode 100644 src/utils/regex-helper.ts diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index 573effd8f..62a46ba1e 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -1,5 +1,9 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { FrontendEngine } from "../../../../components"; import { IMaskedFieldSchema } from "../../../../components/fields"; +import { ERROR_MESSAGES } from "../../../../components/shared"; +import { IFrontendEngineData } from "../../../../components/types"; +import { RegexHelper } from "../../../../utils"; import { ERROR_MESSAGE, createRenderComponent, @@ -60,6 +64,84 @@ describe(UI_TYPE, () => { expect(getMaskedField()).toHaveAttribute("maxLength", "5"); }); + it("should default maxLength to the safe regex length bound when maskRegex is set with no max/length validation", () => { + renderComponent({ maskRange: null, maskRegex: "/^(hello)/g" }); + + expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH}`); + }); + + it("should prefer an explicit max/length validation's maxLength over the maskRegex default", () => { + renderComponent({ maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 5 }] }); + + expect(getMaskedField()).toHaveAttribute("maxLength", "5"); + }); + + it("should not hang when a long value arrives via defaultValues", () => { + const maliciousValue = `${"a".repeat(600)}!`; + + const start = Date.now(); + renderComponent( + { maskRange: null, maskRegex: "/^(a+)+$/" }, + { defaultValues: { [COMPONENT_ID]: maliciousValue } } + ); + expect(Date.now() - start).toBeLessThan(1000); + + expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + ); + }); + + it("should clamp an already-loaded long value at render time when maskRegex changes at runtime", () => { + const maliciousValue = `${"a".repeat(600)}!`; + const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(schema)); + Object.assign(withoutMaskRegex, { defaultValues: { [COMPONENT_ID]: maliciousValue } }); + const { rerender } = render(); + + const withMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(withoutMaskRegex)); + Object.assign(withMaskRegex.sections.section.children[COMPONENT_ID] as object, { + maskRange: null, + maskRegex: "/^(a+)+$/", + }); + + const start = Date.now(); + rerender(); + expect(Date.now() - start).toBeLessThan(1000); + + expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + ); + }); + + it("should reject an oversized programmatic value with a validation error when maskRegex is set but no explicit max/length rule governs the length", async () => { + const oversizedValue = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + renderComponent( + { maskRange: null, maskRegex: "/^(hello)/g" }, + { defaultValues: { [COMPONENT_ID]: oversizedValue } } + ); + + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect( + getErrorMessage( + false, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ) + ).toBeInTheDocument(); + expect(SUBMIT_FN).not.toHaveBeenCalled(); + }); + + it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { + const value = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + renderComponent( + { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, + { defaultValues: { [COMPONENT_ID]: value } } + ); + + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: value })); + }); + it("should support default value", async () => { const defaultValue = "hello"; renderComponent(undefined, { defaultValues: { [COMPONENT_ID]: defaultValue } }); @@ -99,6 +181,15 @@ describe(UI_TYPE, () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValue })); }); + it("should not throw when maskRegex is malformed", () => { + expect(() => + renderComponent( + { maskRange: null, maskRegex: "not a /pattern/flags string [" }, + { defaultValues: { [COMPONENT_ID]: "hello" } } + ) + ).not.toThrow(); + }); + it("should render custom icons", () => { const maskIcon = "AlbumFillIcon"; const unmaskIcon = "AlbumIcon"; diff --git a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts index 0b7b940ea..67195cba1 100644 --- a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts @@ -17,3 +17,25 @@ it.each` expect(TestHelper.getError(() => schema.validateSync(invalidValue)).message).toBe(ERROR_MESSAGE) ); }); + +describe("notMatches", () => { + const buildSchema = (regex: string) => + YupHelper.buildFieldSchema(YupHelper.mapSchemaType("string"), [ + { notMatches: regex, errorMessage: ERROR_MESSAGE }, + ]); + + it("should not throw when the regex string is malformed", () => { + const schema = buildSchema("not a /pattern/flags string ["); + + expect(() => schema.validateSync("hello")).not.toThrow(); + }); + + it("should reject an overly long value instead of testing it against the pattern", () => { + const schema = buildSchema("/^(a+)+$/"); + const maliciousValue = `${"a".repeat(600)}!`; + + const start = Date.now(); + expect(TestHelper.getError(() => schema.validateSync(maliciousValue)).message).toBe(ERROR_MESSAGE); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts b/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts index 2f4b4a75d..6c7f021e8 100644 --- a/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts @@ -248,6 +248,23 @@ describe("YupHelper", () => { ); }); + it("should not hang when input exceeds the safe length bound for a matches pattern", () => { + const schema = YupHelper.mapRules(Yup.string(), [{ matches: "/^(a+)+$/", errorMessage: ERROR_MESSAGE }]); + const maliciousValue = `${"a".repeat(1000)}!`; + + const start = Date.now(); + expect(TestHelper.getError(() => schema.validateSync(maliciousValue))?.message).toBe(ERROR_MESSAGE); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("should skip a matches rule applied to a non-string schema instead of testing the value's string coercion", () => { + const schema = YupHelper.mapRules(Yup.array(), [{ matches: "/^[a-z]+$/", errorMessage: ERROR_MESSAGE }]); + + // an array value stringifies to something that would never satisfy a filename-shaped pattern + // (e.g. "[object Object]") — applying the rule here must not reject the value on that basis + expect(() => schema.validateSync([{ fileName: "test.jpg" }])).not.toThrow(); + }); + const generateMultipleFieldSchema = (type: "string" | "number" | "boolean" | "object" | "array") => YupHelper.buildSchema({ field1: { schema: YupHelper.mapSchemaType(type), validationRules: [] }, diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts new file mode 100644 index 000000000..10add0e97 --- /dev/null +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -0,0 +1,49 @@ +import { RegexHelper } from "../../utils"; + +describe("regex-helper", () => { + describe("parseMatchesPattern", () => { + it("should parse a /pattern/flags string into a RegExp", () => { + const regex = RegexHelper.parseMatchesPattern("/^hello/i"); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe("^hello"); + expect(regex.flags).toBe("i"); + }); + + it("should fall back to treating the whole string as a pattern when it has no /pattern/flags wrapper", () => { + const regex = RegexHelper.parseMatchesPattern("hello"); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe("hello"); + }); + + it("should return undefined instead of throwing on an invalid pattern", () => { + expect(RegexHelper.parseMatchesPattern("/[/")).toBeUndefined(); + }); + }); + + describe("safeTestRegex", () => { + it("should return false when regex is undefined", () => { + expect(RegexHelper.safeTestRegex(undefined, "hello")).toBe(false); + }); + + it("should test the value against the regex when within the safe length bound", () => { + expect(RegexHelper.safeTestRegex(/^hello/, "hello world")).toBe(true); + expect(RegexHelper.safeTestRegex(/^hello/, "goodbye world")).toBe(false); + }); + + it("should return false when value exceeds the safe length bound", () => { + const maliciousValue = `${"a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1)}!`; + + const start = Date.now(); + expect(RegexHelper.safeTestRegex(/^(a+)+$/, maliciousValue)).toBe(false); + expect(Date.now() - start).toBeLessThan(1000); + }); + + it("should return false when a short value does not match the pattern", () => { + const nearMatch = `${"a".repeat(25)}!`; + + expect(RegexHelper.safeTestRegex(/^(a+)+$/, nearMatch)).toBe(false); + }); + }); +}); diff --git a/src/components/fields/image-upload/image-manager/image-manager.ts b/src/components/fields/image-upload/image-manager/image-manager.ts index ce0e13b96..52c7e350b 100644 --- a/src/components/fields/image-upload/image-manager/image-manager.ts +++ b/src/components/fields/image-upload/image-manager/image-manager.ts @@ -1,6 +1,6 @@ import { useContext, useEffect, useRef } from "react"; import { useFormContext } from "react-hook-form"; -import { AxiosApiClient, FileHelper, ImageHelper, generateRandomId } from "../../../../utils"; +import { AxiosApiClient, FileHelper, ImageHelper, RegexHelper, generateRandomId } from "../../../../utils"; import { useFieldEvent, usePrevious } from "../../../../utils/hooks"; import { ImageContext } from "../image-context"; import { @@ -110,7 +110,7 @@ export const ImageManager = (props: IProps) => { case EImageStatus.NONE: if (filenameMatches) { const pattern = resolveMatchesPattern(filenameMatches); - if (pattern && !pattern.test(image.name)) { + if (pattern && !RegexHelper.safeTestRegex(pattern, image.name)) { setImages((prev) => { const updatedImages = [...prev]; updatedImages[index] = { @@ -257,14 +257,7 @@ export const ImageManager = (props: IProps) => { * Converts a matches string (e.g. "/^abc$/i" or "^abc$") to a RegExp. * Returns undefined if the string is invalid. */ - const resolveMatchesPattern = (matches: string): RegExp | undefined => { - try { - const parsed = matches.match(/^\/(.+)\/([gimsuy]*)$/); - return parsed ? new RegExp(parsed[1], parsed[2] || "") : new RegExp(matches); - } catch { - return undefined; - } - }; + const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.parseMatchesPattern(matches); const convertImage = async (index: number, image: IImage) => { try { diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index df0a6e259..34ee7aed9 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -4,9 +4,9 @@ import * as Icons from "@lifesg/react-icons"; import React, { useEffect, useState } from "react"; import * as Yup from "yup"; import { IGenericFieldProps } from ".."; -import { TestHelper } from "../../../utils"; +import { RegexHelper, TestHelper } from "../../../utils"; import { useValidationConfig } from "../../../utils/hooks"; -import { Warning } from "../../shared"; +import { ERROR_MESSAGES, Warning } from "../../shared"; import { IMaskedFieldSchema } from "./types"; export const MaskedField = (props: IGenericFieldProps) => { @@ -24,34 +24,60 @@ export const MaskedField = (props: IGenericFieldProps) => { warning, } = props; - const [stateValue, setStateValue] = useState(value || ""); + const getMaskRegexSafeLength = (): number | undefined => { + if (!maskRegex) return undefined; + const maxRule = validation?.find((rule) => "max" in rule); + const lengthRule = validation?.find((rule) => "length" in rule); + if (maxRule?.max > 0) return maxRule.max; + if (lengthRule?.length > 0) return lengthRule.length; + return RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + }; + + const safeLength = getMaskRegexSafeLength(); + + const clampValue = (val: string | undefined): string => { + const stringVal = val ?? ""; + return safeLength !== undefined ? stringVal.slice(0, safeLength) : stringVal; + }; + + const [stateValue, setStateValue] = useState(() => clampValue(value)); const [derivedAttributes, setDerivedAttributes] = useState({}); const { setFieldValidationConfig } = useValidationConfig(); + const displayedValue = clampValue(stateValue); + // ============================================================================= // EFFECTS // ============================================================================= useEffect(() => { - setFieldValidationConfig(id, Yup.string(), validation); - const maxRule = validation?.find((rule) => "max" in rule); const lengthRule = validation?.find((rule) => "length" in rule); + + let schema = Yup.string(); + if (maskRegex && !maxRule && !lengthRule) { + schema = schema.max( + RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ); + } + setFieldValidationConfig(id, schema, validation); + const attributes = { ...derivedAttributes }; if (maxRule?.max > 0) { attributes.maxLength = maxRule.max; } else if (lengthRule?.length > 0) { attributes.maxLength = lengthRule.length; + } else if (maskRegex) { + attributes.maxLength = RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; } setDerivedAttributes(attributes); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [validation]); + }, [validation, maskRegex]); useEffect(() => { - if (value !== stateValue) { - setStateValue(value || ""); - } + setStateValue(clampValue(value)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [value]); + }, [safeLength, value]); // ============================================================================= // EVENT HANDLERS @@ -65,12 +91,11 @@ export const MaskedField = (props: IGenericFieldProps) => { // ============================================================================= const getRegex = () => { if (!maskRegex) return; - try { - const matches = maskRegex.match(/\/(.*)\/([a-z]+)?/); - return new RegExp(matches[1], matches[2]); - } catch (err) { + const regex = RegexHelper.parseMatchesPattern(maskRegex); + if (!regex) { console.warn(`invalid regex pattern: ${maskRegex}`); } + return regex; }; // ============================================================================= @@ -88,12 +113,13 @@ export const MaskedField = (props: IGenericFieldProps) => { `Value exceeds the maximum allowed length of ${maxLength} characters.`, + }, ARRAY_FIELD: { INVALID: "One or more of the sections is incomplete", REQUIRED: "At least one section must be filled in", diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index 056271e78..df0a28902 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -7,7 +7,7 @@ import { YupHelper } from "../helper"; import "./html-safe"; import "./uinfin"; import "./uen"; -import { DateTimeHelper } from "../../../utils"; +import { DateTimeHelper, RegexHelper } from "../../../utils"; import { IDaysRangeRule, IWhitespaceRule } from "../types"; /** @@ -23,8 +23,14 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => if (isEmptyValue(value)) { return true; } - const matches = regex.match(/\/(.*)\/([a-z]+)?/); - const parsedRegex = new RegExp(matches[1], matches[2]); + const parsedRegex = RegexHelper.parseMatchesPattern(regex); + if (!parsedRegex) { + console.warn(`invalid regex pattern: ${regex}`); + return true; + } + if (value.length > RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) { + return false; + } return !parsedRegex.test(value); }); /** @deprecated */ diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index c83682fd3..849555348 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -1,6 +1,7 @@ import * as Yup from "yup"; import { ObjectShape } from "yup/lib/object"; import { ERROR_MESSAGES } from "../../components/shared"; +import { RegexHelper } from "../../utils"; import { IFieldYupConfig, IYupConditionalValidationRule, @@ -43,25 +44,6 @@ export namespace YupHelper { return Yup.object().meta({ yupId, whenDependencyMap }).shape(yupSchema, whenPairIds); }; - /** - * Converts [dependentField, sourceField] pairs into a sourceField -> dependentFields[] map. - * Used to determine which fields should revalidate when a source field changes. - * @param whenPairIds array of [dependentFieldId, sourceFieldId] pairs - * @returns map of sourceFieldId -> dependentFieldIds[] - */ - const buildWhenDependencyMap = (whenPairIds: [string, string][]): Record => { - const map: Record = {}; - whenPairIds.forEach(([dependentField, sourceField]) => { - if (!map[sourceField]) { - map[sourceField] = []; - } - if (!map[sourceField].includes(dependentField)) { - map[sourceField].push(dependentField); - } - }); - return map; - }; - /** * Iterates through field configs to look for conditional validation rules (`when` condition) * For each conditional validation rule, it will refer to the source field to generate the corresponding yup schema @@ -210,13 +192,19 @@ 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]), - rule.errorMessage - ); - } catch (error) { + if (yupSchema.type !== "string") { + console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); + break; + } + const regex = RegexHelper.parseMatchesPattern(rule.matches); + if (regex) { + yupSchema = (yupSchema as Yup.StringSchema).test({ + name: "matches", + message: rule.errorMessage, + test: (value) => + value === undefined || value === null || RegexHelper.safeTestRegex(regex, value), + }); + } else { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); } } diff --git a/src/utils/index.ts b/src/utils/index.ts index 89bd1b231..d56b3cc2f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,4 @@ export * from "./object-helper"; export * from "./test-helper"; export * from "./types"; export * from "./prop-helper"; +export * from "./regex-helper"; diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts new file mode 100644 index 000000000..80fc07bad --- /dev/null +++ b/src/utils/regex-helper.ts @@ -0,0 +1,21 @@ +export namespace RegexHelper { + export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; + + /** parses a `/pattern/flags`-style string into a RegExp, matching the convention already used + * across the codebase for schema-authored regex config. Returns undefined instead of throwing + * on an invalid pattern. */ + export const parseMatchesPattern = (pattern: string): RegExp | undefined => { + try { + const parsed = pattern.match(/^\/(.+)\/([a-z]*)$/i); + return parsed ? new RegExp(parsed[1], parsed[2]) : new RegExp(pattern); + } catch { + return undefined; + } + }; + + export const safeTestRegex = (regex: RegExp | undefined, value: string): boolean => { + if (!regex) return false; + if (value.length > MAX_SAFE_PATTERN_INPUT_LENGTH) return false; + return regex.test(value); + }; +} From 0cfe2d64d7a378bf7db878c7803ec3b387cf40e0 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 16:39:04 +0800 Subject: [PATCH 03/23] [MOL-22453][SX] sanitize-html: restrict allowedAttributes to safe defaults (no wildcard) --- .../custom/filter/filter-checkbox.spec.tsx | 9 +++++++++ .../components/elements/popover/popover.spec.tsx | 12 ++++++++++++ .../elements/typography/typography.spec.tsx | 12 ++++++++++++ .../filter/filter-checkbox/filter-checkbox.tsx | 3 ++- src/components/elements/popover/popover.tsx | 5 ++++- src/components/elements/typography/typography.tsx | 5 ++++- 6 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx index 41dc58c74..75e1c7081 100644 --- a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx +++ b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx @@ -85,6 +85,15 @@ describe(REFERENCE_KEY, () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValues })); }); + it("should strip event handler attributes from option labels", () => { + renderComponent({ + options: [{ label: 'Apple label', value: "Apple" }], + }); + + const spanElement = screen.getByText("Apple label"); + expect(spanElement).not.toHaveAttribute("onclick"); + }); + it("should be able to render hint", () => { renderComponent({ label: { diff --git a/src/__tests__/components/elements/popover/popover.spec.tsx b/src/__tests__/components/elements/popover/popover.spec.tsx index f4f19d540..c1038e138 100644 --- a/src/__tests__/components/elements/popover/popover.spec.tsx +++ b/src/__tests__/components/elements/popover/popover.spec.tsx @@ -69,6 +69,18 @@ describe(UI_TYPE, () => { expect(screen.queryByTestId("popover").innerHTML.includes("script")).toBe(false); }); + it("should strip event handler attributes from an otherwise-allowed image tag in the hint", () => { + renderComponent({ + hint: { content: '\'broken' }, + }); + + fireEvent.click(screen.getByTestId("field__popover")); + + const imgElement = screen.getByAltText("broken image"); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).not.toHaveAttribute("onerror"); + }); + it("should render icon after text if specified", async () => { renderComponent({ icon: "AlbumFillIcon" }); diff --git a/src/__tests__/components/elements/typography/typography.spec.tsx b/src/__tests__/components/elements/typography/typography.spec.tsx index f7f0e1658..6f29eb9ea 100644 --- a/src/__tests__/components/elements/typography/typography.spec.tsx +++ b/src/__tests__/components/elements/typography/typography.spec.tsx @@ -109,6 +109,18 @@ describe(UI_TYPE, () => { expect(screen.getByText("This is a HTML string")).toBeInTheDocument(); }); + it("should strip event handler attributes from an otherwise-allowed image tag", () => { + renderComponent({ + className: "text-element", + children: '\'broken', + }); + + const imgElement = screen.getByAltText("broken image"); + expect(imgElement).toBeInTheDocument(); + expect(imgElement).not.toHaveAttribute("onerror"); + expect(document.querySelector(".text-element").innerHTML).not.toContain("onerror"); + }); + it("should be able to sanitize HTML string", () => { renderComponent({ className: "text-element", diff --git a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx index 8ea642ba9..138e59864 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import useDeepCompareEffect from "use-deep-compare-effect"; import * as Yup from "yup"; +import sanitizeHtml from "sanitize-html"; import { TestHelper, filterSchemaProps } from "../../../../utils"; import { useValidationConfig } from "../../../../utils/hooks"; import { Sanitize } from "../../../shared"; @@ -89,7 +90,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps (isParentOption(item) ? item.key : item.value)} labelExtractor={(item) => ( - + {item.label} )} diff --git a/src/components/elements/popover/popover.tsx b/src/components/elements/popover/popover.tsx index 789583d1f..9a3255d02 100644 --- a/src/components/elements/popover/popover.tsx +++ b/src/components/elements/popover/popover.tsx @@ -35,7 +35,10 @@ export const Popover = (props: IGenericElementProps) => { const renderPopoverContent = () => { const sanitizeOptions: IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: false, + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ["src", "alt", "width", "height"], + }, }; if (typeof hintContent === "string") { return ( diff --git a/src/components/elements/typography/typography.tsx b/src/components/elements/typography/typography.tsx index 481d0f90a..e937f4f3d 100644 --- a/src/components/elements/typography/typography.tsx +++ b/src/components/elements/typography/typography.tsx @@ -61,7 +61,10 @@ export const Typography = (props: IGenericElementProps) => { // ============================================================================= const sanitizeOptions: IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: false, + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ["src", "alt", "width", "height"], + }, }; const renderText = (): JSX.Element[] | JSX.Element | string[] | string => { From bc40ced41bb31c8633dbbc98056647ba378c0c1d Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 16:39:04 +0800 Subject: [PATCH 04/23] [MOL-22453][SX] ButtonField: restrict href to http/https/mailto/tel schemes only --- src/__tests__/components/fields/button/button.spec.tsx | 8 +++++--- src/components/fields/button/button.tsx | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/__tests__/components/fields/button/button.spec.tsx b/src/__tests__/components/fields/button/button.spec.tsx index 8a1ab3031..e8a4f656c 100644 --- a/src/__tests__/components/fields/button/button.spec.tsx +++ b/src/__tests__/components/fields/button/button.spec.tsx @@ -125,9 +125,11 @@ describe("button", () => { }); it.each` - scenario | href - ${"should not navigate when href is not provided"} | ${undefined} - ${"should not navigate when href is invalid"} | ${"invalid-url"} + scenario | href + ${"should not navigate when href is not provided"} | ${undefined} + ${"should not navigate when href is invalid"} | ${"invalid-url"} + ${"should not navigate when href uses javascript: scheme"} | ${"javascript:alert(1)"} + ${"should not navigate when href uses data: scheme"} | ${"data:text/html,"} `("$scenario", ({ href }) => { renderComponent({ overrideButton: { ...(href && { href }) } }); fireEvent.click(getField("button", COMPONENT_LABEL)); diff --git a/src/components/fields/button/button.tsx b/src/components/fields/button/button.tsx index 2c2a5d3c8..b13f088f8 100644 --- a/src/components/fields/button/button.tsx +++ b/src/components/fields/button/button.tsx @@ -5,6 +5,8 @@ import { useFieldEvent } from "../../../utils/hooks"; import { filterSchemaProps } from "../../../utils/prop-helper"; import { IButtonSchema } from "./types"; +const ALLOWED_URL_SCHEMES = ["http:", "https:", "mailto:", "tel:"]; + export const ButtonField = (props: IGenericFieldProps) => { // ============================================================================= // CONST, STATE, REF @@ -45,7 +47,7 @@ export const ButtonField = (props: IGenericFieldProps) => { const isValidUrl = (url: string): boolean => { try { - return !!new URL(url); + return ALLOWED_URL_SCHEMES.includes(new URL(url).protocol); } catch { return false; } From 35af1ebebced464ca8b59b9b9957493ea4076649 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 16:39:04 +0800 Subject: [PATCH 05/23] [MOL-22453][SX] Iframe: validate postMessage origin against iframe src (default on) --- .../components/custom/iframe/iframe.spec.tsx | 60 +++++++++++++++++++ src/components/custom/iframe/iframe.tsx | 27 ++++++--- src/utils/hooks/use-iframe-message.ts | 5 +- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/__tests__/components/custom/iframe/iframe.spec.tsx b/src/__tests__/components/custom/iframe/iframe.spec.tsx index 0f6567afd..384737976 100644 --- a/src/__tests__/components/custom/iframe/iframe.spec.tsx +++ b/src/__tests__/components/custom/iframe/iframe.spec.tsx @@ -130,6 +130,66 @@ describe("iframe", () => { expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); }); + describe("postMessage origin validation", () => { + const sendPostMessageFromOrigin = (origin: string, type: EPostMessageEvent, payload?: unknown) => { + fireEvent(window, new MessageEvent("message", { data: { type, payload }, origin })); + }; + + it("should ignore a setValue postMessage from an origin that does not match src", async () => { + renderComponent({ validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should accept a setValue postMessage from the origin matching src", async () => { + renderComponent({ validationTimeout: -1 }); + + sendPostMessageFromOrigin(IFRAME_SRC, EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); + }); + + it("should derive the origin for a relative src (resolved against the current page) and still ignore mismatched origins", async () => { + renderComponent({ src: "/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should accept a setValue postMessage matching the origin derived from a relative src", async () => { + renderComponent({ src: "/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("http://localhost", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: "hello world" })); + }); + + it("should derive the origin for a protocol-relative src and still ignore mismatched origins", async () => { + renderComponent({ src: "//localhost/embedded/form", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + + it("should reject every postMessage when src cannot be resolved to a valid http(s) origin", async () => { + renderComponent({ src: "javascript:alert(1)", validationTimeout: -1 }); + + sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world"); + await waitFor(() => fireEvent.click(getSubmitButton())); + + expect(SUBMIT_FN).toHaveBeenCalledWith({}); + }); + }); + describe("load", () => { it("should fire a loading event when iframe starts loading", () => { const testFn = jest.fn(); diff --git a/src/components/custom/iframe/iframe.tsx b/src/components/custom/iframe/iframe.tsx index d26eccbf8..96f610abd 100644 --- a/src/components/custom/iframe/iframe.tsx +++ b/src/components/custom/iframe/iframe.tsx @@ -51,8 +51,11 @@ export const Iframe = (props: IGenericCustomFieldProps) => { // ========================================================================= const getTargetOriginFromSrc = useCallback(() => { try { - const parsedUrl = new URL(src); - return `${parsedUrl.protocol}//${parsedUrl.host}`; + const parsedUrl = new URL(src, window.location.href); + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + return null; + } + return parsedUrl.origin; } catch (error) { console.error("Invalid URL:", error); return null; @@ -130,11 +133,17 @@ export const Iframe = (props: IGenericCustomFieldProps) => { // ========================================================================= // POSTMESSAGE HANDLERS // ========================================================================= + // only messages from the origin derived from `src` are accepted; a child iframe that + // navigates to a different origin before posting back will need to do so via the + // original src origin's window, or the message is dropped + const allowedOrigin = getTargetOriginFromSrc(); + useIframeMessage( EPostMessageEvent.TRIGGER_SYNC, useCallback(() => { iframePostMessage({ type: EPostMessageEvent.SYNC, payload: { error, id, value } }); - }, [error, id, value, iframePostMessage]) + }, [error, id, value, iframePostMessage]), + allowedOrigin ); useIframeMessage<{ width?: number | undefined; height?: number | undefined }>( @@ -144,7 +153,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { width: e.data.payload?.width, height: e.data.payload?.height, }); - }, []) + }, []), + allowedOrigin ); useIframeMessage( @@ -154,7 +164,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { formContext.setValue(id, e.data.payload, { shouldDirty: true }); }, [formContext, id] - ) + ), + allowedOrigin ); useIframeMessage( @@ -169,14 +180,16 @@ export const Iframe = (props: IGenericCustomFieldProps) => { clearAsyncValidation(); }, [clearAsyncValidation] - ) + ), + allowedOrigin ); useIframeMessage( EPostMessageEvent.LOADED, useCallback(() => { dispatchFieldEvent("loaded", id); - }, [dispatchFieldEvent, id]) + }, [dispatchFieldEvent, id]), + allowedOrigin ); // ========================================================================= diff --git a/src/utils/hooks/use-iframe-message.ts b/src/utils/hooks/use-iframe-message.ts index 13d673e6a..fe0a3d701 100644 --- a/src/utils/hooks/use-iframe-message.ts +++ b/src/utils/hooks/use-iframe-message.ts @@ -2,9 +2,10 @@ import { useEffect } from "react"; type MessageHandler = (event: MessageEvent<{ payload: T }>) => void; -export const useIframeMessage = (eventType: string, handler: MessageHandler) => { +export const useIframeMessage = (eventType: string, handler: MessageHandler, allowedOrigin?: string | null) => { useEffect(() => { const eventHandler = (event: MessageEvent) => { + if (allowedOrigin !== undefined && event.origin !== allowedOrigin) return; if (event.data.type === eventType) { handler(event); } @@ -17,5 +18,5 @@ export const useIframeMessage = (eventType: string, handler: MessageHandler { window.removeEventListener("message", eventHandler); }; - }, [eventType, handler]); + }, [eventType, handler, allowedOrigin]); }; From ba523461fe3a4475da4951e81f4fed15c2ebd7b7 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 16:39:04 +0800 Subject: [PATCH 06/23] [MOL-22453][SX] LocationField: escape search query before use in RegExp (prevent ReDoS) --- .../location-search/helper.spec.ts | 31 +++++++++++++++++++ .../location-modal/location-search/helper.ts | 9 +++++- 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts new file mode 100644 index 000000000..757eb31fe --- /dev/null +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -0,0 +1,31 @@ +import { boldResultsWithQuery } from "../../../../../../components/fields/location-field/location-modal/location-search/helper"; +import { IResultListItem } from "../../../../../../components/fields/location-field/types"; + +const buildResult = (address: string): IResultListItem => ({ + address, + displayAddressText: undefined, +}); + +describe("boldResultsWithQuery", () => { + it("should bold the matching portion of the address", () => { + const [result] = boldResultsWithQuery([buildResult("123 Example Street")], "Example"); + + expect(result.displayAddressText).toBe('123 Example Street'); + }); + + it("should treat regex metacharacters in the query as literal characters", () => { + const [result] = boldResultsWithQuery([buildResult("Blk 5 (Example)")], "(Example)"); + + expect(result.displayAddressText).toBe('Blk 5 (Example)'); + }); + + it("should complete within a reasonable time for any query string", () => { + const maliciousQuery = "(a+)+$"; + + const start = Date.now(); + expect(() => + boldResultsWithQuery([buildResult("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")], maliciousQuery) + ).not.toThrow(); + expect(Date.now() - start).toBeLessThan(1000); + }); +}); diff --git a/src/components/fields/location-field/location-modal/location-search/helper.ts b/src/components/fields/location-field/location-modal/location-search/helper.ts index 78c26275c..98031c93b 100644 --- a/src/components/fields/location-field/location-modal/location-search/helper.ts +++ b/src/components/fields/location-field/location-modal/location-search/helper.ts @@ -4,8 +4,15 @@ export const pagination = (array: T[], pageSize: number, pageNum: number) => return array.slice((pageNum - 1) * pageSize, pageNum * pageSize); }; +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + export const boldResultsWithQuery = (arr: IResultListItem[], query: string) => { - const regex = new RegExp(query, "gi"); + let regex: RegExp; + try { + regex = new RegExp(escapeRegExp(query), "gi"); + } catch { + return arr; + } return arr.map((obj) => { const newAddress = (obj.displayAddressText || obj.address).replace( regex, From bb585f7bc00762406638ddfb57d1580628515e0a Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:52 +0800 Subject: [PATCH 07/23] [MOL-22453][SX] StyleHelper: strip @import and url() from schema-authored cssText --- src/__tests__/utils/style-helper.spec.ts | 26 +++++++++++++++++++ .../image-review/image-review.tsx | 4 +-- .../location-modal/location-modal.tsx | 4 +-- src/utils/index.ts | 1 + src/utils/style-helper.ts | 8 ++++++ 5 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/utils/style-helper.spec.ts create mode 100644 src/utils/style-helper.ts diff --git a/src/__tests__/utils/style-helper.spec.ts b/src/__tests__/utils/style-helper.spec.ts new file mode 100644 index 000000000..015c783a4 --- /dev/null +++ b/src/__tests__/utils/style-helper.spec.ts @@ -0,0 +1,26 @@ +import { StyleHelper } from "../../utils"; + +describe("style-helper", () => { + describe("sanitizeStyleString", () => { + it("should strip @import rules", () => { + const result = StyleHelper.sanitizeStyleString( + 'padding: 1rem; @import url("https://evil.example.com/x.css");' + ); + + expect(result).not.toContain("@import"); + expect(result).toContain("padding: 1rem;"); + }); + + it("should strip url() references", () => { + const result = StyleHelper.sanitizeStyleString('background: url("https://evil.example.com/track.png");'); + + expect(result).not.toContain("url("); + }); + + it("should leave a style string with neither construct unchanged", () => { + const value = "padding: 1rem; margin: 2rem;"; + + expect(StyleHelper.sanitizeStyleString(value)).toBe(value); + }); + }); +}); diff --git a/src/components/fields/image-upload/image-review/image-review.tsx b/src/components/fields/image-upload/image-review/image-review.tsx index 753de56a5..72bfb4770 100644 --- a/src/components/fields/image-upload/image-review/image-review.tsx +++ b/src/components/fields/image-upload/image-review/image-review.tsx @@ -14,7 +14,7 @@ import { PencilIcon } from "@lifesg/react-icons/pencil"; import { PencilStrokeIcon } from "@lifesg/react-icons/pencil-stroke"; import clsx from "clsx"; import { Suspense, lazy, useCallback, useContext, useEffect, useRef, useState } from "react"; -import { FileHelper, ImageHelper, TestHelper, generateRandomId } from "../../../../utils"; +import { FileHelper, ImageHelper, StyleHelper, TestHelper, generateRandomId } from "../../../../utils"; import { useFieldEvent, usePrevious } from "../../../../utils/hooks"; import { ImageContext } from "../image-context"; import { ImageUploadHelper } from "../image-upload-helper"; @@ -131,7 +131,7 @@ export const ImageReview = (props: IProps) => { useEffect(() => { if (modalBoxRef) { - modalBoxRef.style.cssText = imageReviewModalStyles || ""; + modalBoxRef.style.cssText = StyleHelper.sanitizeStyleString(imageReviewModalStyles || ""); } }, [imageReviewModalStyles, modalBoxRef]); diff --git a/src/components/fields/location-field/location-modal/location-modal.tsx b/src/components/fields/location-field/location-modal/location-modal.tsx index 67801e187..46c19c23b 100644 --- a/src/components/fields/location-field/location-modal/location-modal.tsx +++ b/src/components/fields/location-field/location-modal/location-modal.tsx @@ -10,7 +10,7 @@ import clsx from "clsx"; import { isEmpty } from "lodash"; import { useCallback, useEffect, useRef, useState } from "react"; import { OneMapError } from "../../../../services/onemap/types"; -import { GeoLocationHelper, TestHelper } from "../../../../utils"; +import { GeoLocationHelper, StyleHelper, TestHelper } from "../../../../utils"; import { useFieldEvent } from "../../../../utils/hooks"; import { Prompt } from "../../../shared"; import { LocationHelper } from "../location-helper"; @@ -268,7 +268,7 @@ const LocationModal = ({ useEffect(() => { if (modalBoxRef) { - modalBoxRef.style.cssText = locationModalStyles || ""; + modalBoxRef.style.cssText = StyleHelper.sanitizeStyleString(locationModalStyles || ""); } }, [locationModalStyles, modalBoxRef]); diff --git a/src/utils/index.ts b/src/utils/index.ts index d56b3cc2f..bcc85bbcd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,4 @@ export * from "./test-helper"; export * from "./types"; export * from "./prop-helper"; export * from "./regex-helper"; +export * from "./style-helper"; diff --git a/src/utils/style-helper.ts b/src/utils/style-helper.ts new file mode 100644 index 000000000..c449cd0a6 --- /dev/null +++ b/src/utils/style-helper.ts @@ -0,0 +1,8 @@ +export namespace StyleHelper { + /** strips the two concrete network-side-channel risks from a schema-authored CSS string before it's + * assigned to an element's style.cssText: @import (loads a remote stylesheet) and url() (references + * a remote resource). Not a full CSS parser/allowlist — legitimate uses of this styling hook + * (padding/margin tweaks) need neither construct. */ + export const sanitizeStyleString = (value: string): string => + value.replace(/@import[^;]*;?/gi, "").replace(/url\s*\([^)]*\)/gi, ""); +} From 69f1ddf8234af3546ba8681e73e107f91f171897 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:52 +0800 Subject: [PATCH 08/23] [MOL-22453][SX] ImageUpload: fix matches rule incorrectly blocking valid file submissions --- .../fields/image-upload/image-upload.spec.tsx | 31 +++++++++++++++++++ .../fields/image-upload/image-upload.tsx | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index c4f58197e..c5be18725 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -349,6 +349,22 @@ describe("image-upload", () => { await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); }); + it("should be able to submit a valid file when a matches rule is configured", async () => { + await renderComponent({ + files: [FILE_1], // "test.jpg" — lowercase alphanumeric + dot + overrideField: { validation: [{ matches: MATCHES_PATTERN, errorMessage: ERROR_MESSAGE }] }, + uploadType: "input", + }); + + await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => fireEvent.click(getSubmitButton())); + expect(SUBMIT_FN).toHaveBeenCalledWith( + expect.objectContaining({ + field: expect.arrayContaining([expect.objectContaining({ fileName: FILE_1.name })]), + }) + ); + }); + it("should exclude invalid filename files from form submission", async () => { await renderComponent({ files: [INVALID_FILE], @@ -380,6 +396,21 @@ describe("image-upload", () => { }) ); }); + + it("should not hang when matching a long filename against a regex pattern", async () => { + const maliciousFile = new File(["file"], `${"a".repeat(600)}!.jpg`, { type: "image/jpeg" }); + + const start = Date.now(); + await renderComponent({ + files: [maliciousFile], + overrideField: { validation: [{ matches: "/^(a+)+$/", errorMessage: ERROR_MESSAGE }] }, + uploadType: "input", + }); + await waitFor(() => expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument()); + + expect(Date.now() - start).toBeLessThan(1000); + expect(uploadSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/components/fields/image-upload/image-upload.tsx b/src/components/fields/image-upload/image-upload.tsx index 915f1ff1c..342b97927 100644 --- a/src/components/fields/image-upload/image-upload.tsx +++ b/src/components/fields/image-upload/image-upload.tsx @@ -155,7 +155,7 @@ export const ImageUploadInner = (props: IGenericFieldProps) ); } ), - validation + validation?.filter((rule) => !("matches" in rule)) ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [validation]); From 6b159da62ba099a0f7e2ea650839d5dffff8336a Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:52 +0800 Subject: [PATCH 09/23] [MOL-22453][SX] Docker: bind port explicitly instead of host networking --- docker-compose.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index f72cf6aa3..d87bd2eb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: playwright-tests: build: . - network_mode: "host" + ports: + - "127.0.0.1:3010:3010" extra_hosts: - "host.docker.internal:host-gateway" From 531d1f8505a21521cbf8d4e5897243df6e8be491 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:52 +0800 Subject: [PATCH 10/23] [MOL-22453][SX] FilterCheckbox: omit sanitizeOptions (equivalent to default) --- .../custom/filter/filter-checkbox/filter-checkbox.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx index 138e59864..c56aa9972 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import { useFormContext } from "react-hook-form"; import useDeepCompareEffect from "use-deep-compare-effect"; import * as Yup from "yup"; -import sanitizeHtml from "sanitize-html"; import { TestHelper, filterSchemaProps } from "../../../../utils"; import { useValidationConfig } from "../../../../utils/hooks"; import { Sanitize } from "../../../shared"; @@ -90,7 +89,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps (isParentOption(item) ? item.key : item.value)} labelExtractor={(item) => ( - + {item.label} )} From fc2318e16253435d1b622045d74e82017aa4e8d9 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:52 +0800 Subject: [PATCH 11/23] [MOL-22453][SX] yup matches: add comment explaining non-string guard --- src/context-providers/yup/helper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index 849555348..d79f14a82 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -192,6 +192,7 @@ export namespace YupHelper { break; case !!rule.matches: { + // "matches" tests the field's own value as a string; skip for non-string values if (yupSchema.type !== "string") { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); break; From 6ba949152d6683831c4a5b80d2a2fffd264768ec Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:53 +0800 Subject: [PATCH 12/23] [MOL-22453][SX] RegexHelper: rename parseMatchesPattern to compile --- src/__tests__/utils/regex-helper.spec.ts | 8 ++++---- .../fields/image-upload/image-manager/image-manager.ts | 2 +- src/components/fields/masked-field/masked-field.tsx | 2 +- src/context-providers/yup/custom-conditions/index.ts | 2 +- src/context-providers/yup/helper.ts | 2 +- src/utils/regex-helper.ts | 5 +---- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index 10add0e97..62ffcfdc5 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -1,9 +1,9 @@ import { RegexHelper } from "../../utils"; describe("regex-helper", () => { - describe("parseMatchesPattern", () => { + describe("compile", () => { it("should parse a /pattern/flags string into a RegExp", () => { - const regex = RegexHelper.parseMatchesPattern("/^hello/i"); + const regex = RegexHelper.compile("/^hello/i"); expect(regex).toBeInstanceOf(RegExp); expect(regex.source).toBe("^hello"); @@ -11,14 +11,14 @@ describe("regex-helper", () => { }); it("should fall back to treating the whole string as a pattern when it has no /pattern/flags wrapper", () => { - const regex = RegexHelper.parseMatchesPattern("hello"); + const regex = RegexHelper.compile("hello"); expect(regex).toBeInstanceOf(RegExp); expect(regex.source).toBe("hello"); }); it("should return undefined instead of throwing on an invalid pattern", () => { - expect(RegexHelper.parseMatchesPattern("/[/")).toBeUndefined(); + expect(RegexHelper.compile("/[/")).toBeUndefined(); }); }); diff --git a/src/components/fields/image-upload/image-manager/image-manager.ts b/src/components/fields/image-upload/image-manager/image-manager.ts index 52c7e350b..53b0a8e90 100644 --- a/src/components/fields/image-upload/image-manager/image-manager.ts +++ b/src/components/fields/image-upload/image-manager/image-manager.ts @@ -257,7 +257,7 @@ export const ImageManager = (props: IProps) => { * Converts a matches string (e.g. "/^abc$/i" or "^abc$") to a RegExp. * Returns undefined if the string is invalid. */ - const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.parseMatchesPattern(matches); + const resolveMatchesPattern = (matches: string): RegExp | undefined => RegexHelper.compile(matches); const convertImage = async (index: number, image: IImage) => { try { diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index 34ee7aed9..7093f3f00 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -91,7 +91,7 @@ export const MaskedField = (props: IGenericFieldProps) => { // ============================================================================= const getRegex = () => { if (!maskRegex) return; - const regex = RegexHelper.parseMatchesPattern(maskRegex); + const regex = RegexHelper.compile(maskRegex); if (!regex) { console.warn(`invalid regex pattern: ${maskRegex}`); } diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index df0a28902..3d1e270ab 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -23,7 +23,7 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => if (isEmptyValue(value)) { return true; } - const parsedRegex = RegexHelper.parseMatchesPattern(regex); + const parsedRegex = RegexHelper.compile(regex); if (!parsedRegex) { console.warn(`invalid regex pattern: ${regex}`); return true; diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index d79f14a82..ad98544b7 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -197,7 +197,7 @@ export namespace YupHelper { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); break; } - const regex = RegexHelper.parseMatchesPattern(rule.matches); + const regex = RegexHelper.compile(rule.matches); if (regex) { yupSchema = (yupSchema as Yup.StringSchema).test({ name: "matches", diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts index 80fc07bad..17d379a47 100644 --- a/src/utils/regex-helper.ts +++ b/src/utils/regex-helper.ts @@ -1,10 +1,7 @@ export namespace RegexHelper { export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; - /** parses a `/pattern/flags`-style string into a RegExp, matching the convention already used - * across the codebase for schema-authored regex config. Returns undefined instead of throwing - * on an invalid pattern. */ - export const parseMatchesPattern = (pattern: string): RegExp | undefined => { + export const compile = (pattern: string): RegExp | undefined => { try { const parsed = pattern.match(/^\/(.+)\/([a-z]*)$/i); return parsed ? new RegExp(parsed[1], parsed[2]) : new RegExp(pattern); From 50b78d87af3ac3661d5a2609e5fdefc4dfd962e3 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:53 +0800 Subject: [PATCH 13/23] [MOL-22453][SX] RegexHelper: rename MAX_SAFE_PATTERN_INPUT_LENGTH to MAX_MATCHES_INPUT_LENGTH, bump to 1000 --- .../fields/masked-field/masked-field.spec.tsx | 12 ++++++------ src/__tests__/utils/regex-helper.spec.ts | 2 +- src/components/fields/masked-field/masked-field.tsx | 8 ++++---- src/context-providers/yup/custom-conditions/index.ts | 2 +- src/utils/regex-helper.ts | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index 62a46ba1e..c59bdcab7 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -67,7 +67,7 @@ describe(UI_TYPE, () => { it("should default maxLength to the safe regex length bound when maskRegex is set with no max/length validation", () => { renderComponent({ maskRange: null, maskRegex: "/^(hello)/g" }); - expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH}`); + expect(getMaskedField()).toHaveAttribute("maxLength", `${RegexHelper.MAX_MATCHES_INPUT_LENGTH}`); }); it("should prefer an explicit max/length validation's maxLength over the maskRegex default", () => { @@ -87,7 +87,7 @@ describe(UI_TYPE, () => { expect(Date.now() - start).toBeLessThan(1000); expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + RegexHelper.MAX_MATCHES_INPUT_LENGTH ); }); @@ -108,12 +108,12 @@ describe(UI_TYPE, () => { expect(Date.now() - start).toBeLessThan(1000); expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + RegexHelper.MAX_MATCHES_INPUT_LENGTH ); }); it("should reject an oversized programmatic value with a validation error when maskRegex is set but no explicit max/length rule governs the length", async () => { - const oversizedValue = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + const oversizedValue = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); renderComponent( { maskRange: null, maskRegex: "/^(hello)/g" }, { defaultValues: { [COMPONENT_ID]: oversizedValue } } @@ -124,14 +124,14 @@ describe(UI_TYPE, () => { expect( getErrorMessage( false, - ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_MATCHES_INPUT_LENGTH) ) ).toBeInTheDocument(); expect(SUBMIT_FN).not.toHaveBeenCalled(); }); it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { - const value = "a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1); + const value = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); renderComponent( { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, { defaultValues: { [COMPONENT_ID]: value } } diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index 62ffcfdc5..ebe9acb5d 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -33,7 +33,7 @@ describe("regex-helper", () => { }); it("should return false when value exceeds the safe length bound", () => { - const maliciousValue = `${"a".repeat(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH + 1)}!`; + const maliciousValue = `${"a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1)}!`; const start = Date.now(); expect(RegexHelper.safeTestRegex(/^(a+)+$/, maliciousValue)).toBe(false); diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index 7093f3f00..c122fd1c4 100644 --- a/src/components/fields/masked-field/masked-field.tsx +++ b/src/components/fields/masked-field/masked-field.tsx @@ -30,7 +30,7 @@ export const MaskedField = (props: IGenericFieldProps) => { const lengthRule = validation?.find((rule) => "length" in rule); if (maxRule?.max > 0) return maxRule.max; if (lengthRule?.length > 0) return lengthRule.length; - return RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + return RegexHelper.MAX_MATCHES_INPUT_LENGTH; }; const safeLength = getMaskRegexSafeLength(); @@ -56,8 +56,8 @@ export const MaskedField = (props: IGenericFieldProps) => { let schema = Yup.string(); if (maskRegex && !maxRule && !lengthRule) { schema = schema.max( - RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH, - ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) + RegexHelper.MAX_MATCHES_INPUT_LENGTH, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_MATCHES_INPUT_LENGTH) ); } setFieldValidationConfig(id, schema, validation); @@ -68,7 +68,7 @@ export const MaskedField = (props: IGenericFieldProps) => { } else if (lengthRule?.length > 0) { attributes.maxLength = lengthRule.length; } else if (maskRegex) { - attributes.maxLength = RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH; + attributes.maxLength = RegexHelper.MAX_MATCHES_INPUT_LENGTH; } setDerivedAttributes(attributes); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/context-providers/yup/custom-conditions/index.ts b/src/context-providers/yup/custom-conditions/index.ts index 3d1e270ab..d78185aad 100644 --- a/src/context-providers/yup/custom-conditions/index.ts +++ b/src/context-providers/yup/custom-conditions/index.ts @@ -28,7 +28,7 @@ YupHelper.addCondition("string", "notMatches", (value: string, regex: string) => console.warn(`invalid regex pattern: ${regex}`); return true; } - if (value.length > RegexHelper.MAX_SAFE_PATTERN_INPUT_LENGTH) { + if (value.length > RegexHelper.MAX_MATCHES_INPUT_LENGTH) { return false; } return !parsedRegex.test(value); diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts index 17d379a47..6431582cb 100644 --- a/src/utils/regex-helper.ts +++ b/src/utils/regex-helper.ts @@ -1,5 +1,5 @@ export namespace RegexHelper { - export const MAX_SAFE_PATTERN_INPUT_LENGTH = 500; + export const MAX_MATCHES_INPUT_LENGTH = 1000; export const compile = (pattern: string): RegExp | undefined => { try { @@ -12,7 +12,7 @@ export namespace RegexHelper { export const safeTestRegex = (regex: RegExp | undefined, value: string): boolean => { if (!regex) return false; - if (value.length > MAX_SAFE_PATTERN_INPUT_LENGTH) return false; + if (value.length > MAX_MATCHES_INPUT_LENGTH) return false; return regex.test(value); }; } From ba8a2fa228b3caec743fa03cf2d975a15f8040f4 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:30:53 +0800 Subject: [PATCH 14/23] [MOL-22453][SX] yup matches: also pass empty string --- src/context-providers/yup/helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index ad98544b7..83a2231f7 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -203,7 +203,7 @@ export namespace YupHelper { name: "matches", message: rule.errorMessage, test: (value) => - value === undefined || value === null || RegexHelper.safeTestRegex(regex, value), + value === undefined || value === null || value === "" || RegexHelper.safeTestRegex(regex, value), }); } else { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); From 7cbf0e51d003a3abf06ab30bab977626d28ac9e2 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Fri, 18 Sep 2026 18:40:30 +0800 Subject: [PATCH 15/23] [MOL-22453][SX] LocationField: use vm.runInNewContext to prevent CI hang on ReDoS regression --- .../location-modal/location-search/helper.spec.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts index 757eb31fe..66c4b1f86 100644 --- a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -1,3 +1,4 @@ +import vm from "vm"; import { boldResultsWithQuery } from "../../../../../../components/fields/location-field/location-modal/location-search/helper"; import { IResultListItem } from "../../../../../../components/fields/location-field/types"; @@ -20,12 +21,9 @@ describe("boldResultsWithQuery", () => { }); it("should complete within a reasonable time for any query string", () => { - const maliciousQuery = "(a+)+$"; - - const start = Date.now(); + const input = [buildResult("a".repeat(25) + "!")]; expect(() => - boldResultsWithQuery([buildResult("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!")], maliciousQuery) + vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 }) ).not.toThrow(); - expect(Date.now() - start).toBeLessThan(1000); }); }); From e4329bbaa75f1366ad4435f9ac4d07ba1001dd7a Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 10:15:15 +0800 Subject: [PATCH 16/23] [MOL-22453][SX] Stories: document url()/import stripping in locationModalStyles and imageReviewModalStyles --- .../3-fields/image-upload/image-upload.stories.tsx | 11 +++++++++++ .../location-field/location-field.stories.tsx | 13 ++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/stories/3-fields/image-upload/image-upload.stories.tsx b/src/stories/3-fields/image-upload/image-upload.stories.tsx index 1e332ae72..385e6d0ef 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -189,6 +189,17 @@ const meta: Meta = { defaultValue: { summary: null }, }, }, + imageReviewModalStyles: { + description: "CSS string applied directly to the image review modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.", + table: { + type: { + summary: "string", + }, + }, + control: { + type: "text", + }, + }, }, }; export default meta; diff --git a/src/stories/3-fields/location-field/location-field.stories.tsx b/src/stories/3-fields/location-field/location-field.stories.tsx index 9f8423c88..433b98f3f 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -186,9 +186,20 @@ const meta: Meta = { type: "object", }, }, + locationModalStyles: { + description: “CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.”, + table: { + type: { + summary: “string”, + }, + }, + control: { + type: “text”, + }, + }, restrictNonSGLocation: { description: - "Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.", + “Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.”, table: { type: { summary: "boolean", From 4ad599b69fd832a4fd4383cb4645b8ad57200d3c Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 11:15:03 +0800 Subject: [PATCH 17/23] [MOL-22453][SX] yup: restore missing buildWhenDependencyMap (accidentally dropped in fe96edf0) --- src/context-providers/yup/helper.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/context-providers/yup/helper.ts b/src/context-providers/yup/helper.ts index 83a2231f7..3e45a7273 100644 --- a/src/context-providers/yup/helper.ts +++ b/src/context-providers/yup/helper.ts @@ -44,6 +44,25 @@ export namespace YupHelper { return Yup.object().meta({ yupId, whenDependencyMap }).shape(yupSchema, whenPairIds); }; + /** + * Converts [dependentField, sourceField] pairs into a sourceField -> dependentFields[] map. + * Used to determine which fields should revalidate when a source field changes. + * @param whenPairIds array of [dependentFieldId, sourceFieldId] pairs + * @returns map of sourceFieldId -> dependentFieldIds[] + */ + const buildWhenDependencyMap = (whenPairIds: [string, string][]): Record => { + const map: Record = {}; + whenPairIds.forEach(([dependentField, sourceField]) => { + if (!map[sourceField]) { + map[sourceField] = []; + } + if (!map[sourceField].includes(dependentField)) { + map[sourceField].push(dependentField); + } + }); + return map; + }; + /** * Iterates through field configs to look for conditional validation rules (`when` condition) * For each conditional validation rule, it will refer to the source field to generate the corresponding yup schema From c0348912f6b9c91bd1d40d65f26b5b58bed9e490 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 15:56:14 +0800 Subject: [PATCH 18/23] [MOL-22453][SX] Stories: fix curly quotes (U+201C/U+201D) in locationModalStyles argType --- .../3-fields/location-field/location-field.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/stories/3-fields/location-field/location-field.stories.tsx b/src/stories/3-fields/location-field/location-field.stories.tsx index 433b98f3f..f0bf0c28a 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -187,19 +187,19 @@ const meta: Meta = { }, }, locationModalStyles: { - description: “CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.”, + description: "CSS string applied directly to the location modal box via `style.cssText`. Note: `url()` and `@import` are stripped before application.", table: { type: { - summary: “string”, + summary: "string", }, }, control: { - type: “text”, + type: "text", }, }, restrictNonSGLocation: { description: - “Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.”, + "Prevents confirming and submitting locations that are outside Singapore. On confirming any selected location — a searched address (e.g. `CAUSEWAY (JOHOR)`), a map selection or an unresolvable `Pin location: , ` value — its coordinates are checked against the coastal outlines of SLA's National Map Polygon dataset: if it falls on a neighbouring (JOHOR (MALAYSIA)) landmass or in waters outside Singapore, the “This location is outside Singapore.” prompt is shown and the location modal stays open. Areas within Singapore that simply have no addresses nearby (e.g. sea just off the coast, reservoirs) remain confirmable. Prefilled values that resolve to locations outside Singapore are cleared and such values fail validation on submission.", table: { type: { summary: "boolean", From 2414fcce586d8f7f6458153ab403d977f9119f75 Mon Sep 17 00:00:00 2001 From: shengxi-gt Date: Mon, 21 Sep 2026 16:27:57 +0800 Subject: [PATCH 19/23] [MOL-22453][SX] Update src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts Co-authored-by: Ruo Ling --- .../location-modal/location-search/helper.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts index 66c4b1f86..2e701d1d1 100644 --- a/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -21,7 +21,7 @@ describe("boldResultsWithQuery", () => { }); it("should complete within a reasonable time for any query string", () => { - const input = [buildResult("a".repeat(25) + "!")]; + const input = [buildResult("a".repeat(30) + "!")]; expect(() => vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 }) ).not.toThrow(); From ef6c651a3c236fe28174a78f43cde04e987e89bd Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 16:47:38 +0800 Subject: [PATCH 20/23] [MOL-22453][SX] ImageUpload: use repeat(1000) to exceed MAX_MATCHES_INPUT_LENGTH guard in hang test --- .../components/fields/image-upload/image-upload.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index c5be18725..b1dab377b 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -398,7 +398,7 @@ describe("image-upload", () => { }); it("should not hang when matching a long filename against a regex pattern", async () => { - const maliciousFile = new File(["file"], `${"a".repeat(600)}!.jpg`, { type: "image/jpeg" }); + const maliciousFile = new File(["file"], `${"a".repeat(1000)}!.jpg`, { type: "image/jpeg" }); const start = Date.now(); await renderComponent({ From bd83d8a9dc941be7eba0a0615931859a076364da Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 17:19:07 +0800 Subject: [PATCH 21/23] [MOL-22453][SX] Tests: fix catastrophic-regex test values to exceed MAX_MATCHES_INPUT_LENGTH guard --- .../components/frontend-engine/yup/custom-conditions.spec.ts | 2 +- src/__tests__/utils/regex-helper.spec.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts index 67195cba1..ec755c8ce 100644 --- a/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts +++ b/src/__tests__/components/frontend-engine/yup/custom-conditions.spec.ts @@ -32,7 +32,7 @@ describe("notMatches", () => { it("should reject an overly long value instead of testing it against the pattern", () => { const schema = buildSchema("/^(a+)+$/"); - const maliciousValue = `${"a".repeat(600)}!`; + const maliciousValue = `${"a".repeat(1000)}!`; const start = Date.now(); expect(TestHelper.getError(() => schema.validateSync(maliciousValue)).message).toBe(ERROR_MESSAGE); diff --git a/src/__tests__/utils/regex-helper.spec.ts b/src/__tests__/utils/regex-helper.spec.ts index ebe9acb5d..6cb72bea1 100644 --- a/src/__tests__/utils/regex-helper.spec.ts +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -41,9 +41,7 @@ describe("regex-helper", () => { }); it("should return false when a short value does not match the pattern", () => { - const nearMatch = `${"a".repeat(25)}!`; - - expect(RegexHelper.safeTestRegex(/^(a+)+$/, nearMatch)).toBe(false); + expect(RegexHelper.safeTestRegex(/^[0-9]+$/, "abc")).toBe(false); }); }); }); From 1d3311aababcfc6e9e02411b07f01e55a75ab997 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 18:20:14 +0800 Subject: [PATCH 22/23] [MOL-22453][SX] Tests: fix masked-field repeat(600) to exceed MAX_MATCHES_INPUT_LENGTH guard --- .../components/fields/masked-field/masked-field.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index c59bdcab7..f487fa01d 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -77,7 +77,7 @@ describe(UI_TYPE, () => { }); it("should not hang when a long value arrives via defaultValues", () => { - const maliciousValue = `${"a".repeat(600)}!`; + const maliciousValue = `${"a".repeat(1000)}!`; const start = Date.now(); renderComponent( @@ -92,7 +92,7 @@ describe(UI_TYPE, () => { }); it("should clamp an already-loaded long value at render time when maskRegex changes at runtime", () => { - const maliciousValue = `${"a".repeat(600)}!`; + const maliciousValue = `${"a".repeat(1000)}!`; const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(schema)); Object.assign(withoutMaskRegex, { defaultValues: { [COMPONENT_ID]: maliciousValue } }); const { rerender } = render(); From bd40b47b1189b7d9ed22082c35e14c636c5c9195 Mon Sep 17 00:00:00 2001 From: "Chen Shengxi (Dave)" Date: Mon, 21 Sep 2026 18:40:49 +0800 Subject: [PATCH 23/23] [MOL-22453][SX] Tests: fix masked-field hang tests and max-validation values --- .../components/fields/masked-field/masked-field.spec.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx index f487fa01d..ceb621483 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -131,9 +131,9 @@ describe(UI_TYPE, () => { }); it("should not reject an oversized value when an explicit max validation rule already permits that length", async () => { - const value = "a".repeat(RegexHelper.MAX_MATCHES_INPUT_LENGTH + 1); + const value = "a".repeat(1001); // above MAX_MATCHES_INPUT_LENGTH (1000) but within explicit max: 1100 renderComponent( - { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1000 }] }, + { maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1100 }] }, { defaultValues: { [COMPONENT_ID]: value } } );