diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
index 10788727f..fb7658619 100644
--- a/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
+++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive.js
@@ -64,6 +64,37 @@ function highlightPython(code) {
return Prism.highlight(code, Prism.languages.python, "python");
}
+function escapeHtml(text) {
+ if (text === null || text === undefined) {
+ return "";
+ }
+ const str = String(text);
+ const div = document.createElement("div");
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+function sanitizeHtml(html) {
+ if (typeof DOMPurify !== "undefined") {
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: [
+ "div", "span", "pre", "code", "ul", "li", "button", "svg", "line",
+ "rect", "path", "polyline", "i", "label", "select", "option"
+ ],
+ ALLOWED_ATTR: [
+ "class", "style", "id", "data-code", "data-node-id", "data-trigger-items",
+ "data-trigger-group", "data-router-paths", "data-condition-label",
+ "data-lucide", "viewBox", "fill", "stroke", "stroke-width",
+ "stroke-dasharray", "stroke-linecap", "stroke-linejoin",
+ "x", "y", "x1", "y1", "x2", "y2", "width", "height", "rx",
+ "points", "d", "onmouseover", "onmouseout"
+ ],
+ ALLOW_DATA_ATTR: true,
+ });
+ }
+ return html;
+}
+
class NodeRenderer {
constructor(nodes, networkManager) {
this.nodes = nodes;
@@ -1428,7 +1459,7 @@ class DrawerManager {
content += this.renderSourceCode(metadata);
}
- this.elements.content.innerHTML = content;
+ this.elements.content.innerHTML = sanitizeHtml(content);
this.attachContentEventListeners(nodeName);
// Initialize Lucide icons in the newly rendered drawer content
@@ -1448,9 +1479,9 @@ class DrawerManager {
${grouped.map((group) => {
if (group.items.length === 1) {
- return `- ${group.items[0]}
`;
+ return `- ${escapeHtml(group.items[0])}
`;
} else {
- const groupId = group.items.join(',');
+ const groupId = escapeHtml(group.items.join(','));
return `
-
@@ -1458,7 +1489,7 @@ class DrawerManager {
${group.items.length} routes →
- ${group.items.map((t) => `${t}`).join("")}
+ ${group.items.map((t) => `${escapeHtml(t)}`).join("")}
@@ -1535,17 +1566,17 @@ class DrawerManager {
renderConditionTree(condition, depth = 0) {
if (typeof condition === "string") {
- return `${condition}`;
+ return `${escapeHtml(condition)}`;
}
if (condition.type === "AND" || condition.type === "OR") {
- const conditionType = condition.type;
+ const conditionType = escapeHtml(condition.type);
const color = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)";
const bgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)";
const hoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)";
const triggerIds = this.extractTriggerIds(condition);
- const triggerIdsJson = JSON.stringify(triggerIds).replace(/"/g, '"');
+ const triggerIdsJson = escapeHtml(JSON.stringify(triggerIds));
const stringChildren = condition.conditions.filter(c => typeof c === 'string');
const nonStringChildren = condition.conditions.filter(c => typeof c !== 'string');
@@ -1562,7 +1593,7 @@ class DrawerManager {
if (group.items.length === 1) {
return this.renderConditionTree(group.items[0], depth + 1);
} else {
- const groupId = group.items.join(',');
+ const groupId = escapeHtml(group.items.join(','));
const groupColor = conditionType === "AND" ? "{{ CREWAI_ORANGE }}" : "var(--text-secondary)";
const groupBgColor = conditionType === "AND" ? "rgba(255,90,80,0.08)" : "rgba(102,102,102,0.06)";
const groupHoverBg = conditionType === "AND" ? "rgba(255,90,80,0.15)" : "rgba(102,102,102,0.12)";
@@ -1572,7 +1603,7 @@ class DrawerManager {
${group.items.length} routes
- ${group.items.map((t) => `${t}`).join("")}
+ ${group.items.map((t) => `${escapeHtml(t)}`).join("")}
`;
@@ -1615,7 +1646,7 @@ class DrawerManager {
console.log('renderMetadata called with:', metadata);
let metadataContent = "";
- const nodeType = metadata.type || "unknown";
+ const nodeType = escapeHtml(metadata.type || "unknown");
const typeBadgeColor =
nodeType === "start" || nodeType === "router"
? "{{ CREWAI_ORANGE }}"
@@ -1640,7 +1671,7 @@ class DrawerManager {
metadataContent += `
Condition
-
${metadata.condition_type}
+
${escapeHtml(metadata.condition_type)}
`;
}
@@ -1662,14 +1693,14 @@ class DrawerManager {
if (metadata.router_paths && metadata.router_paths.length > 0) {
const uniqueRouterPaths = [...new Set(metadata.router_paths)];
- const routerPathsJson = JSON.stringify(uniqueRouterPaths).replace(/"/g, '"');
+ const routerPathsJson = escapeHtml(JSON.stringify(uniqueRouterPaths));
metadataContent += `
Router Paths
- ${uniqueRouterPaths.map((p) => `- ${p}
`).join("")}
+ ${uniqueRouterPaths.map((p) => `- ${escapeHtml(p)}
`).join("")}
`;
diff --git a/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2 b/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2
index 876286e67..58002de15 100644
--- a/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2
+++ b/lib/crewai/src/crewai/flow/visualization/assets/interactive_flow.html.j2
@@ -9,6 +9,7 @@
+
diff --git a/lib/crewai/tests/test_flow_visualization.py b/lib/crewai/tests/test_flow_visualization.py
index d55e98bac..c8ffcb35b 100644
--- a/lib/crewai/tests/test_flow_visualization.py
+++ b/lib/crewai/tests/test_flow_visualization.py
@@ -667,4 +667,67 @@ def test_no_warning_for_properly_typed_router(caplog):
# No warnings should be logged
warning_messages = [r.message for r in caplog.records if r.levelno >= logging.WARNING]
assert not any("Could not determine return paths" in msg for msg in warning_messages)
- assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)
\ No newline at end of file
+ assert not any("Found listeners waiting for triggers" in msg for msg in warning_messages)
+
+
+def test_visualization_includes_dompurify_for_xss_protection():
+ """Test that visualization includes DOMPurify library for XSS protection."""
+ flow = SimpleFlow()
+ structure = build_flow_structure(flow)
+
+ html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
+
+ with open(html_file, "r", encoding="utf-8") as f:
+ html_content = f.read()
+
+ assert "dompurify" in html_content.lower()
+ assert "cdnjs.cloudflare.com/ajax/libs/dompurify" in html_content
+
+
+def test_visualization_js_includes_sanitization_functions():
+ """Test that generated JS includes escapeHtml and sanitizeHtml functions."""
+ flow = SimpleFlow()
+ structure = build_flow_structure(flow)
+
+ html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
+ html_path = Path(html_file)
+
+ js_file = html_path.parent / f"{html_path.stem}_script.js"
+
+ js_content = js_file.read_text(encoding="utf-8")
+
+ assert "function escapeHtml" in js_content
+ assert "function sanitizeHtml" in js_content
+ assert "DOMPurify.sanitize" in js_content
+
+
+def test_visualization_uses_sanitize_html_for_drawer_content():
+ """Test that drawer content rendering uses sanitizeHtml."""
+ flow = SimpleFlow()
+ structure = build_flow_structure(flow)
+
+ html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
+ html_path = Path(html_file)
+
+ js_file = html_path.parent / f"{html_path.stem}_script.js"
+
+ js_content = js_file.read_text(encoding="utf-8")
+
+ assert "innerHTML = sanitizeHtml(content)" in js_content
+
+
+def test_visualization_escapes_user_controlled_values():
+ """Test that user-controlled values are escaped in render methods."""
+ flow = SimpleFlow()
+ structure = build_flow_structure(flow)
+
+ html_file = visualize_flow_structure(structure, "test_flow.html", show=False)
+ html_path = Path(html_file)
+
+ js_file = html_path.parent / f"{html_path.stem}_script.js"
+
+ js_content = js_file.read_text(encoding="utf-8")
+
+ assert "escapeHtml(group.items[0])" in js_content
+ assert "escapeHtml(condition)" in js_content
+ assert "escapeHtml(metadata.type" in js_content