Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
184acb2
[MOL-22453][SX][asgard-0001] Fix CI script injection in trigger-gitla…
Sep 16, 2026
3e87a1c
[MOL-22453][SX][asgard-0002] Fix sanitize-html allowedAttributes:fals…
Sep 16, 2026
d061dff
[MOL-22453][SX][asgard-0003] Fix sanitize-html allowedAttributes:fals…
Sep 16, 2026
f84e766
[MOL-22453][SX][asgard-0006] Fix sanitize-html allowedAttributes:fals…
Sep 16, 2026
7fca2d6
[MOL-22453][SX][asgard-0005] Restrict ButtonField link URLs to a sche…
Sep 16, 2026
4b2a947
[MOL-22453][SX][asgard-0007] Enforce postMessage origin check on Ifra…
Sep 16, 2026
d24f592
[MOL-22453][SX][asgard-0008] Escape user search query before compilin…
Sep 16, 2026
6792ebb
[MOL-22453][SX] Bump sanitize-html 2.17.5 -> 2.17.7
Sep 16, 2026
061d531
[MOL-22453][SX] Allow Jest to transform sanitize-html's ESM-only depe…
Sep 16, 2026
674abdd
[MOL-22453][SX][asgard-0004] Harden local Playwright server container…
Sep 16, 2026
04d5f73
[MOL-22453][SX][asgard-0013] Restrict FileUpload prefill fileUrl to a…
Sep 16, 2026
9bb2170
[MOL-22453][SX][asgard-0014] Stop trusting caller-supplied file metad…
Sep 16, 2026
ef7255f
[MOL-22453][SX][asgard-0015] Document that OTP verified state is clie…
Sep 16, 2026
b2ce51f
[MOL-22453][SX][asgard-0009] Bound input length before testing schema…
Sep 16, 2026
7e0100e
[MOL-22453][SX] Fix notMatches condition: crash on malformed regex + …
Sep 16, 2026
b0e82a6
[MOL-22453][SX] Bound input length before testing filenameMatches pat…
Sep 16, 2026
db09394
[MOL-22453][SX] Use shared RegexHelper for maskRegex parsing in Maske…
Sep 16, 2026
d0f9cb0
[MOL-22453][SX][asgard-0011] Strip @import/url() from locationModalSt…
Sep 16, 2026
3de74f4
[MOL-22453][SX][asgard-0012] Strip @import/url() from imageReviewModa…
Sep 16, 2026
5012191
[MOL-22453][SX] Bound MaskedField keystroke input length as a partial…
Sep 16, 2026
c835f7e
[MOL-22453][SX] Fix matches rule silently blocking ImageUpload submis…
Sep 16, 2026
74168e1
[MOL-22453][SX] Use printf+awk to extract commit message first line, …
Sep 17, 2026
ced4acd
[MOL-22453][SX] Fix Iframe postMessage origin check failing open for …
Sep 17, 2026
3aae54a
[MOL-22453][SX] Clamp MaskedField value length, not just the maxLengt…
Sep 17, 2026
6a091de
[MOL-22453][SX] Add regression test documenting the ReDoS bound's rea…
Sep 17, 2026
d687d9b
[MOL-22453][SX] Clamp MaskedField at render time and reject oversized…
Sep 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/trigger-gitlab-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ jobs:

steps:
- name: Print Configs
env:
HEAD_REF: ${{ github.head_ref }}
REF_NAME: ${{ github.ref_name }}
PR_TITLE: ${{ github.event.pull_request.title }}
HEAD_COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
[[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="${{ github.head_ref }}" || BRANCH_NAME="${{ github.ref_name }}"
[[ $GITHUB_EVENT_NAME = "pull_request" ]] && BRANCH_NAME="$HEAD_REF" || BRANCH_NAME="$REF_NAME"

[[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="${{ github.event.pull_request.title }}" || COMMIT_MSG=$(echo -e "${{ github.event.head_commit.message }}" | head -n 1)
[[ $GITHUB_EVENT_NAME = "pull_request" ]] && COMMIT_MSG="$PR_TITLE" || COMMIT_MSG=$(printf '%s\n' "$HEAD_COMMIT_MSG" | awk 'NR>1{exit};1')

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

Expand Down
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
services:
playwright-tests:
build: .
network_mode: "host"
ports:
- "127.0.0.1:3010:3010"
Comment thread
shengxi-gt marked this conversation as resolved.
extra_hosts:
- "host.docker.internal:host-gateway"
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const SCHEMA: IFrontendEngineData = {
type: "base64",
url: "/api/upload",
},
// this demo prefills a file via uploadResponse only (no dataURL/fileUrl); since
// asgard-0014, that metadata is untrusted by default and the file is rejected as
// unverified unless the schema explicitly opts in
trustProvidedFileMetadata: true,
},
},
},
Expand Down
6 changes: 5 additions & 1 deletion jest/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ module.exports = async () => ({
],
verbose: true,
bail: false,
transformIgnorePatterns: ["/node_modules/(?!@lifesg/react-design-system|leaflet)"],
// sanitize-html >=2.15 pulls in htmlparser2 v12+, which is ESM-only (and its own
// deps domhandler/domutils/domelementtype/entities), so it needs to go through babel too
transformIgnorePatterns: [
"/node_modules/(?!@lifesg/react-design-system|leaflet|sanitize-html|htmlparser2|dom-serializer|domhandler|domutils|domelementtype|entities)",
],
transform: {
"\\.[jt]sx?$": ["babel-jest", { excludeJestPreset: true }],
"^.+\\.css$": "jest-transform-css",
Expand Down
121 changes: 116 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"react-dropzone": "^14.2.3",
"react-hook-form": "7.54.2",
"react-infinite-scroll-hook": "^4.1.1",
"sanitize-html": "^2.8.1",
"sanitize-html": "^2.17.7",
"use-deep-compare-effect": "^1.8.1",
"yup": "^0.32.11"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ describe(REFERENCE_KEY, () => {
expect(SUBMIT_FN).toHaveBeenCalledWith(expect.objectContaining({ [COMPONENT_ID]: defaultValues }));
});

it("should strip event handler attributes from option labels", () => {
renderComponent({
options: [{ label: '<span onclick="window.xssFired=true">Apple label</span>', value: "Apple" }],
});

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

it("should be able to render hint", () => {
renderComponent({
label: {
Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/components/custom/iframe/iframe.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,71 @@ 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 () => {
// new URL("/embedded/form") with no base throws; resolving against window.location.href
// (http://localhost/ in this test environment) must derive "http://localhost", not fail open
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 (fail closed) when src cannot be resolved to a valid http(s) origin", async () => {
// a non-http(s) scheme parses fine as a URL but must not be trusted as a postMessage origin —
// and critically, when no valid origin can be established, messages must be rejected by
// default, not accepted by default
renderComponent({ src: "javascript:alert(1)", validationTimeout: -1 });

sendPostMessageFromOrigin("https://attacker.example", EPostMessageEvent.SET_VALUE, "hello world");
await waitFor(() => fireEvent.click(getSubmitButton()));

expect(SUBMIT_FN).toHaveBeenCalledWith({});
});
});

describe("load", () => {
it("should fire a loading event when iframe starts loading", () => {
const testFn = jest.fn();
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/components/elements/popover/popover.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ describe(UI_TYPE, () => {
expect(screen.queryByTestId("popover").innerHTML.includes("script")).toBe(false);
});

it("should strip event handler attributes from an otherwise-allowed image tag in the hint", () => {
renderComponent({
hint: { content: '<img src="x" onerror="window.xssFired=true" alt=\'broken image\'>' },
});

fireEvent.click(screen.getByTestId("field__popover"));

const imgElement = screen.getByAltText("broken image");
expect(imgElement).toBeInTheDocument();
expect(imgElement).not.toHaveAttribute("onerror");
});

it("should render icon after text if specified", async () => {
renderComponent({ icon: "AlbumFillIcon" });

Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/components/elements/typography/typography.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,18 @@ describe(UI_TYPE, () => {
expect(screen.getByText("This is a HTML string")).toBeInTheDocument();
});

it("should strip event handler attributes from an otherwise-allowed image tag", () => {
renderComponent({
className: "text-element",
children: '<img src="x" onerror="window.xssFired=true" alt=\'broken image\'>',
});

const imgElement = screen.getByAltText("broken image");
expect(imgElement).toBeInTheDocument();
expect(imgElement).not.toHaveAttribute("onerror");
expect(document.querySelector(".text-element").innerHTML).not.toContain("onerror");
});

it("should be able to sanitize HTML string", () => {
renderComponent({
className: "text-element",
Expand Down
8 changes: 5 additions & 3 deletions src/__tests__/components/fields/button/button.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,11 @@ describe("button", () => {
});

it.each`
scenario | href
${"should not navigate when href is not provided"} | ${undefined}
${"should not navigate when href is invalid"} | ${"invalid-url"}
scenario | href
${"should not navigate when href is not provided"} | ${undefined}
${"should not navigate when href is invalid"} | ${"invalid-url"}
${"should not navigate when href uses javascript: scheme"} | ${"javascript:alert(1)"}
${"should not navigate when href uses data: scheme"} | ${"data:text/html,<script>alert(1)</script>"}
`("$scenario", ({ href }) => {
renderComponent({ overrideButton: { ...(href && { href }) } });
fireEvent.click(getField("button", COMPONENT_LABEL));
Expand Down
Loading
Loading