diff --git a/.github/workflows/trigger-gitlab-pipeline.yml b/.github/workflows/trigger-gitlab-pipeline.yml index babc714f3..fd7f0d83d 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=$(echo -e "$HEAD_COMMIT_MSG" | head -n 1) PIPELINE_PROJECT_URL="github.com/$GITHUB_REPOSITORY.git" diff --git a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx index ff0a0d48b..4dba92094 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/custom/iframe/iframe.spec.tsx b/src/__tests__/components/custom/iframe/iframe.spec.tsx index b051d1acd..37dfb9f84 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/__tests__/components/elements/popover/popover.spec.tsx b/src/__tests__/components/elements/popover/popover.spec.tsx index 45ad3bb06..a653bff28 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/text/text.spec.tsx b/src/__tests__/components/elements/text/text.spec.tsx index 272f3fe7a..58467ccba 100644 --- a/src/__tests__/components/elements/text/text.spec.tsx +++ b/src/__tests__/components/elements/text/text.spec.tsx @@ -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: '\'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/__tests__/components/fields/button/button.spec.tsx b/src/__tests__/components/fields/button/button.spec.tsx index 178772c41..9cfc9f3b1 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/__tests__/components/fields/image-upload/image-upload.spec.tsx b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx index e278f2320..d2d14d512 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -35,6 +35,11 @@ const FILE_2 = new File(["file"], "test2.jpg", { }); const COMPONENT_ID = "field"; const UI_TYPE = "image-upload"; +const DELETE_PROMPT_TEXT = "Delete photo?"; +const DELETE_EXIT_PROMPT_TEXT = "Delete photo and exit?"; +const REVIEW_MODAL_TEXT = "Review photos"; +const REVIEW_PROMPT_TEXT = "Review photos?"; +const REVIEW_EXIT_PROMPT_TEXT = "Exit without saving?"; const SUBMIT_FN = jest.fn(); let uploadSpy: jest.SpyInstance; let extractMetadataSpy: jest.SpyInstance; @@ -43,6 +48,8 @@ const getSaveButton = (isQuery = false): HTMLElement => getField("button", "Save const getDragInputUploadField = (): HTMLElement => screen.getByTestId("field-drag-upload__hidden-input"); const getReviewModalUploadField = (): HTMLElement => screen.getByTestId("field-image-thumbnails__file-input"); +const waitForUpload = async () => await new Promise((resolve) => setTimeout(resolve, 100)); + interface ICustomFrontendEngineProps extends IFrontendEngineProps { eventType: string; eventListener: (this: Element, ev: Event) => any; @@ -143,7 +150,7 @@ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => }, }); if (uploadType === "input") { - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForUpload(); await flushPromise(); } else { await flushPromise(); @@ -152,8 +159,9 @@ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => }); if (reviewImage) { - await waitFor(() => fireEvent.click(getField("button", "Ok"))); - await new Promise((resolve) => setTimeout(resolve)); + await waitFor(() => expect(screen.getByText(REVIEW_PROMPT_TEXT)).toBeVisible()); + fireEvent.click(getField("button", "Ok")); + await flushPromise(); } }; @@ -341,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], @@ -372,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(); + }); }); }); @@ -568,19 +607,21 @@ describe("image-upload", () => { files: [FILE_1], uploadType: inputType, }); - await flushPromise(); + await act(async () => { + await flushPromise(); + }); expect(compressSpy).not.toHaveBeenCalled(); }); it("should compress image if compress=true and max size is defined", async () => { const compressSpy = jest.spyOn(ImageHelper, "compressImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, - uploadType: inputType, - }); await flushPromise(); }); @@ -590,12 +631,13 @@ describe("image-upload", () => { it("Should extract image metadata", async () => { jest.spyOn(ImageHelper, "compressImage").mockResolvedValue(FILE_1); - await waitFor(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, - uploadType: inputType, - }); + await renderComponent({ + files: [FILE_1], + overrideField: { compress: true, validation: [{ maxSizeInKb: 1 }] }, + uploadType: inputType, + }); + await act(async () => { + await flushPromise(); }); await waitFor(() => expect(extractMetadataSpy).toHaveBeenCalledTimes(1)); @@ -604,16 +646,16 @@ describe("image-upload", () => { it("should resize image to fit dimensions when crop is false", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: true, + crop: false, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: true, - crop: false, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -623,16 +665,16 @@ describe("image-upload", () => { it("should crop image to exact dimensions when crop is true", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: true, + crop: true, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: true, - crop: true, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -647,16 +689,16 @@ describe("image-upload", () => { it("should not use crop when compress is false even if crop is true", async () => { const resampleSpy = jest.spyOn(ImageHelper, "resampleImage"); const convertSpy = jest.spyOn(ImageHelper, "convertBlob"); + await renderComponent({ + files: [FILE_1], + overrideField: { + compress: false, + crop: true, + dimensions: { width: 500, height: 500 }, + }, + uploadType: inputType, + }); await act(async () => { - await renderComponent({ - files: [FILE_1], - overrideField: { - compress: false, - crop: true, - dimensions: { width: 500, height: 500 }, - }, - uploadType: inputType, - }); await flushPromise(); }); @@ -674,6 +716,7 @@ describe("image-upload", () => { files: [FILE_1], overrideField: { editImage: true }, }); + await waitFor(() => expect(screen.getByText(REVIEW_PROMPT_TEXT)).toBeVisible()); }); it("should not upload photo", () => { @@ -681,9 +724,7 @@ describe("image-upload", () => { }); it("should show confirmation prompt", async () => { - await waitFor(() => { - expect(screen.getByText("Review photos?")).toBeVisible(); - }); + expect(await screen.findByText(REVIEW_PROMPT_TEXT)).toBeVisible(); }); it("should show review modal after clicking ok in confirmation prompt", async () => { @@ -693,9 +734,7 @@ describe("image-upload", () => { fireEvent.click(getField("button", "Ok")); - await waitFor(() => { - expect(screen.getByText("Review photos")).toBeVisible(); - }); + expect(await screen.findByText(REVIEW_MODAL_TEXT)).toBeVisible(); }); }); @@ -714,10 +753,10 @@ describe("image-upload", () => { }); it("should skip confirmation prompt and show review modal", async () => { - expect(screen.getByText("Review photos?")).not.toBeVisible(); await waitFor(() => { - expect(screen.getByText("Review photos")).toBeVisible(); + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); }); + expect(screen.queryByText(REVIEW_PROMPT_TEXT)).not.toBeVisible(); }); }); }); @@ -736,8 +775,10 @@ describe("image-upload", () => { expect(uploadSpy).not.toHaveBeenCalled(); }); - it("should show as many images", () => { - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + it("should show as many images", async () => { + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + }); expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); }); @@ -746,8 +787,13 @@ describe("image-upload", () => { }); it("should upload as many images after clicking save", async () => { - await waitFor(() => fireEvent.click(getSaveButton())); - await flushPromise(); + await waitFor(() => { + expect(getSaveButton()).toBeEnabled(); + }); + await act(async () => { + fireEvent.click(getSaveButton()); + await flushPromise(); + }); expect(uploadSpy).toHaveBeenCalledTimes(2); }); @@ -766,14 +812,18 @@ describe("image-upload", () => { }); jest.spyOn(FileHelper, "getType").mockResolvedValueOnce({ ext: "png", mime: "image/png" }); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); - expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); + }); expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.FILE_TYPE.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); }); @@ -790,13 +840,15 @@ describe("image-upload", () => { }); jest.spyOn(ImageHelper, "convertBlob").mockRejectedValue("error"); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.GENERIC_ERROR.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); }); @@ -811,9 +863,9 @@ describe("image-upload", () => { }); jest.spyOn(ImageHelper, "convertBlob").mockResolvedValue(`${JPG_BASE64}${JPG_BASE64}`); - await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } })); + fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1] } }); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitForUpload(); }); }); @@ -823,7 +875,9 @@ describe("image-upload", () => { it("should show error and disable submit button if image exceeds max size", async () => { expect(screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MODAL.MAX_FILE_SIZE.TITLE)).toBeInTheDocument(); - expect(getSaveButton()).toBeDisabled(); + await waitFor(() => { + expect(getSaveButton()).toBeDisabled(); + }); }); it("Should extract image metadata", async () => { @@ -839,7 +893,8 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Draw"))); + const drawButton = await screen.findByRole("button", { name: "Draw" }); + fireEvent.click(drawButton); }); it("should hide the thumbnails and show the drawing toolbar", () => { @@ -857,14 +912,13 @@ describe("image-upload", () => { jest.spyOn(ImageHelper, "dataUrlToImage").mockResolvedValue(new Image()); jest.spyOn(ImageHelper, "resampleImage").mockResolvedValue(FILE_1); - await act(async () => { - await waitFor(() => fireEvent.click(getField("button", "Save"))); - await waitFor(() => getField("button", `thumbnail of ${FILE_1.name}`)); - }); + fireEvent.click(getField("button", "Save")); - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); - expect(getField("button", "eraser", true)).not.toBeInTheDocument(); - expect(getField("button", /brush$/i, true)).not.toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + expect(getField("button", "eraser", true)).not.toBeInTheDocument(); + expect(getField("button", /brush$/i, true)).not.toBeInTheDocument(); + }); }); }); @@ -886,10 +940,14 @@ describe("image-upload", () => { reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Draw"))); + await waitFor(() => { + expect(getField("button", "Draw")).toBeInTheDocument(); + }); + expect(getField("button", "Save")).toBeInTheDocument(); await act(async () => { - await waitFor(() => fireEvent.click(getField("button", "Save"))); + fireEvent.click(getField("button", "Draw")); + fireEvent.click(getField("button", "Save")); await flushPromise(); }); @@ -911,29 +969,36 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Delete"))); + await waitFor(() => { + fireEvent.click(getField("button", "Delete")); + }); + await waitFor(() => { + expect(screen.getByText(DELETE_PROMPT_TEXT)).toBeVisible(); + }); }); it("should show delete confirmation prompt on clicking the delete button", async () => { - await waitFor(() => { - expect(screen.getByText("Delete photo?")).toBeVisible(); - }); + expect(await screen.findByText(DELETE_PROMPT_TEXT)).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); expect(getField("button", "Yes, delete")).toBeVisible(); }); it("should delete the image and hide the prompt on confirming delete", async () => { - await waitFor(() => fireEvent.click(getField("button", "Yes, delete"))); + fireEvent.click(getField("button", "Yes, delete")); - expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(2); - expect(screen.getByText("Delete photo?")).not.toBeVisible(); + await waitFor(() => expect(screen.getByText(DELETE_PROMPT_TEXT)).not.toBeVisible()); + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(2); + }); }); it("should not delete the image but dismiss the prompt on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(screen.getByText("Delete photo?")).not.toBeVisible(); - expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(3); + await waitFor(() => expect(screen.getByText(DELETE_PROMPT_TEXT)).not.toBeVisible()); + await waitFor(() => { + expect(screen.getAllByRole("button", { name: /^thumbnail/i })).toHaveLength(3); + }); }); }); @@ -944,31 +1009,36 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "Delete"))); + await waitFor(() => { + fireEvent.click(getField("button", "Delete")); + }); + await waitFor(() => expect(screen.getByText(DELETE_EXIT_PROMPT_TEXT)).toBeVisible()); }); it("should show delete and exit confirmation prompt on attempting to delete the last photo", async () => { - await waitFor(() => { - expect(screen.getByText("Delete photo and exit?")).toBeVisible(); - }); + expect(await screen.findByText(DELETE_EXIT_PROMPT_TEXT)).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); expect(getField("button", "Delete and exit")).toBeVisible(); }); it("should delete the image and close the review modal on deleting the last image", async () => { - await waitFor(() => fireEvent.click(getField("button", "Delete and exit"))); + fireEvent.click(getField("button", "Delete and exit")); - expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); - expect(screen.queryByText("Delete photo and exit?")).not.toBeInTheDocument(); - expect(screen.queryByText("Review photos")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(DELETE_EXIT_PROMPT_TEXT)).not.toBeInTheDocument()); + expect(screen.queryByText(REVIEW_MODAL_TEXT)).not.toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); + }); }); it("should not delete the image and return to the review modal on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); - expect(screen.getByText("Delete photo and exit?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(DELETE_EXIT_PROMPT_TEXT)).not.toBeVisible()); + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeInTheDocument(); + await waitFor(() => { + expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); + }); }); }); }); @@ -980,31 +1050,39 @@ describe("image-upload", () => { overrideField: { editImage: true }, reviewImage: true, }); - await waitFor(() => fireEvent.click(getField("button", "exit review modal"))); - }); + await waitFor(() => { + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); + }); - it("should show confirmation prompt", async () => { await waitFor(() => { - expect(screen.getByText("Exit without saving?")).toBeVisible(); + fireEvent.click(getField("button", "exit review modal")); }); + + await waitFor(() => expect(screen.getByText(REVIEW_EXIT_PROMPT_TEXT)).toBeVisible()); + }); + + it("should show confirmation prompt", async () => { expect(screen.getByText("Yes, exit")).toBeVisible(); expect(getField("button", "Cancel")).toBeVisible(); }); it("should close review modal on confirmation", async () => { - await waitFor(() => fireEvent.click(getField("button", "Yes, exit"))); + fireEvent.click(getField("button", "Yes, exit")); - expect(screen.queryByText("Exit without saving?")).not.toBeInTheDocument(); - expect(screen.queryByText("Review photos")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(REVIEW_EXIT_PROMPT_TEXT)).not.toBeInTheDocument()); + expect(screen.queryByText(REVIEW_MODAL_TEXT)).not.toBeInTheDocument(); expect(getField("button", /^thumbnail/i, true)).not.toBeInTheDocument(); }); it("should not close review modal on cancelling the confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Cancel"))); + fireEvent.click(getField("button", "Cancel")); - expect(screen.getByText("Exit without saving?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeInTheDocument(); - expect(getField("button", /^thumbnail/i)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText(REVIEW_EXIT_PROMPT_TEXT)).not.toBeVisible()); + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeInTheDocument(); + + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + }); }); }); }); @@ -1141,7 +1219,8 @@ describe("image-upload", () => { onClick: handleClick, }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); expect(handleDismissReviewModal).toHaveBeenCalled(); }); @@ -1160,7 +1239,8 @@ describe("image-upload", () => { onClick: handleClick, }); - await waitFor(() => fireEvent.click(screen.getByRole("button", { name: "Custom Button" }))); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); expect(saveReviewImageFn).toHaveBeenCalled(); }); @@ -1182,7 +1262,8 @@ describe("image-upload", () => { onClick: handleClick, }); - fireEvent.click(screen.getByRole("button", { name: "Custom Button" })); + const customButton = await screen.findByRole("button", { name: "Custom Button" }); + fireEvent.click(customButton); const errMsg = screen.getAllByTestId("field-file-item-1__error-text")[0].innerHTML; expect(errMsg).toBe(ERROR_MESSAGE); expect(screen.getAllByTestId("field-file-item-1__error-text")[0]).toBeInTheDocument(); @@ -1395,12 +1476,11 @@ describe("image-upload", () => { await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1, FILE_2] } }) ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitFor(() => { + expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); + expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); + expect(getField("button", `thumbnail of test (1).jpg`)).toBeInTheDocument(); }); - expect(getField("button", `thumbnail of ${FILE_1.name}`)).toBeInTheDocument(); - expect(getField("button", `thumbnail of ${FILE_2.name}`)).toBeInTheDocument(); - expect(getField("button", `thumbnail of test (1).jpg`)).toBeInTheDocument(); }); it("should show exceed error when add over the max number", async () => { @@ -1412,10 +1492,9 @@ describe("image-upload", () => { await waitFor(() => fireEvent.change(getReviewModalUploadField(), { target: { files: [FILE_1, FILE_2] } }) ); - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); //add time-out due the the behavior change in the drag-upload + await waitFor(() => { + expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); }); - expect(getField("button", `error with ${FILE_1.name}`)).toBeInTheDocument(); expect( screen.getByText(ERROR_MESSAGES.UPLOAD("photo").MAX_FILES_WITH_REMAINING(1)) ).toBeInTheDocument(); 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..2e701d1d1 --- /dev/null +++ b/src/__tests__/components/fields/location-field/location-modal/location-search/helper.spec.ts @@ -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 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 input = [buildResult("a".repeat(30) + "!")]; + expect(() => + vm.runInNewContext("fn(input, query)", { fn: boldResultsWithQuery, input, query: "(a+)+$" }, { timeout: 1000 }) + ).not.toThrow(); + }); +}); 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..ceb621483 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_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(); + + 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_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 } }); @@ -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..ec755c8ce 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(1000)}!`; + + 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..6cb72bea1 --- /dev/null +++ b/src/__tests__/utils/regex-helper.spec.ts @@ -0,0 +1,47 @@ +import { RegexHelper } from "../../utils"; + +describe("regex-helper", () => { + describe("compile", () => { + it("should parse a /pattern/flags string into a RegExp", () => { + const regex = RegexHelper.compile("/^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.compile("hello"); + + expect(regex).toBeInstanceOf(RegExp); + expect(regex.source).toBe("hello"); + }); + + it("should return undefined instead of throwing on an invalid pattern", () => { + expect(RegexHelper.compile("/[/")).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_MATCHES_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", () => { + expect(RegexHelper.safeTestRegex(/^[0-9]+$/, "abc")).toBe(false); + }); + }); +}); 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/custom/filter/filter-checkbox/filter-checkbox.tsx b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx index 8ea642ba9..c56aa9972 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -89,7 +89,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps (isParentOption(item) ? item.key : item.value)} labelExtractor={(item) => ( - + {item.label} )} diff --git a/src/components/custom/iframe/iframe.tsx b/src/components/custom/iframe/iframe.tsx index 4315aa973..83e8b7576 100644 --- a/src/components/custom/iframe/iframe.tsx +++ b/src/components/custom/iframe/iframe.tsx @@ -41,8 +41,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; @@ -120,11 +123,14 @@ export const Iframe = (props: IGenericCustomFieldProps) => { // ========================================================================= // POSTMESSAGE HANDLERS // ========================================================================= + 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 }>( @@ -134,7 +140,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { width: e.data.payload?.width, height: e.data.payload?.height, }); - }, []) + }, []), + allowedOrigin ); useIframeMessage( @@ -144,7 +151,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { formContext.setValue(id, e.data.payload, { shouldDirty: true }); }, [formContext, id] - ) + ), + allowedOrigin ); useIframeMessage( @@ -159,14 +167,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/components/elements/popover/popover.tsx b/src/components/elements/popover/popover.tsx index 8d051ec7b..84e9915e1 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/text/text.tsx b/src/components/elements/text/text.tsx index b1fb1f7a5..f41c69030 100644 --- a/src/components/elements/text/text.tsx +++ b/src/components/elements/text/text.tsx @@ -71,7 +71,10 @@ export const Text = (props: IGenericElementProps { diff --git a/src/components/fields/button/button.tsx b/src/components/fields/button/button.tsx index 26333e77d..5a3e94bd1 100644 --- a/src/components/fields/button/button.tsx +++ b/src/components/fields/button/button.tsx @@ -7,6 +7,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 @@ -30,7 +32,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; } 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..53b0a8e90 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.compile(matches); const convertImage = async (index: number, image: IImage) => { try { diff --git a/src/components/fields/image-upload/image-review/image-review.styles.ts b/src/components/fields/image-upload/image-review/image-review.styles.ts index 1ed8f41d1..be0a4b142 100644 --- a/src/components/fields/image-upload/image-review/image-review.styles.ts +++ b/src/components/fields/image-upload/image-review/image-review.styles.ts @@ -8,6 +8,7 @@ import { EraserIcon } from "@lifesg/react-icons/eraser"; import { PencilIcon } from "@lifesg/react-icons/pencil"; import { PencilStrokeIcon } from "@lifesg/react-icons/pencil-stroke"; import styled, { css } from "styled-components"; +import { StyleHelper } from "../../../../utils"; interface IModalBoxStyle { imageReviewModalStyles?: string | undefined; @@ -18,7 +19,7 @@ export const ModalBox = styled(Modal.Box)` max-height: fit-content; ${({ imageReviewModalStyles }) => { - if (imageReviewModalStyles) return `${imageReviewModalStyles}`; + if (imageReviewModalStyles) return StyleHelper.sanitizeStyleString(imageReviewModalStyles); }} ${MediaQuery.MinWidth.xl} { 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]); diff --git a/src/components/fields/location-field/location-modal/location-modal.styles.ts b/src/components/fields/location-field/location-modal/location-modal.styles.ts index ebb1c8a25..7b37b44a4 100644 --- a/src/components/fields/location-field/location-modal/location-modal.styles.ts +++ b/src/components/fields/location-field/location-modal/location-modal.styles.ts @@ -1,5 +1,6 @@ import { Modal } from "@lifesg/react-design-system/modal"; import styled from "styled-components"; +import { StyleHelper } from "../../../../utils"; import { TPanelInputMode } from "../types"; import { LocationPicker } from "./location-picker"; import { Breakpoint, MediaQuery, Spacing } from "@lifesg/react-design-system/theme"; @@ -22,7 +23,7 @@ export const ModalBox = styled(Modal.Box)` z-index: 1; ${({ locationModalStyles }) => { - if (locationModalStyles) return `${locationModalStyles}`; + if (locationModalStyles) return StyleHelper.sanitizeStyleString(locationModalStyles); }} ${MediaQuery.MaxWidth.lg} { 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, diff --git a/src/components/fields/masked-field/masked-field.tsx b/src/components/fields/masked-field/masked-field.tsx index df0a6e259..c122fd1c4 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_MATCHES_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_MATCHES_INPUT_LENGTH, + ERROR_MESSAGES.MASKED_FIELD.VALUE_TOO_LONG(RegexHelper.MAX_MATCHES_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_MATCHES_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.compile(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..d78185aad 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.compile(regex); + if (!parsedRegex) { + console.warn(`invalid regex pattern: ${regex}`); + return true; + } + if (value.length > RegexHelper.MAX_MATCHES_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 90f147efd..aa2e06ff0 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, @@ -187,13 +188,20 @@ 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) { + // "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; + } + const regex = RegexHelper.compile(rule.matches); + if (regex) { + yupSchema = (yupSchema as Yup.StringSchema).test({ + name: "matches", + message: rule.errorMessage, + test: (value) => + value === undefined || value === null || value === "" || RegexHelper.safeTestRegex(regex, value), + }); + } else { console.warn(`error applying "${ruleKey}" condition to ${yupSchema.type} schema`); } } 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..f0bf0c28a 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -186,6 +186,17 @@ 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.", 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]); }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 89bd1b231..bcc85bbcd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -9,3 +9,5 @@ export * from "./object-helper"; export * from "./test-helper"; export * from "./types"; export * from "./prop-helper"; +export * from "./regex-helper"; +export * from "./style-helper"; diff --git a/src/utils/regex-helper.ts b/src/utils/regex-helper.ts new file mode 100644 index 000000000..6431582cb --- /dev/null +++ b/src/utils/regex-helper.ts @@ -0,0 +1,18 @@ +export namespace RegexHelper { + export const MAX_MATCHES_INPUT_LENGTH = 1000; + + 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); + } catch { + return undefined; + } + }; + + export const safeTestRegex = (regex: RegExp | undefined, value: string): boolean => { + if (!regex) return false; + if (value.length > MAX_MATCHES_INPUT_LENGTH) return false; + return regex.test(value); + }; +} 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, ""); +}