fix(#200): Enhance Mermaid XSS protection with DOMPurify
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

Added defense-in-depth security layers for Mermaid rendering:

DOMPurify SVG Sanitization:
- Sanitize SVG output after mermaid.render()
- Remove script tags, iframes, objects, embeds
- Remove event handlers (onerror, onclick, onload, etc.)
- Use SVG profile for allowed elements

Label Sanitization:
- Added sanitizeMermaidLabel() function
- Remove HTML tags from all labels
- Remove dangerous protocols (javascript:, data:, vbscript:)
- Remove control characters
- Escape Mermaid special characters
- Truncate to 200 chars for DoS prevention
- Applied to all node labels in diagrams

Comprehensive XSS Testing:
- 15 test cases covering all attack vectors
- Script tag injection variants
- Event handler injection
- JavaScript/data URL injection
- SVG with embedded scripts
- HTML entity bypass attempts
- All tests passing

Files modified:
- apps/web/src/components/mindmap/MermaidViewer.tsx
- apps/web/src/components/mindmap/hooks/useGraphData.ts
- apps/web/src/components/mindmap/MermaidViewer.test.tsx (new)

Fixes #200

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 22:55:57 -06:00
parent 6ff6957db4
commit f87a28ac55
12 changed files with 537 additions and 9 deletions

View File

@@ -121,6 +121,35 @@ interface UseGraphDataResult {
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
/**
* Sanitize labels for Mermaid diagrams to prevent XSS
* Removes HTML tags, dangerous protocols, and special characters
*/
function sanitizeMermaidLabel(label: string): string {
if (!label) return "";
// Remove HTML tags
let sanitized = label.replace(/<[^>]*>/g, "");
// Remove dangerous protocols
sanitized = sanitized.replace(/javascript:/gi, "");
sanitized = sanitized.replace(/data:/gi, "");
sanitized = sanitized.replace(/vbscript:/gi, "");
// Remove control characters
// eslint-disable-next-line no-control-regex
sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, "");
// Escape Mermaid special characters that could break syntax
sanitized = sanitized.replace(/["\n\r]/g, " ");
sanitized = sanitized.replace(/[[\](){}]/g, "");
// Truncate to prevent DoS
sanitized = sanitized.slice(0, 200);
return sanitized.trim();
}
async function apiFetch<T>(
endpoint: string,
accessToken: string | null,
@@ -311,35 +340,35 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRes
nodesByType[nodeType].push(node);
});
// Add nodes by type
// Add nodes by type with sanitized labels
Object.entries(nodesByType).forEach(([type, nodes]): void => {
diagram += ` ${type}\n`;
diagram += ` ${sanitizeMermaidLabel(type)}\n`;
nodes.forEach((node): void => {
diagram += ` ${node.title}\n`;
diagram += ` ${sanitizeMermaidLabel(node.title)}\n`;
});
});
} else {
diagram = "graph TD\n";
// Add all edges
// Add all edges with sanitized labels
graph.edges.forEach((edge): void => {
const source = graph.nodes.find((n): boolean => n.id === edge.source_id);
const target = graph.nodes.find((n): boolean => n.id === edge.target_id);
if (source && target) {
const sourceLabel = source.title.replace(/["\n]/g, " ");
const targetLabel = target.title.replace(/["\n]/g, " ");
const sourceLabel = sanitizeMermaidLabel(source.title);
const targetLabel = sanitizeMermaidLabel(target.title);
diagram += ` ${edge.source_id}["${sourceLabel}"] --> ${edge.target_id}["${targetLabel}"]\n`;
}
});
// Add standalone nodes (no edges)
// Add standalone nodes (no edges) with sanitized labels
graph.nodes.forEach((node): void => {
const hasEdge = graph.edges.some(
(e): boolean => e.source_id === node.id || e.target_id === node.id
);
if (!hasEdge) {
const label = node.title.replace(/["\n]/g, " ");
const label = sanitizeMermaidLabel(node.title);
diagram += ` ${node.id}["${label}"]\n`;
}
});