diff --git a/.github/workflows/trigger-gitlab-pipeline.yml b/.github/workflows/trigger-gitlab-pipeline.yml index c3be0f3c6..64c151dad 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 0c3a68663..4bfeffc85 100644 --- a/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx +++ b/src/__tests__/components/custom/filter/filter-checkbox.spec.tsx @@ -84,6 +84,15 @@ describe(REFERENCE_KEY, () => { expect(SUBMIT_FN).toBeCalledWith(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 beb1c1237..e318cf281 100644 --- a/src/__tests__/components/custom/iframe/iframe.spec.tsx +++ b/src/__tests__/components/custom/iframe/iframe.spec.tsx @@ -120,6 +120,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/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 354b2b5ae..34ecbdd6a 100644 --- a/src/__tests__/components/fields/image-upload/image-upload.spec.tsx +++ b/src/__tests__/components/fields/image-upload/image-upload.spec.tsx @@ -33,6 +33,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; @@ -41,6 +46,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; @@ -141,7 +148,7 @@ const renderComponent = async (options: IRenderAndPerformActionsOptions = {}) => }, }); if (uploadType === "input") { - await new Promise((resolve) => setTimeout(resolve, 100)); + await waitForUpload(); await flushPromise(); } else { await flushPromise(); @@ -150,8 +157,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(); } }; @@ -287,7 +295,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSubmitButton())); - expect(SUBMIT_FN).not.toBeCalled(); + expect(SUBMIT_FN).not.toHaveBeenCalled(); expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); }); @@ -325,7 +333,7 @@ describe("image-upload", () => { }); await waitFor(() => expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument()); - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should not show error when filename matches the pattern", async () => { @@ -336,7 +344,23 @@ describe("image-upload", () => { }); expect(screen.queryByText(ERROR_MESSAGE)).not.toBeInTheDocument(); - await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + 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 () => { @@ -362,7 +386,7 @@ describe("image-upload", () => { uploadType: "input", }); - await waitFor(() => expect(uploadSpy).toBeCalledTimes(1)); + await waitFor(() => expect(uploadSpy).toHaveBeenCalledTimes(1)); await waitFor(() => fireEvent.click(getSubmitButton())); expect(SUBMIT_FN).toHaveBeenCalledWith( expect.objectContaining({ @@ -370,6 +394,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(); + }); }); }); @@ -390,7 +429,7 @@ describe("image-upload", () => { it("should show and upload as many images", async () => { expect(screen.getByText(FILE_1.name)).toBeInTheDocument(); expect(screen.getByText(FILE_2.name)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(2); + expect(uploadSpy).toHaveBeenCalledTimes(2); }); it("should hide the add button", () => { @@ -429,7 +468,7 @@ describe("image-upload", () => { it("should show and upload up to max number of images", async () => { expect(screen.getByText(FILE_1.name)).toBeInTheDocument(); expect(screen.queryByText(FILE_2.name)).not.toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should display error message when adding beyond max no. of images", () => { @@ -471,7 +510,7 @@ describe("image-upload", () => { it("should not upload the invalid file and show an error message", () => { expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -500,7 +539,7 @@ describe("image-upload", () => { it("should not upload the erroneous file and show an error message", async () => { expect(screen.getByText(ERROR_MESSAGES.UPLOAD().GENERIC)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -535,7 +574,7 @@ describe("image-upload", () => { it("should show error and not upload the image that exceeds the file size limit", async () => { expect(screen.getByText(ERROR_MESSAGE)).toBeInTheDocument(); - expect(uploadSpy).toBeCalledTimes(1); + expect(uploadSpy).toHaveBeenCalledTimes(1); }); it("should submit only the valid files", async () => { @@ -566,34 +605,37 @@ describe("image-upload", () => { files: [FILE_1], uploadType: inputType, }); - await flushPromise(); + await act(async () => { + await flushPromise(); + }); - expect(compressSpy).not.toBeCalled(); + 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(); }); - expect(compressSpy).toBeCalled(); + expect(compressSpy).toHaveBeenCalled(); }); 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)); @@ -602,16 +644,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(); }); @@ -621,16 +663,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(); }); @@ -645,16 +687,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(); }); @@ -672,20 +714,25 @@ describe("image-upload", () => { files: [FILE_1], overrideField: { editImage: true }, }); + await waitFor(() => expect(screen.getByText(REVIEW_PROMPT_TEXT)).toBeVisible()); }); it("should not upload photo", () => { - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); - it("should show confirmation prompt", () => { - expect(screen.getByText("Review photos?")).toBeVisible(); + it("should show confirmation prompt", async () => { + expect(await screen.findByText(REVIEW_PROMPT_TEXT)).toBeVisible(); }); it("should show review modal after clicking ok in confirmation prompt", async () => { - await waitFor(() => fireEvent.click(getField("button", "Ok"))); + await waitFor(() => { + expect(getField("button", "Ok")).toBeVisible(); + }); + + fireEvent.click(getField("button", "Ok")); - expect(screen.getByText("Review photos")).toBeVisible(); + expect(await screen.findByText(REVIEW_MODAL_TEXT)).toBeVisible(); }); }); @@ -700,12 +747,14 @@ describe("image-upload", () => { }); it("should not upload photo", () => { - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should skip confirmation prompt and show review modal", async () => { - expect(screen.getByText("Review photos?")).not.toBeVisible(); - expect(screen.getByText("Review photos")).toBeVisible(); + await waitFor(() => { + expect(screen.getByText(REVIEW_MODAL_TEXT)).toBeVisible(); + }); + expect(screen.queryByText(REVIEW_PROMPT_TEXT)).not.toBeVisible(); }); }); }); @@ -721,11 +770,13 @@ describe("image-upload", () => { }); it("should not upload images", () => { - expect(uploadSpy).not.toBeCalled(); + 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(); }); @@ -734,10 +785,15 @@ 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).toBeCalledTimes(2); + expect(uploadSpy).toHaveBeenCalledTimes(2); }); }); @@ -754,14 +810,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(); + }); }); }); @@ -778,13 +838,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(); + }); }); }); @@ -799,19 +861,21 @@ 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(); }); }); it("should not compress the image", async () => { - expect(compressSpy).not.toBeCalled(); + expect(compressSpy).not.toHaveBeenCalled(); }); 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 () => { @@ -827,7 +891,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", () => { @@ -845,14 +910,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(); + }); }); }); @@ -874,10 +938,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(); }); @@ -899,27 +967,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", () => { - expect(screen.getByText("Delete photo?")).toBeVisible(); + it("should show delete confirmation prompt on clicking the delete button", async () => { + 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); + }); }); }); @@ -930,29 +1007,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", () => { - expect(screen.getByText("Delete photo and exit?")).toBeVisible(); + it("should show delete and exit confirmation prompt on attempting to delete the last photo", async () => { + 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(); + }); }); }); }); @@ -964,29 +1048,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(); + }); + + await waitFor(() => { + fireEvent.click(getField("button", "exit review modal")); + }); + + await waitFor(() => expect(screen.getByText(REVIEW_EXIT_PROMPT_TEXT)).toBeVisible()); }); - it("should show confirmation prompt", () => { - expect(screen.getByText("Exit without saving?")).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(); + }); }); }); }); @@ -995,7 +1089,7 @@ describe("image-upload", () => { it("should fire mount event on mount", async () => { const handleMount = jest.fn(); await renderComponent({ eventType: "mount", eventListener: handleMount }); - expect(handleMount).toBeCalled(); + expect(handleMount).toHaveBeenCalled(); }); it("should fire show-review-modal event on showing review modal", async () => { @@ -1008,7 +1102,7 @@ describe("image-upload", () => { reviewImage: true, }); - expect(handleShowReviewModal).toBeCalled(); + expect(handleShowReviewModal).toHaveBeenCalled(); }); it("should fire hide-review-modal event on hiding review modal", async () => { @@ -1022,7 +1116,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleHideReviewModal).toBeCalled(); + expect(handleHideReviewModal).toHaveBeenCalled(); }); it("should fire file-dialog event on showing file-dialog", async () => { @@ -1035,7 +1129,7 @@ describe("image-upload", () => { await waitFor(() => fireEvent.click(getField("button", "Image Upload"))); }); - expect(handleFileDialog).toBeCalled(); + expect(handleFileDialog).toHaveBeenCalled(); }); it("should fire save-review-images event on clicking save button in review modal", async () => { @@ -1049,7 +1143,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleSaveImages).toBeCalled(); + expect(handleSaveImages).toHaveBeenCalled(); }); it("should not save images / close modal if save-review-images event is prevented", async () => { @@ -1066,7 +1160,7 @@ describe("image-upload", () => { await waitFor(() => fireEvent.click(getSaveButton())); expect(getSaveButton()).toBeInTheDocument(); - expect(uploadSpy).not.toBeCalled(); + expect(uploadSpy).not.toHaveBeenCalled(); }); it("should allow retry through save-review-images event detail", async () => { @@ -1088,9 +1182,9 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(mockCounter.value).toBeCalledTimes(2); + expect(mockCounter.value).toHaveBeenCalledTimes(2); expect(getSaveButton(true)).not.toBeInTheDocument(); - expect(uploadSpy).toBeCalled(); + expect(uploadSpy).toHaveBeenCalled(); }); it("should fire hide-review-modal event on hiding review modal", async () => { @@ -1104,7 +1198,7 @@ describe("image-upload", () => { }); await waitFor(() => fireEvent.click(getSaveButton())); - expect(handleHideReviewModal).toBeCalled(); + expect(handleHideReviewModal).toHaveBeenCalled(); }); it("should allow dismissing of the review modal via dismiss-review-modal event", async () => { @@ -1123,9 +1217,10 @@ 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).toBeCalled(); + expect(handleDismissReviewModal).toHaveBeenCalled(); }); it("should be able to save review images via trigger-save-review-images event", async () => { @@ -1142,9 +1237,10 @@ 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).toBeCalled(); + expect(saveReviewImageFn).toHaveBeenCalled(); }); it("should be able to show custom error message when update-image-status is fired", async () => { @@ -1164,7 +1260,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(); @@ -1482,12 +1579,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 () => { @@ -1499,10 +1595,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 ef100fc61..94d9b5c09 100644 --- a/src/__tests__/components/fields/masked-field/masked-field.spec.tsx +++ b/src/__tests__/components/fields/masked-field/masked-field.spec.tsx @@ -3,7 +3,9 @@ import cloneDeep from "lodash/cloneDeep"; import merge from "lodash/merge"; import { FrontendEngine } from "../../../../components"; import { IMaskedFieldSchema } from "../../../../components/fields"; +import { ERROR_MESSAGES } from "../../../../components/shared"; import { IFrontendEngineData, IFrontendEngineRef } from "../../../../components/types"; +import { RegexHelper } from "../../../../utils"; import { ERROR_MESSAGE, FRONTEND_ENGINE_ID, @@ -90,6 +92,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(JSON_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 } }); @@ -125,6 +205,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 cee24272d..f21738f88 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 a542314eb..a1630e929 100644 --- a/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx +++ b/src/components/custom/filter/filter-checkbox/filter-checkbox.tsx @@ -61,7 +61,7 @@ export const FilterCheckbox = (props: IGenericCustomFieldProps ( - + {item.label} )} diff --git a/src/components/custom/iframe/iframe.tsx b/src/components/custom/iframe/iframe.tsx index cb5c0a36a..c9a3c96c9 100644 --- a/src/components/custom/iframe/iframe.tsx +++ b/src/components/custom/iframe/iframe.tsx @@ -42,8 +42,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; @@ -121,11 +124,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 }>( @@ -135,7 +141,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { width: e.data.payload?.width, height: e.data.payload?.height, }); - }, []) + }, []), + allowedOrigin ); useIframeMessage( @@ -145,7 +152,8 @@ export const Iframe = (props: IGenericCustomFieldProps) => { formContext.setValue(id, e.data.payload, { shouldDirty: true }); }, [formContext, id] - ) + ), + allowedOrigin ); useIframeMessage( @@ -160,14 +168,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/text/text.tsx b/src/components/elements/text/text.tsx index acb6fe34c..f6089e32b 100644 --- a/src/components/elements/text/text.tsx +++ b/src/components/elements/text/text.tsx @@ -61,7 +61,10 @@ export const Text = (props: IGenericElementProps) => { // ============================================================================= const sanitizeOptions: IOptions = { allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]), - allowedAttributes: false, + allowedAttributes: { + ...sanitizeHtml.defaults.allowedAttributes, + img: ["src", "alt", "width", "height"], + }, }; const renderText = (): JSX.Element[] | JSX.Element | string[] | string => { diff --git a/src/components/fields/button/button.tsx b/src/components/fields/button/button.tsx index 8dd1aec64..ed0644823 100644 --- a/src/components/fields/button/button.tsx +++ b/src/components/fields/button/button.tsx @@ -5,6 +5,8 @@ import { IGenericFieldProps } from ".."; import { IButtonSchema, TLinkTarget } from "./types"; import { useFieldEvent } from "../../../utils/hooks"; +const ALLOWED_URL_SCHEMES = ["http:", "https:", "mailto:", "tel:"]; + export const ButtonField = (props: IGenericFieldProps) => { // ============================================================================= // CONST, STATE, REF @@ -37,7 +39,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 333a12a8e..804b35ff0 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 @@ -9,6 +9,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; @@ -19,7 +20,7 @@ export const ModalBox = styled(Modal.Box)` max-height: fit-content; ${({ imageReviewModalStyles }) => { - if (imageReviewModalStyles) return `${imageReviewModalStyles}`; + if (imageReviewModalStyles) return StyleHelper.sanitizeStyleString(imageReviewModalStyles); }} ${MediaQuery.MinWidth.tablet} { diff --git a/src/components/fields/image-upload/image-upload.tsx b/src/components/fields/image-upload/image-upload.tsx index 844987b7b..93d3ccfb5 100644 --- a/src/components/fields/image-upload/image-upload.tsx +++ b/src/components/fields/image-upload/image-upload.tsx @@ -154,7 +154,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 3425c08cc..2fb89897f 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,6 +1,7 @@ import { MediaQuery, MediaWidths } from "@lifesg/react-design-system/media"; 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"; @@ -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.tablet} { 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 22112ce33..3bf82182d 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) => { ...otherProps } = 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 | number | undefined): string => { + const stringVal = val !== undefined && val !== null ? `${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; }; // ============================================================================= @@ -89,11 +114,12 @@ export const MaskedField = (props: IGenericFieldProps) => { {...otherSchema} {...otherProps} {...derivedAttributes} + key={maskRegex ?? "no-mask-regex"} id={id} data-testid={TestHelper.generateId(id, uiType)} label={formattedLabel} onChange={handleChange} - value={stateValue} + value={displayedValue} errorMessage={error?.message} maskRegex={getRegex()} iconMask={renderIcon(iconMask)} diff --git a/src/components/shared/error-messages.tsx b/src/components/shared/error-messages.tsx index b83852d6e..347548d5e 100644 --- a/src/components/shared/error-messages.tsx +++ b/src/components/shared/error-messages.tsx @@ -116,6 +116,9 @@ export const ERROR_MESSAGES = { LOCATION: { MUST_HAVE_POSTAL_CODE: "Selected location must have postal code.", }, + MASKED_FIELD: { + VALUE_TOO_LONG: (maxLength: number) => `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 550296f5f..2c7bc0ff2 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 3ba2ad1ba..385e6d0ef 100644 --- a/src/stories/3-fields/image-upload/image-upload.stories.tsx +++ b/src/stories/3-fields/image-upload/image-upload.stories.tsx @@ -1,6 +1,6 @@ -import { action } from "@storybook/addon-actions"; -import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; -import { Meta, StoryFn } from "@storybook/react"; +import { action } from "storybook/actions"; +import { ArgTypes, Stories, Title } from "@storybook/addon-docs/blocks"; +import { Meta, StoryFn } from "@storybook/react-webpack5"; import { useEffect, useRef } from "react"; import { IImageUploadSchema } from "../../../components/fields"; import { IFrontendEngineRef } from "../../../components/frontend-engine"; @@ -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; @@ -397,6 +408,7 @@ export const WithTooltip: StoryFn = (args: IImageUploadSchem const formRef = useRef(); const handleTooltipClick = (e: unknown) => action("click-tooltip")(e); useEffect(() => { + if (!formRef.current) return; const currentFormRef = formRef.current; currentFormRef.addFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); return () => currentFormRef.removeFieldEventListener("image-upload", "click-tooltip", id, handleTooltipClick); 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 a9ad472fd..f0bf0c28a 100644 --- a/src/stories/3-fields/location-field/location-field.stories.tsx +++ b/src/stories/3-fields/location-field/location-field.stories.tsx @@ -1,5 +1,5 @@ -import { ArgTypes, Stories, Title } from "@storybook/addon-docs"; -import { Meta, StoryFn } from "@storybook/react"; +import { ArgTypes, Stories, Title } from "@storybook/addon-docs/blocks"; +import { Meta, StoryFn } from "@storybook/react-webpack5"; import { useEffect, useRef } from "react"; import { ILocationCoord, ILocationFieldSchema, ILocationFieldValues } from "../../../components/fields"; import { IMapPin } from "../../../components/fields/location-field/location-modal/location-picker/types"; @@ -16,18 +16,19 @@ import { const recaptchaSiteKey = "6LfCjocsAAAAALM6wuZN3bqarbgbdaLuJIgFSrXT"; -const reverseGeocode = "https://api.dev.lifesg.io/onemap/revgeocode"; -const convertLatLngToXY = "https://api.dev.lifesg.io/onemap/4326to3414"; -const search = "https://api.dev.lifesg.io/onemap/search"; +const reverseGeocode = "https://api.dev.life.gov.sg/onemap/revgeocode"; +const convertLatLngToXY = "https://api.dev.life.gov.sg/onemap/4326to3414"; +const search = "https://api.dev.life.gov.sg/onemap/search"; const defaultMapApi = { reverseGeocode, convertLatLngToXY, search, headers: { - "x-client-app": "LifeSG", + "x-client-app": "LIFESG", }, }; + const meta: Meta = { title: "Field/LocationField", parameters: { @@ -185,6 +186,31 @@ 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.", + table: { + type: { + summary: "boolean", + }, + defaultValue: { summary: "false" }, + }, + options: [true, false], + control: { + type: "boolean", + }, + }, }, }; export default meta; @@ -298,6 +324,19 @@ MustHavePostalCode.args = { mapApi: defaultMapApi, }; +export const RestrictNonSGLocation = DefaultStoryTemplate( + "location-field-restrict-non-sg-location", + false, + recaptchaSiteKey +).bind({}); +RestrictNonSGLocation.args = { + uiType: "location-field", + label: "RestrictNonSGLocation", + restrictNonSGLocation: true, + validation: [{ required: true }], + mapApi: defaultMapApi, +}; + export const Warning = WarningStoryTemplate("location-field-with-warning", recaptchaSiteKey).bind( {} ); @@ -413,6 +452,7 @@ const IndicateCurrentLocationTemplate = () => const formRef = useRef(); useEffect(() => { + if (!formRef.current) return; const currentFormRef = formRef.current; currentFormRef.addFieldEventListener("location-field", "get-selectable-pins", id, getPins); 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 f3a426a54..070147261 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 "./window-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, ""); +}