Sanitize notification HTML to prevent XSS#3243
Sanitize notification HTML to prevent XSS#3243mlodic merged 2 commits intointelowlproject:developfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Sanitizes notification rendering to mitigate XSS by replacing raw HTML injection with Markdown rendering.
Changes:
- Replaced
dangerouslySetInnerHTMLusage in notifications withreact-markdown. - Added custom markdown renderers for emphasis and links (including
target="_blank"andrel="noopener noreferrer"). - Added frontend tests focused on XSS payloads and basic markdown behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| frontend/src/components/jobs/notification/NotificationsList.jsx | Switches notification body rendering to ReactMarkdown with custom link handling. |
| frontend/tests/components/jobs/notification/NotificationsList.test.jsx | Adds tests to ensure injected HTML/scripts don’t produce DOM elements and validates markdown rendering behaviors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const markdownComponents = { | ||
| // eslint-disable-next-line id-length | ||
| em: ({ node: _node, ...props }) => <i className="text-code" {...props} />, | ||
| // eslint-disable-next-line id-length | ||
| a: ({ node: _node, href, children, ...props }) => { | ||
| // eslint-disable-next-line no-script-url | ||
| if (href && href.startsWith("javascript:")) { | ||
| return null; |
There was a problem hiding this comment.
The javascript: URL check is easy to bypass because it’s case-sensitive and doesn’t normalize/trim the URL (e.g., JaVaScRiPt:..., leading whitespace/newlines). It also only covers javascript: and not other dangerous schemes (e.g., data:, vbscript:). Prefer a scheme allowlist via react-markdown’s URL transform hook or by parsing the URL and validating protocol after normalization, and render the link text safely when disallowed.
| const markdownComponents = { | |
| // eslint-disable-next-line id-length | |
| em: ({ node: _node, ...props }) => <i className="text-code" {...props} />, | |
| // eslint-disable-next-line id-length | |
| a: ({ node: _node, href, children, ...props }) => { | |
| // eslint-disable-next-line no-script-url | |
| if (href && href.startsWith("javascript:")) { | |
| return null; | |
| const SAFE_URL_PROTOCOLS = ["http:", "https:", "mailto:", "tel:"]; | |
| const isSafeHref = (href) => { | |
| if (!href) { | |
| return false; | |
| } | |
| const trimmedHref = href.trim(); | |
| // Allow relative links and in-page anchors. | |
| if (trimmedHref.startsWith("/") || trimmedHref.startsWith("#")) { | |
| return true; | |
| } | |
| try { | |
| const url = new URL(trimmedHref, window.location.origin); | |
| return SAFE_URL_PROTOCOLS.includes(url.protocol); | |
| } catch (_e) { | |
| // If parsing fails, treat as unsafe. | |
| return false; | |
| } | |
| }; | |
| const markdownComponents = { | |
| // eslint-disable-next-line id-length | |
| em: ({ node: _node, ...props }) => <i className="text-code" {...props} />, | |
| // eslint-disable-next-line id-length | |
| a: ({ node: _node, href, children, ...props }) => { | |
| if (!isSafeHref(href)) { | |
| // Render the text but do not make it a clickable link. | |
| return ( | |
| <span className="link-primary" {...props}> | |
| {children} | |
| </span> | |
| ); |
| const markdownComponents = { | ||
| // eslint-disable-next-line id-length | ||
| em: ({ node: _node, ...props }) => <i className="text-code" {...props} />, | ||
| // eslint-disable-next-line id-length | ||
| a: ({ node: _node, href, children, ...props }) => { | ||
| // eslint-disable-next-line no-script-url | ||
| if (href && href.startsWith("javascript:")) { | ||
| return null; | ||
| } | ||
| return ( | ||
| <a | ||
| href={href} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="link-primary" | ||
| {...props} | ||
| > | ||
| {children} | ||
| </a> | ||
| ); | ||
| }, | ||
| }; |
There was a problem hiding this comment.
This file introduces a custom markdownComponents implementation, but there is already a shared helper at frontend/src/components/common/markdownToHtml.jsx that wraps react-markdown and is used in multiple places (e.g., components/common/form/pluginsMultiSelectDropdownInput.jsx, components/jobs/result/pluginReportTables.jsx). Consider moving the security/link-hardening changes into the shared helper (or extracting shared markdown components) and reusing it here to avoid duplicated/inconsistent markdown behavior across the UI.
There was a problem hiding this comment.
Already explained in the description.
| test("prevents javascript: protocol in links", () => { | ||
| const notifications = [ | ||
| { | ||
| id: 4, | ||
| title: "JS Protocol Test", | ||
| body: '[Click me](javascript:alert("XSS"))', | ||
| read: false, | ||
| created_at: "2023-01-01T00:00:00Z", | ||
| }, | ||
| ]; | ||
|
|
||
| render( | ||
| <NotificationsList | ||
| notifications={notifications} | ||
| refetchFn={mockRefetch} | ||
| />, | ||
| ); | ||
|
|
||
| const link = screen.queryByRole("link"); | ||
| if (link) { | ||
| const href = link.getAttribute("href"); | ||
| if (href) { | ||
| expect(href).not.toContain("javascript:"); | ||
| } | ||
| } | ||
| }); |
There was a problem hiding this comment.
The javascript: protocol test can produce false positives because it passes if no link is rendered at all (e.g., parsing changes), which wouldn’t actually validate sanitization. Make the assertion deterministic by first asserting the link text is present and then asserting either (a) no anchor is rendered for it, or (b) the rendered anchor’s href is transformed to a safe value.
| const link = screen.getByRole("link", { name: "Google" }); | ||
| expect(link).toHaveAttribute("href", "https://google.com"); | ||
| expect(link).toHaveAttribute("target", "_blank"); | ||
| expect(link).toHaveClass("link-primary"); | ||
| }); |
There was a problem hiding this comment.
The link rendering test asserts target="_blank" but doesn’t verify the security requirement from the PR description (rel="noopener noreferrer"). Add an assertion for the rel attribute so regressions don’t reintroduce tabnabbing risk.
|
Hey @fgibertoni @mlodic PTAL at this :) |
|
can you also show us a brief video that this actually works in the GUI? |
Hey @mlodic apologies for the delay, hope this video explains it clearly. (Before then after) Screen.Recording.2026-01-29.at.5.58.56.PM.mov |
|
thanks, very helpful! |
Sanitize notification HTML to prevent XSS
Closes #3123
Description
This PR fixes a potential XSS vulnerability in the
NotificationsListcomponent by replacingdangerouslySetInnerHTMLwithreact-markdownChanges
dangerouslySetInnerHTMLwith<ReactMarkdown>infrontend/src/components/jobs/notification/NotificationsList.jsxrel="noopener noreferrer"for external links to improve security.src/components/common/markdownToHtml.jsxwas avoided because it is being used across multiple components, thus I added the snippet toNotificationsList.jsxitself. Let me know if I should be folllowign an alternative approach.frontend/tests/components/jobs/notification/NotificationsList.test.jsxType of change
Checklist
developBlack,Flake,Isort) gave 0 errors. If you have correctly installed pre-commit, it does these checks and adjustments on your behalf.testsfolder). All the tests (new and old ones) gave 0 errors.