fix(security): remove dangerous tags and attributes from DOMPurify allow-list (WCH-SI10-001) - #310
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
There was a problem hiding this comment.
Pull request overview
This PR hardens the Webchat HTML sanitization layer by tightening the DOMPurify allow-list, aiming to restore DOMPurify’s intended security boundary by no longer re-permitting tags/attributes that are excluded by secure defaults.
Changes:
- Removes high-risk tags (e.g.,
iframe,base,form,object,embed,style,meta,link) from the DOMPurify allow-list used bysanitizeHTML(). - Removes high-risk attributes (e.g.,
formaction,srcdoc,style,target, etc.) from the DOMPurify allow-list. - Adds a Cypress E2E suite to guard against regressions and verify dangerous tags/attributes don’t survive sanitization.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/webchat/helper/sanitize.ts | Updates DOMPurify allow-lists to stop re-permitting tags/attributes excluded by secure defaults. |
| cypress/e2e/sanitize.cy.ts | Adds E2E regression tests for stripping dangerous tags/attributes and preserving safe content. |
Suppressed comments (2)
cypress/e2e/sanitize.cy.ts:72
- Same issue as the
<base>test: scoping the assertion to[data-cognigy-webchat-root]can miss<meta>if the browser relocates it (e.g., intohead). To ensure the tag is fully stripped from the DOM, assert globally (or at least againsthead).
it("strips <meta> — was in allow-list, enables HTTP redirect and CSP bypass", () => {
typeAndSend('<meta http-equiv="refresh" content="0;url=https://attacker.example.com">');
cy.get("[data-cognigy-webchat-root]").find("meta").should("not.exist");
});
cypress/e2e/sanitize.cy.ts:77
- Same issue as the
<base>/<meta>tests:<link>can also end up outside the widget subtree (e.g., moved intohead), so asserting only within the webchat root can produce a false negative. Assert globally (or at least againsthead).
it("strips <link> — was in allow-list, loads external stylesheets", () => {
typeAndSend('<link rel="stylesheet" href="https://attacker.example.com/evil.css">');
cy.get("[data-cognigy-webchat-root]").find("link").should("not.exist");
});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Three issues with this and 309, by increasing severity:
- These PRs should be merged into one.
- The reported issue stated in the jira ticket is not closed: this hardens the outgoing path (user messages), the report describes incoming path (bot messages). The incoming path config is in the https://github.com/Cognigy/chat-components/ repo.
- If we actually remove this capability from the bot messages in the repo above - we can't and should not prohibit style attribute on the elements. It is used by the customers to customize message stylings. Also I remember some customers had iframe use-case previously.
If this comes as a hard requirement for the FedRAMP, we need to consider a separate, harden build of the Cognigy Webchat.
This is also relevant for the recent PRs which harden and limit the xApps iframe capabilities. We strip them of valid use-cases like geolocation for the sake of audit compliance. This would be another argument towards separate build.
Please raise these concerns with the stakeholders.
afa4f90 to
88c7659
Compare
88c7659 to
60aa9ed
Compare
|
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Approving. I checked the customer blast radius carefully and it is much smaller than the diff size suggests.
Bot/Flow-authored HTML is not affected by this PR. Bot and agent message content is rendered by @cognigy/chat-components, which ships its own allowedHtmlTags/allowedHtmlAttributes and its own bundled DOMPurify. So customers whose Flows emit <iframe> video embeds, inline style, target="_blank" links, or class/id for CSS customisation are unaffected. PrivacyNotice.tsx is also safe — it feeds the sanitised text to react-markdown without rehype-raw, so raw HTML never rendered there.
That leaves only the user-typed input path, where the impact is a genuine nit.
Known minor regression, calling it out for the record. iframe and noframes are members of DOMPurify's DEFAULT_FORBID_CONTENTS, so once they leave ALLOWED_TAGS the element and its entire subtree are removed rather than unwrapped. Since <iframe> is a raw-text element, everything after a stray tag becomes its content:
in: "Can I embed an <iframe> in my page?"
was: "Can I embed an in my page?"
now: "Can I embed an "
Only reachable when a user literally types those tags, which realistically means developer-facing bots. Not worth blocking on. If you want it gone, pass an explicit FORBID_CONTENTS that excludes iframe/noframes — their inner text is inert once the element is stripped. (For the avoidance of doubt: the equivalent <style> behaviour already exists on main and is not introduced by this PR.)
Two documentation nits:
- The new comment at the top of the tag list says "always blocked regardless of caller configuration", but line 238 still does
{ ...config, ALLOWED_TAGS: customAllowedHtmlTags }— a tenant can re-permit every removed tag. #309 is what makes that statement true, so either land #309 first or soften the wording to "blocked by default". The attribute comment is accurate as written, since there is no custom-attribute setting. bodyis removed in the diff but missing from the PR description, the commit message, and the comment block. Also,html,headandtitlewere left in the list, so the "restore DOMPurify defaults" rationale is applied inconsistently. Worth reconciling the audit trail on an SI-10 PR.
This does not close CSA-97602 on its own — see my earlier comment and my review on #309. The tenant-influenced rendering surface is @cognigy/chat-components, whose allow-list still contains iframe, object, embed, applet, frame, frameset, meta, base, link, style, form, svg, template plus action, formaction, sandbox, srcdoc, style, target, and which applies customAllowedHtmlTags with no deny-list. Please keep the ticket open, or split a follow-up, until the same hardening lands there and the dependency is bumped.
Merges cleanly with #309 (verified with git merge-tree); suggest landing this one first so #309's FORBID_TAGS becomes a pure no-op on the default path.
…WCH-SI10-001) Three documentation nits from PR #310 review: 1. Soften comment wording — "always blocked regardless of caller configuration" overstated: the custom-tag path (customAllowedHtmlTags) can still override the list. Reworded to "blocked by default when no custom tag list is configured" and added a note that PR #309 ensures dangerous tags are stripped from any tenant-supplied list before it is applied. 2. Document body removal — body was removed from allowedHtmlTags in the original commit but was absent from the comment block, PR description, and commit message. Added to the comment alongside html/head. 3. Remove html and head from allowedHtmlTags — the "restore DOMPurify defaults" rationale was applied inconsistently: body was removed but html and head were left in. Both are structural document elements with no legitimate use in sanitised chat fragments; removing them makes the list consistent. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…low-list (WCH-SI10-001) SI-10 / AC-4 / FedRAMP Moderate: The hand-rolled ALLOWED_TAGS and ALLOWED_ATTR lists re-permitted every tag and attribute that DOMPurify excludes by default, negating the sanitizer entirely. Tags removed (were explicit XSS / URL-hijacking / exfiltration vectors): applet, base, embed, form, frame, frameset, iframe, link, meta, noframes, object, style Attributes removed: action, formaction — form phishing sandbox — attacker control of iframe sandbox policy srcdoc — inline HTML document; direct XSS in iframe style — CSS injection and attribute exfiltration target — navigation hijacking All other tags (img, audio, video, table, a, b, etc.) and safe attributes (href, src, alt, rel, etc.) are preserved — no functional regression for legitimate bot message content. Tests added in cypress/e2e/sanitize.cy.ts: each removed tag and attribute is verified to be absent from the DOM after a user message is sent through the sanitizeHTML path (disableHtmlInput:false, disableTextInputSanitization defaults to false). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…I10-001) Collapse multi-line typeAndSend() calls and .find().should() chains that fit within printWidth (≤89 chars) onto single lines as Prettier requires. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Lines 64 and 90 exceeded printWidth 100 (106 and 101 display chars).
Wrap the it("...", () => { body }) pattern so each line stays within 100.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…H-SI10-001) Flatten nested describe blocks to a single level (matching disableHtmlInput.cy.ts pattern) so all it() lines stay within printWidth:100. Shortened test descriptions to eliminate 2-tab prefix that was pushing lines over the limit. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…WCH-SI10-001)
form test: webchat renders its own <form.webchat-input-menu-form> in the input
area, so find("form") on the whole root always found it. Scoped to
.webchat-chat-history where injected <form> tags would appear.
style-attr test: Emotion CSS-in-JS adds [style] to many webchat elements, so
find("[style]").should("not.exist") always failed. Changed to
find('[style*="attacker.example.com"]') to match the specific injected value.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…WCH-SI10-001) Three documentation nits from PR #310 review: 1. Soften comment wording — "always blocked regardless of caller configuration" overstated: the custom-tag path (customAllowedHtmlTags) can still override the list. Reworded to "blocked by default when no custom tag list is configured" and added a note that PR #309 ensures dangerous tags are stripped from any tenant-supplied list before it is applied. 2. Document body removal — body was removed from allowedHtmlTags in the original commit but was absent from the comment block, PR description, and commit message. Added to the comment alongside html/head. 3. Remove html and head from allowedHtmlTags — the "restore DOMPurify defaults" rationale was applied inconsistently: body was removed but html and head were left in. Both are structural document elements with no legitimate use in sanitised chat fragments; removing them makes the list consistent. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
7cf9706 to
872dfcf
Compare
Removes dangerous tags and attributes that were explicitly re-permitted in the hand-rolled DOMPurify allow-list, restoring the security boundary the sanitizer was intended to provide.
Scope — user-typed input and PrivacyNotice text only.
sanitizeHTML()in this repo is called on two surfaces:message-middleware.ts:123(SEND_MESSAGE path), whendisableTextInputSanitizationisfalsePrivacyNotice.tsx:58, sanitizing the tenant-configured notice textBot and agent messages are out of scope for this PR.
Incoming bot/agent messages flow through
RECEIVE_MESSAGE → addMessage() → @cognigy/chat-components, which never callssanitizeHTML()in this repo. Bot message HTML sanitization lives in@cognigy/chat-components. Tags likestyleandiframeused by customers to style or embed content in bot messages are not affected by this change and must be addressed separately in that repo.FedRAMP SI-10 / AC-4 remediation (WCH-SI10-001 — partial):
The previous
allowedHtmlTagsandallowedHtmlAttributesarrays re-permitted every tag and attribute that DOMPurify excludes by default, weakening sanitization for user-typed input. This PR restores the correct security boundary for the two surfaces listed above.The dangerous path only opens when
disableHtmlInput: falseis configured (non-default). With the defaultdisableHtmlInput: true,stripHtmlToInertText()runs aftersanitizeHTML()and strips all HTML anyway — but the permissive allow-list is still a defence-in-depth gap.Tags removed from
allowedHtmlTags:applet,base,body,embed,form,frame,frameset,head,html,iframe,link,meta,noframes,object,styleAttributes removed from
allowedHtmlAttributes:action,formaction,sandbox,srcdoc,style,targetAll other tags (
img,audio,video,table,a,b,p, etc.) and safe attributes (href,src,alt,rel, etc.) are preserved — no functional regression for PrivacyNotice content or user text input under non-default configurations.Jira: CSA-97602
Success criteria
styleoriframein bot messages see no changeHow to test
npm run build && npm test— all Cypress tests including the newsanitize.cy.tssuite passdisableHtmlInput: false, submit an iframe with a srcdoc script — verify no iframe element appears in chat historySecurity
Accessibility (WCAG 2.2 AA)
Additional considerations
Documentation Considerations
No documentation update required —
allowedHtmlTagsandallowedHtmlAttributesare internal implementation details. Bot message rendering (and its allow-list) lives in@cognigy/chat-componentsand is not changed by this PR.🤖 Generated with Claude Code