Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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=$(echo -e "$HEAD_COMMIT_MSG" | head -n 1)

PIPELINE_PROJECT_URL="github.com/$GITHUB_REPOSITORY.git"

Expand Down
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/text/text.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,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 @@ -341,6 +341,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 @@ -372,6 +388,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();
});
});
});

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(25) + "!")];
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(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_MATCHES_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(<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(RegexHelper.MAX_MATCHES_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 } });
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(600)}!`;

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