Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/**
* @vitest-environment jsdom
*/

import { describe, expect, it } from 'vitest';

import { sanitizeMermaidSvg } from './sanitizeMermaidSvg';

describe('sanitizeMermaidSvg', () => {
describe('XSS vectors are removed', () => {
it('strips <script> elements entirely', () => {
const raw = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><circle cx="1" cy="1" r="1"/></svg>';
const clean = sanitizeMermaidSvg(raw);
expect(clean).not.toContain('<script');
expect(clean).not.toContain('alert(1)');
expect(clean.toLowerCase()).toContain('<svg');
expect(clean.toLowerCase()).toContain('<circle');
});

it('strips on* event handlers from elements', () => {
const raw = '<svg xmlns="http://www.w3.org/2000/svg"><g onload="alert(1)"><circle cx="1" cy="1" r="1" onmouseover="alert(2)"/></g></svg>';
const clean = sanitizeMermaidSvg(raw);
expect(clean).not.toContain('onload');
expect(clean).not.toContain('onmouseover');
expect(clean).not.toContain('alert(2)');
expect(clean.toLowerCase()).toContain('<svg');
});

it('strips javascript: URLs from href/src/xlink:href', () => {
const raw = '<svg xmlns="http://www.w3.org/2000/svg"><a href="javascript:alert(1)">x</a><image xlink:href="javascript:alert(2)"/></svg>';
const clean = sanitizeMermaidSvg(raw);
expect(clean).not.toContain('javascript:');
expect(clean.toLowerCase()).toContain('<svg');
});

it('removes injected HTML/script inside foreignObject labels', () => {
const raw =
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject width="10" height="10">' +
'<div xmlns="http://www.w3.org/1999/xhtml"><img src="x" onerror="alert(1)">' +
'<script>alert(2)</script><p onclick="alert(3)">label</p></div></foreignObject></svg>';
const clean = sanitizeMermaidSvg(raw);
expect(clean).not.toContain('onerror');
expect(clean).not.toContain('onclick');
expect(clean).not.toContain('<script');
expect(clean).not.toContain('alert(1)');
expect(clean).not.toContain('<img');
expect(clean.toLowerCase()).toContain('label');
});

it('returns empty string for malformed / non-svg input', () => {
expect(sanitizeMermaidSvg('')).toBe('');
expect(sanitizeMermaidSvg('<div>not an svg</div>')).toBe('');
});
});

describe('normal mermaid output is preserved', () => {
it('keeps structural svg elements and drops nothing that a rendered diagram needs', () => {
const raw = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="500" height="300" viewBox="0 0 500 300" role="img" aria-roledescription="diagram">
<style>#mermaid-1 .node rect { fill: #fff; }</style>
<defs>
<marker id="mermaid-1_arrow" markerWidth="10" markerHeight="10" orient="auto" refX="9" refY="3">
<path d="M0,0 L0,6 L9,3 z" fill="black"/>
</marker>
<linearGradient id="mermaid-1_g" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#eeeeee"/>
</linearGradient>
</defs>
<g id="mermaid-1" class="node" transform="translate(100,50)">
<rect width="120" height="40" rx="5" ry="5" fill="url(#mermaid-1_g)" stroke="#333" stroke-width="1.5"/>
<text x="60" y="25" text-anchor="middle" dominant-baseline="central" font-size="14" font-family="sans-serif">Process</text>
</g>
<g id="mermaid-1_label" class="edgeLabel">
<foreignObject width="100" height="30" x="0" y="0">
<div xmlns="http://www.w3.org/1999/xhtml" class="nodeLabel" style="text-align:center;"><p>Hello</p></div>
</foreignObject>
</g>
</svg>`;
const clean = sanitizeMermaidSvg(raw);
const lower = clean.toLowerCase();
expect(lower).toContain('<svg');
expect(lower).toContain('<path');
expect(lower).toContain('<rect');
expect(lower).toContain('<text');
expect(lower).toContain('<foreignobject');
expect(lower).toContain('<div');
expect(lower).toContain('<style');
expect(lower).toContain('class="nodelabel"');
expect(lower).toContain('viewbox');
// safety invariants on a normal diagram
expect(clean).not.toContain('<script');
expect(clean).not.toMatch(/on\w+=/i);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { MermaidService } from '../../../tools/mermaid-editor/services/MermaidSe
import { mermaidAppearanceAdapter } from '@/infrastructure/appearance/adapters/MermaidAppearanceAdapter';
import { Loader2, AlertCircle, Code2, Copy, Check } from 'lucide-react';
import { createLogger } from '@/shared/utils/logger';
import { sanitizeMermaidSvg } from './sanitizeMermaidSvg';
import './MermaidBlock.scss';

const log = createLogger('MermaidBlock');
Expand Down Expand Up @@ -206,7 +207,7 @@ export const MermaidBlock: React.FC<MermaidBlockProps> = ({
className="mermaid-block__diagram"
data-bf-component="mermaid-block"
data-bf-part="diagram"
dangerouslySetInnerHTML={{ __html: svgContent }}
dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svgContent) }}
/>

<div data-bf-component="mermaid-block" data-bf-part="actions" className="mermaid-block__actions">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/**
* Sanitizer for Mermaid-rendered SVG markup.
*
* Mermaid is initialized with `securityLevel: 'loose'`, which allows label
* markup (raw HTML in labels) to pass through rendering. A crafted diagram
* can therefore carry `<script>`, event-handler attributes, `javascript:` URLs
* or foreignObject payloads into the rendered output. This module strips those
* vectors while keeping the structural SVG and text-container elements that a
* rendered diagram needs.
*/

/** Elements that must never survive sanitization. */
const DANGEROUS_TAGS = new Set([
'script', 'iframe', 'object', 'embed', 'form', 'meta', 'link', 'base',
'frame', 'frameset', 'noscript', 'template',
]);

/** Structural SVG / HTML elements that rendered diagrams legitimately use. */
const ALLOWED_SVG_TAGS = new Set([
// svg structure / shapes
'svg', 'g', 'defs', 'style', 'marker', 'path', 'rect', 'circle', 'ellipse',
'line', 'polyline', 'polygon', 'text', 'tspan', 'title', 'desc', 'use',
'symbol', 'textpath', 'clippath', 'stop', 'pattern', 'mask', 'filter',
'fegaussianblur', 'fecolormatrix', 'feoffset', 'feflood', 'feblend',
'image', 'a',
// gradient / pattern
'lineargradient', 'radialgradient',
// foreignObject content (mermaid htmlLabels) + text containers
'foreignobject', 'div', 'span', 'p', 'br', 'b', 'strong', 'i', 'em', 'u',
'small', 'sub', 'sup', 'ul', 'ol', 'li', 'pre', 'code', 'font',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'figure', 'h1', 'h2',
'h3', 'h4', 'h5', 'h6',
]);

/** Attributes that rendered diagrams legitimately use. */
const ALLOWED_SVG_ATTRS = new Set([
// core + namespace
'id', 'class', 'xmlns', 'xmlns:xlink', 'xlink:href', 'xlink:title',
'xml:space', 'role', 'aria-label', 'aria-roledescription', 'aria-hidden',
'focusable', 'tabindex', 'title',
// geometry / viewport
'viewbox', 'preserveaspectratio', 'width', 'height', 'x', 'y', 'x1', 'y1',
'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry', 'd', 'points', 'pathlength',
'transform', 'translate', 'scale', 'rotate', 'skewx', 'skewy', 'matrix',
'offset', 'refx', 'refy', 'refwidth', 'refheight',
// painting
'fill', 'fill-opacity', 'fill-rule', 'stroke', 'stroke-width',
'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-dasharray',
'stroke-dashoffset', 'stroke-opacity', 'opacity', 'color', 'flood-color',
'flood-opacity', 'stop-color', 'stop-opacity',
// typography / text
'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant',
'text-anchor', 'dominant-baseline', 'baseline-shift', 'letter-spacing',
'word-spacing', 'text-decoration', 'direction', 'unicode-bidi',
'writing-mode', 'alignment-baseline', 'line-height', 'text-transform',
'white-space', 'font-stretch',
// markers / gradients / patterns / filters
'marker-start', 'marker-mid', 'marker-end', 'marker', 'markerwidth',
'markerheight', 'markerunits', 'orient', 'gradientunits', 'gradienttransform',
'spreadmethod', 'patternunits', 'patterncontentunits', 'patterntransform',
'maskunits', 'maskcontentunits', 'in', 'in2', 'result', 'stddeviation',
'edgemode', 'kernelunitlength', 'tablevalues', 'values', 'type', 'mode',
'interpolate', 'numoctaves', 'basefrequency', 'stitchtiles',
// styles / display
'style', 'display', 'visibility', 'overflow', 'clip', 'clip-path', 'clip-rule',
'vector-effect', 'shape-rendering', 'text-rendering', 'image-rendering',
'color-rendering', 'color-interpolation', 'color-interpolation-filters',
'paint-order', 'mix-blend-mode', 'isolation', 'pointer-events', 'cursor',
'filter', 'mask', 'enable-background',
// animation
'attributename', 'attributetype', 'begin', 'dur', 'end', 'repeatcount',
'repeatdur', 'from', 'to', 'by', 'calcmode', 'additive', 'keytimes',
'keysplines', 'keypoints', 'restart', 'fill-freeze',
// html container attributes
'align', 'valign', 'colspan', 'rowspan', 'cellpadding', 'cellspacing',
'border', 'bgcolor', 'nowrap', 'dir', 'lang', 'col', 'row', 'span',
'start', 'reversed', 'data-label',
]);

const EVENT_ATTR_RE = /^on/i;
const URL_ATTRS = new Set(['href', 'src', 'xlink:href']);
const UNSAFE_URL_RE = /^\s*(?:javascript|vbscript|data:text\/html)\s*:/i;

/**
* Sanitize Mermaid-rendered SVG markup before it is injected via
* dangerouslySetInnerHTML. Anything outside the allowlist (dangerous tags,
* unknown elements injected through labels, `on*` handlers, unsafe URL
* schemes) is removed. Input that does not parse into a single root `<svg>`
* element is dropped entirely.
*/
export function sanitizeMermaidSvg(svgRaw: string): string {
if (!svgRaw) return svgRaw;

let doc: Document;
try {
doc = new DOMParser().parseFromString(svgRaw, 'text/html');
} catch {
return '';
}

const root = doc.body.firstElementChild;
if (!root || root.localName.toLowerCase() !== 'svg') return '';

const sanitizeNode = (node: Element) => {
const tag = node.localName.toLowerCase();
// Any element outside the allowed set (scripts, iframes, unknown HTML
// injected via labels, etc.) is removed together with its subtree.
if (DANGEROUS_TAGS.has(tag) || !ALLOWED_SVG_TAGS.has(tag)) {
node.remove();
return;
}

for (const attr of Array.from(node.attributes)) {
const name = attr.name;
const lowerName = name.toLowerCase();
if (EVENT_ATTR_RE.test(lowerName)) {
node.removeAttribute(name);
continue;
}
if (URL_ATTRS.has(lowerName) && UNSAFE_URL_RE.test(attr.value)) {
node.removeAttribute(name);
continue;
}
if (!ALLOWED_SVG_ATTRS.has(lowerName)) {
node.removeAttribute(name);
}
}

Array.from(node.children).forEach((child) => sanitizeNode(child));
};

sanitizeNode(root);
return root.outerHTML;
}