fix(security): add deny-list to customAllowedHtmlTags in sanitizeHTML (WCH-SI10-002) - #309
smohite-nice wants to merge 5 commits into
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
Adds a case-insensitive deny-list to sanitizeHTML() for tenant-supplied HTML tags, with Cypress coverage.
Changes:
- Filters dangerous tags from
customAllowedHtmlTags. - Adds regression tests for blocked tags, casing, and safe tags.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Review summary |
|---|---|
src/webchat/helper/sanitize.ts |
The deny-list is not applied when customAllowedHtmlTags is unset, leaving blocked tags allowed through the base configuration. Critical issue. |
cypress/e2e/customAllowedHtmlTags.cy.ts |
The tests exercise react-markdown, so they do not verify that sanitizeHTML() performs the filtering. Moderate issue. |
Suppressed comments (6)
cypress/e2e/customAllowedHtmlTags.cy.ts:41
cy.its()retries until the requested property exists. Since the expected secure result is that__xssis never created, this assertion can time out rather than verify absence. Assert the property is absent directly withcy.window().should("not.have.property", "__xss").
cy.window().its("__xss").should("equal", undefined);
cypress/e2e/customAllowedHtmlTags.cy.ts:88
- This test is named as a safe-tag regression, but it never asserts that
porbsurvives; it only repeats the non-execution check that the Markdown renderer would pass even if all tags were stripped. Add an assertion on the actual sanitized/rendered output for a safe tag (or testsanitizeHTMLdirectly), so the stated safe-tag compatibility criterion is covered.
it("still allows safe tags listed in customAllowedHtmlTags through to the deny-list filter", () => {
// "script" is in the list but blocked; "p" is safe and must survive the filter.
// The privacy notice renders its text via react-markdown which does not render
// arbitrary HTML, so we verify no dangerous element was injected rather than
// asserting the <p> element specifically.
cypress/e2e/customAllowedHtmlTags.cy.ts:105
- The success criteria require case-insensitive filtering for all 12 blocked tags, but this test exercises casing for only
scriptandiframe. A regression for mixed-casestyle,base, orform, for example, would still pass. Generate mixed-case variants for every entry inblockedTagsand assert each corresponding element is absent.
it("handles uppercase and mixed-case tag names in deny-list check", () => {
// Attacker supplies "SCRIPT" or "Script" hoping to bypass toLowerCase() check
initWithAllowedTags(
["SCRIPT", "Script", "IFRAME"],
"<script>window.__xss3 = true</script><iframe></iframe>",
cypress/e2e/customAllowedHtmlTags.cy.ts:20
- The helper leaves the default home screen enabled, but each caller only clicks the toggle. That opens
HomeScreen; it does not callstartConversation(), soPrivacyNoticenever mounts andsanitizeHTML()is not exercised. AddhomeScreen: { enabled: false }here (or start the conversation after opening) so these assertions reach the sanitized surface.
const initWithAllowedTags = (customAllowedHtmlTags: string[], privacyText: string) =>
cy.visitWebchat().initMockWebchat({
settings: {
src/webchat/helper/sanitize.ts:257
fetchWebchatConfig()returns raw JSON cast toIWebchatConfig, so the TypeScript type does not protect this runtime boundary. A tenant value such ascustomAllowedHtmlTags: [null]now throws attag.toLowerCase(), breaking privacy-notice rendering and message sanitization instead of safely ignoring the malformed entry. Guard the array and retain only string tags before lowercasing.
const safeTags = customAllowedHtmlTags.filter(
tag => !ALWAYS_BLOCKED_TAGS.has(tag.toLowerCase()),
);
src/webchat/helper/sanitize.ts:254
- The added specs cover arrays (including
[]) but never omitcustomAllowedHtmlTags, so the false branch that preserves the default configuration is untested. This is an explicit success criterion and should have a case proving the no-setting behavior remains unchanged.
let configToUse = config;
if (customAllowedHtmlTags) {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Please add a comment about this black list into embedding doc to the related row.
0082fa7 to
9ffec85
Compare
9ffec85 to
ca1e1d1
Compare
ca1e1d1 to
eef1ceb
Compare
Dmitrii Ostasevich (kwinto)
left a comment
There was a problem hiding this comment.
Thanks for adding the docs row — that addresses my earlier comment. The deny-list itself is well built: case-insensitivity is handled, and using FORBID_TAGS plus a pre-filter is genuine defence-in-depth rather than a single gate (DOMPurify applies FORBID_TAGS over ALLOWED_TAGS, so the ordering is correct). Two things need fixing before merge.
1. (Blocking) Malformed customAllowedHtmlTags now throws — blank widget. sanitize.ts:264-265
customAllowedHtmlTags.filter(tag => !ALWAYS_BLOCKED_TAGS.has(tag.toLowerCase()))This assumes an array of strings, but the value arrives from remote endpoint JSON / embed-time overrides and is deep-merged with no type validation in config-reducer.ts. I ran each shape against the exact filter logic:
| value | result |
|---|---|
["p", null] |
TypeError: Cannot read properties of null (reading 'toLowerCase') |
["p", 1] / [{}] |
TypeError: tag.toLowerCase is not a function |
"p,br" (string) |
TypeError: customAllowedHtmlTags.filter is not a function |
All four are handled gracefully on main — DOMPurify's addToSet tolerates non-strings and non-arrays, so this is a new failure mode introduced here. Impact: PrivacyNotice.tsx:58 calls sanitizeHTML inside useMemo during render, and there is no ErrorBoundary/componentDidCatch anywhere in src/, so React unmounts the entire widget — blank webchat. On the SEND_MESSAGE path (message-middleware.ts:123) it throws out of dispatch and breaks message sending.
const list = Array.isArray(customAllowedHtmlTags) ? customAllowedHtmlTags : [];
const safeTags = list.filter(
tag => typeof tag === "string" && !ALWAYS_BLOCKED_TAGS.has(tag.toLowerCase().trim()),
);(The .trim() also closes the cosmetic " script " gap — harmless today since DOMPurify never matches a tag name containing spaces, but it belongs in the same fix.)
2. (Blocking, docs) FORBID_TAGS changes the default path too. sanitize.ts:251
FORBID_TAGS is added to the base config, so all 12 tags are stripped even when customAllowedHtmlTags is unset — they were previously in allowedHtmlTags. The success criteria say "customAllowedHtmlTags not set → existing behaviour unchanged", which the PR's own test (FORBID_TAGS also blocks form in the default config) contradicts, and the docs/embedding.md edit only documents the block under the customAllowedHtmlTags row. Real blast radius is small, but the description and docs should say what the code does. Landing #310 first makes this a genuine no-op and resolves it cleanly.
Non-blocking:
- Test assertions are vacuous for 8 of the 12 tags.
customAllowedHtmlTags.cy.ts:113-137only sendsscript/iframe/form/objectmarkup but loopsexpect(text).not.to.contain('<' + tag)over all 12 — the assertions forembed,applet,frame,frameset,meta,base,link,stylepass regardless of implementation. Please include markup for every tag, and add cases for the malformed shapes above. - Reconcile with #310. #310 considers
bodyandnoframesdangerous enough to delete from the default allow-list, but they aren't inALWAYS_BLOCKED_TAGS— so after both land, a tenant can re-enable exactly those two via config. Suggest adding them here. - A one-time
console.warnlisting dropped tags would make support cases diagnosable; silent-drop is correct behaviour but opaque to whoever configured it. - Merge order. #309 and #310 auto-merge cleanly (
git merge-tree, exit 0) and the result is coherent. Recommend #310 first, then this — otherwisemaintemporarily ships anallowedHtmlTagsconstant that contradicts effective behaviour.
On CSA-97603 closure. The PR body notes chat-components is out of scope, and I agree for this diff — but the docs now say these tags are blocked "regardless of this setting", and that isn't true on the surface where tenant-influenced content actually renders. @cognigy/chat-components reads the same config.settings.widgetSettings.customAllowedHtmlTags (via useSanitize → sanitizeHTMLWithConfig) and passes it straight to ALLOWED_TAGS with no deny-list, then renders through dangerouslySetInnerHTML. Its own defaults still allow iframe, object, embed, base, meta, link, form, style plus srcdoc, action, formaction, sandbox, style, target, and it deliberately re-emits srcdoc — so adding "script" via config yields <iframe srcdoc="<script>…"> surviving sanitisation. Please either soften the docs wording or, better, filter the setting once in config-reducer so both consumers get the sanitised list — that would genuinely close the ticket in one place.
Three issues fixed based on reviewer feedback: 1. Add type guard to sanitize.ts filter — Array.isArray() check plus typeof string guard plus .trim() prevent TypeError when customAllowedHtmlTags contains null, numbers, objects, or is a plain string instead of an array. 2. Filter customAllowedHtmlTags in config-reducer.ts before storing — strips dangerous tags and non-string entries at config-load time so both webchat (sanitizeHTML) and @cognigy/chat-components (which reads the same store key directly) receive the pre-sanitised list. BLOCKED_TAGS is defined inline to avoid the sanitize.ts → store → config-reducer circular import. 3. Update docs/embedding.md wording — clarifies that blocked tags are removed from the tenant-supplied array before it is applied (accurate for both consumers now), and notes that non-string entries are silently ignored. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
Thanks Dmitrii, all three points addressed in commit 94caffb: Blocking 1 — type guard in sanitize.ts const list = Array.isArray(customAllowedHtmlTags) ? customAllowedHtmlTags : [];
const safeTags = list.filter(
tag => typeof tag === "string" && !ALWAYS_BLOCKED_TAGS.has(tag.toLowerCase().trim()),
);Blocking 2 / Advisory — chat-components bypass
Docs — embedding.md On FORBID_TAGS and the default path: |
Three issues fixed based on reviewer feedback: 1. Add type guard to sanitize.ts filter — Array.isArray() check plus typeof string guard plus .trim() prevent TypeError when customAllowedHtmlTags contains null, numbers, objects, or is a plain string instead of an array. 2. Filter customAllowedHtmlTags in config-reducer.ts before storing — strips dangerous tags and non-string entries at config-load time so both webchat (sanitizeHTML) and @cognigy/chat-components (which reads the same store key directly) receive the pre-sanitised list. BLOCKED_TAGS is defined inline to avoid the sanitize.ts → store → config-reducer circular import. 3. Update docs/embedding.md wording — clarifies that blocked tags are removed from the tenant-supplied array before it is applied (accurate for both consumers now), and notes that non-string entries are silently ignored. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
94caffb to
6e7d5c7
Compare
…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>
…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) (#310) * fix(security): remove dangerous tags and attributes from DOMPurify allow-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> * chore: fix prettier — inline chained methods in sanitize.cy.ts (WCH-SI10-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> * chore: fix prettier — wrap two long it() descriptions in sanitize.cy.ts 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> * chore: rewrite sanitize.cy.ts with flat structure to fix prettier (WCH-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> * fix(test): tighten form and style-attr assertions in sanitize.cy.ts (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> * docs(security): reconcile allow-list audit trail per Dmitrii review (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> --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
… (WCH-SI10-002) SI-10 / AC-3 / FedRAMP CRITICAL: customAllowedHtmlTags replaced ALLOWED_TAGS wholesale with no validation, allowing a tenant to re-enable script, iframe, object, embed and other dangerous tags for all sessions on that endpoint. Add ALWAYS_BLOCKED_TAGS — a compile-time Set of 12 tags that are filtered out of any tenant-supplied customAllowedHtmlTags array before it reaches DOMPurify, regardless of endpoint configuration: script, iframe, object, embed, applet, frame, frameset, meta, base, link, style, form The filter is case-insensitive (.toLowerCase()) to prevent SCRIPT/Script bypasses. Safe tags in customAllowedHtmlTags are unaffected; existing behaviour for tenants that do not set customAllowedHtmlTags is unchanged. Tests added (cypress/e2e/customAllowedHtmlTags.cy.ts): - script blocked even when explicitly listed in customAllowedHtmlTags - iframe blocked even when explicitly listed - all 12 ALWAYS_BLOCKED_TAGS blocked in a single pass - uppercase/mixed-case tag names rejected by toLowerCase() normalisation - empty customAllowedHtmlTags ([]) strips all tags as before - WCAG 2.2 AA compliance check on the privacy-notice surface Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…tests (WCH-SI10-002)
Address two Copilot review comments:
1. FORBID_TAGS in base DOMPurify config (previously only customAllowedHtmlTags
path was guarded):
- Move ALWAYS_BLOCKED_TAGS before config so it can be referenced in FORBID_TAGS
- Add FORBID_TAGS: [...ALWAYS_BLOCKED_TAGS] to the base config — DOMPurify's
FORBID_TAGS overrides ALLOWED_TAGS at sanitisation time and is preserved
through the { ...config, ALLOWED_TAGS: safeTags } spread, making the block
truly unconditional for both the default and custom-tags code paths
- Keep the pre-filter on customAllowedHtmlTags as defence-in-depth
2. Rewrite tests to exercise sanitizeHTML() via the SEND_MESSAGE boundary:
- Tests now type and submit text through the real user-input path
- Assertions read the sanitized text from the Redux message store
- This gives a direct observable signal on sanitizeHTML() output rather
than relying on react-markdown's own HTML filtering (which would have
made the tests pass even without the fix)
- Added a dedicated test for the default-config FORBID_TAGS path
(customAllowedHtmlTags not set)
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…I10-002)
cy.its("__xss").should("equal", undefined) timed out because cy.its()
retries until the property EXISTS on the subject. When script is blocked
and window.__xss is never set, it waits 4 s then fails.
Replace with cy.window().then(win => expect(win.__xss).to.be.undefined)
which uses a synchronous assertion in a .then() callback and correctly
passes when the property is absent.
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…-002) Add a security note to the customAllowedHtmlTags row in embedding.md listing the 12 tags that are unconditionally blocked regardless of the tenant-supplied list (script, iframe, object, embed, applet, frame, frameset, meta, base, link, style, form). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Three issues fixed based on reviewer feedback: 1. Add type guard to sanitize.ts filter — Array.isArray() check plus typeof string guard plus .trim() prevent TypeError when customAllowedHtmlTags contains null, numbers, objects, or is a plain string instead of an array. 2. Filter customAllowedHtmlTags in config-reducer.ts before storing — strips dangerous tags and non-string entries at config-load time so both webchat (sanitizeHTML) and @cognigy/chat-components (which reads the same store key directly) receive the pre-sanitised list. BLOCKED_TAGS is defined inline to avoid the sanitize.ts → store → config-reducer circular import. 3. Update docs/embedding.md wording — clarifies that blocked tags are removed from the tenant-supplied array before it is applied (accurate for both consumers now), and notes that non-string entries are silently ignored. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
6e7d5c7 to
3017b8f
Compare
Adds a hard deny-list to
sanitizeHTML()so that dangerous HTML tags can never be re-enabled through thecustomAllowedHtmlTagsendpoint configuration, regardless of what a tenant supplies.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.
Incoming bot/agent messages flow through
RECEIVE_MESSAGE → addMessage() → @cognigy/chat-components, which never callssanitizeHTML()in this repo. Tags likestyleandiframethat customers use for bot message styling and embedding are handled entirely in@cognigy/chat-componentsand are unaffected by this change.The
<script>tag that loadswebchat.jsis unrelated.Blocking
<style>or<script>insanitizeHTML()has zero effect on how the widget loads or renders. The webchat's own styling uses Emotion CSS-in-JS which injects<style>elements directly — DOMPurify never touches the widget's own markup. The deny-list only strips those tags when a user types them into the chat input.FedRAMP SI-10 / AC-3 remediation (WCH-SI10-002 — partial):
customAllowedHtmlTagspreviously replacedALLOWED_TAGSwholesale with no validation. A tenant could setcustomAllowedHtmlTags: ["script"]and make every session on that endpoint XSS-vulnerable in a single config change.Changes:
src/webchat/helper/sanitize.ts— addsALWAYS_BLOCKED_TAGS(exportedSetof 12 tags) and filters the tenant-supplied list against it before passing to DOMPurify. The filter is case-insensitive (.toLowerCase()) to preventSCRIPT/Scriptbypasses. Also addsFORBID_TAGS: [...ALWAYS_BLOCKED_TAGS]to the base DOMPurify config so the block is unconditional on both the default and custom-tags code paths.Always-blocked tags (regardless of tenant config):
script,iframe,object,embed,applet,frame,frameset,meta,base,link,style,formSafe tags in
customAllowedHtmlTags(e.g.["p", "b", "span"]) continue to work unchanged. Behaviour whencustomAllowedHtmlTagsis not set is unchanged.Jira: CSA-97603
Success criteria
customAllowedHtmlTags: ["script"]no longer allows<script>tags throughsanitizeHTMLcustomAllowedHtmlTagswith safe tags (e.g.["p", "b", "span"]) are unaffectedstyleoriframein bot messages see no changecustomAllowedHtmlTagsnot set → existing behaviour unchangedHow to test
npm run build && npm test— all Cypress tests including the newcustomAllowedHtmlTagstests passcustomAllowedHtmlTags: ["script"]in endpoint config, send a message containing a script tag — verify no<script>element appears and no script executescustomAllowedHtmlTags: ["SCRIPT"](uppercase) — same result (case-insensitive block)customAllowedHtmlTags: ["p", "b"]— verify those safe tags still workSecurity
Accessibility (WCAG 2.2 AA)
Additional considerations
Documentation Considerations
The
customAllowedHtmlTagsrow indocs/embedding.mdhas been updated to list the 12 always-blocked tags. Bot message rendering (and its allow-list) lives in@cognigy/chat-componentsand is not changed by this PR.🤖 Generated with Claude Code