Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6426785
[MOL-22453][SX] CI: harden GitHub Actions workflow against injection …
Sep 18, 2026
fe96edf
[MOL-22453][SX] Add RegexHelper: shared regex parsing + safe cap, upd…
Sep 18, 2026
0cfe2d6
[MOL-22453][SX] sanitize-html: restrict allowedAttributes to safe def…
Sep 18, 2026
bc40ced
[MOL-22453][SX] ButtonField: restrict href to http/https/mailto/tel s…
Sep 18, 2026
35af1eb
[MOL-22453][SX] Iframe: validate postMessage origin against iframe sr…
Sep 18, 2026
ba52346
[MOL-22453][SX] LocationField: escape search query before use in RegE…
Sep 18, 2026
bb585f7
[MOL-22453][SX] StyleHelper: strip @import and url() from schema-auth…
Sep 18, 2026
69f1ddf
[MOL-22453][SX] ImageUpload: fix matches rule incorrectly blocking va…
Sep 18, 2026
6b159da
[MOL-22453][SX] Docker: bind port explicitly instead of host networking
Sep 18, 2026
531d1f8
[MOL-22453][SX] FilterCheckbox: omit sanitizeOptions (equivalent to d…
Sep 18, 2026
fc2318e
[MOL-22453][SX] yup matches: add comment explaining non-string guard
Sep 18, 2026
6ba9491
[MOL-22453][SX] RegexHelper: rename parseMatchesPattern to compile
Sep 18, 2026
50b78d8
[MOL-22453][SX] RegexHelper: rename MAX_SAFE_PATTERN_INPUT_LENGTH to …
Sep 18, 2026
ba8a2fa
[MOL-22453][SX] yup matches: also pass empty string
Sep 18, 2026
7cbf0e5
[MOL-22453][SX] LocationField: use vm.runInNewContext to prevent CI h…
Sep 18, 2026
e4329bb
[MOL-22453][SX] Stories: document url()/import stripping in locationM…
Sep 21, 2026
4ad599b
[MOL-22453][SX] yup: restore missing buildWhenDependencyMap (accident…
Sep 21, 2026
c034891
[MOL-22453][SX] Stories: fix curly quotes (U+201C/U+201D) in location…
Sep 21, 2026
2414fcc
[MOL-22453][SX] Update src/__tests__/components/fields/location-field…
shengxi-gt Sep 21, 2026
ef6c651
[MOL-22453][SX] ImageUpload: use repeat(1000) to exceed MAX_MATCHES_I…
Sep 21, 2026
bd83d8a
[MOL-22453][SX] Tests: fix catastrophic-regex test values to exceed M…
Sep 21, 2026
1d3311a
[MOL-22453][SX] Tests: fix masked-field repeat(600) to exceed MAX_MAT…
Sep 21, 2026
bd40b47
[MOL-22453][SX] Tests: fix masked-field hang tests and max-validation…
Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/trigger-gitlab-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<span onclick="window.xssFired=true">Apple label</span>', value: "Apple" }],
});

const spanElement = screen.getByText("Apple label");
expect(spanElement).not.toHaveAttribute("onclick");
});

it("should be able to render hint", () => {
renderComponent({
label: {
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/components/custom/iframe/iframe.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/components/elements/popover/popover.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<img src="x" onerror="window.xssFired=true" alt=\'broken image\'>' },
});

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" });

Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/components/elements/typography/typography.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<img src="x" onerror="window.xssFired=true" alt=\'broken image\'>',
});

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",
Expand Down
8 changes: 5 additions & 3 deletions src/__tests__/components/fields/button/button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,<script>alert(1)</script>"}
`("$scenario", ({ href }) => {
renderComponent({ overrideButton: { ...(href && { href }) } });
fireEvent.click(getField("button", COMPONENT_LABEL));
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/components/fields/image-upload/image-upload.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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(1000)}!.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();
});
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import vm from "vm";
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 <span class="keyword">Example</span> 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 <span class="keyword">(Example)</span>');
});

it("should complete within a reasonable time for any query string", () => {
const input = [buildResult("a".repeat(30) + "!")];
expect(() =>
vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 })
).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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_MATCHES_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(1000)}!`;

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_MATCHES_INPUT_LENGTH
);
});

it("should clamp an already-loaded long value at render time when maskRegex changes at runtime", () => {
const maliciousValue = `${"a".repeat(1000)}!`;
const withoutMaskRegex: IFrontendEngineData = JSON.parse(JSON.stringify(schema));
Object.assign(withoutMaskRegex, { defaultValues: { [COMPONENT_ID]: maliciousValue } });
const { rerender } = render(<FrontendEngine data={withoutMaskRegex} onSubmit={SUBMIT_FN} />);

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(<FrontendEngine data={withMaskRegex} onSubmit={SUBMIT_FN} />);
expect(Date.now() - start).toBeLessThan(1000);

expect((getMaskedField() as HTMLInputElement).value.length).toBeLessThanOrEqual(
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_MATCHES_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_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(1001); // above MAX_MATCHES_INPUT_LENGTH (1000) but within explicit max: 1100
renderComponent(
{ maskRange: null, maskRegex: "/^(hello)/g", validation: [{ max: 1100 }] },
{ 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 } });
Expand Down Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(1000)}!`;

const start = Date.now();
expect(TestHelper.getError(() => schema.validateSync(maliciousValue)).message).toBe(ERROR_MESSAGE);
expect(Date.now() - start).toBeLessThan(1000);
});
});
17 changes: 17 additions & 0 deletions src/__tests__/components/frontend-engine/yup/yup-helper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] },
Expand Down
Loading
Loading