From ff41e182d71fba5cd12da594da6193c3e88a1161 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 13:27:06 +0000 Subject: [PATCH 1/7] feat: add sandboxBaseUrl config option for resolving relative URLs in sandbox mode When securityLevel is 'sandbox', diagrams are rendered inside a data: URI iframe. Since data URIs have no base URL context, relative URLs in diagram links (e.g., "./page.html") cannot be resolved by the browser and fail to navigate. This change adds a `sandboxBaseUrl` configuration option that, when provided, pre-resolves all relative URLs in the rendered SVG to absolute URLs before base64-encoding and embedding in the sandbox iframe. Changes: - Add `sandboxBaseUrl` to config schema and TypeScript types - Create `resolveRelativeUrls` utility in `utils/sandboxUrl.ts` - Integrate URL resolution into `putIntoIFrame` function - Add comprehensive unit tests for the URL resolution logic Example usage: ```javascript mermaid.initialize({ securityLevel: 'sandbox', sandboxBaseUrl: 'https://example.com/docs/', flowchart: { htmlLabels: false } }); ``` --- .cspell/code-terms.txt | 1 + .../setup/mermaid/interfaces/MermaidConfig.md | 86 ++++++---- packages/mermaid/src/config.type.ts | 15 ++ packages/mermaid/src/mermaidAPI.ts | 21 ++- .../mermaid/src/schemas/config.schema.yaml | 14 ++ packages/mermaid/src/utils/sandboxUrl.spec.ts | 159 ++++++++++++++++++ packages/mermaid/src/utils/sandboxUrl.ts | 93 ++++++++++ 7 files changed, 353 insertions(+), 36 deletions(-) create mode 100644 packages/mermaid/src/utils/sandboxUrl.spec.ts create mode 100644 packages/mermaid/src/utils/sandboxUrl.ts diff --git a/.cspell/code-terms.txt b/.cspell/code-terms.txt index 5f72ea22130..8b44cbb9c68 100644 --- a/.cspell/code-terms.txt +++ b/.cspell/code-terms.txt @@ -89,6 +89,7 @@ MULT NODIR NSTR outdir +parsererror Qcontrolx QSTR reinit diff --git a/docs/config/setup/mermaid/interfaces/MermaidConfig.md b/docs/config/setup/mermaid/interfaces/MermaidConfig.md index f4c5b0b2b5e..36aa63a05fe 100644 --- a/docs/config/setup/mermaid/interfaces/MermaidConfig.md +++ b/docs/config/setup/mermaid/interfaces/MermaidConfig.md @@ -26,7 +26,7 @@ Defined in: [packages/mermaid/src/config.type.ts:132](https://github.com/mermaid > `optional` **architecture**: `ArchitectureDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:204](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L204) +Defined in: [packages/mermaid/src/config.type.ts:219](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L219) --- @@ -34,7 +34,7 @@ Defined in: [packages/mermaid/src/config.type.ts:204](https://github.com/mermaid > `optional` **arrowMarkerAbsolute**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:151](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L151) +Defined in: [packages/mermaid/src/config.type.ts:166](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L166) Controls whether or arrow markers in html code are absolute paths or anchors. This matters if you are using base tag settings. @@ -45,7 +45,7 @@ This matters if you are using base tag settings. > `optional` **block**: `BlockDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L211) +Defined in: [packages/mermaid/src/config.type.ts:226](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L226) --- @@ -53,7 +53,7 @@ Defined in: [packages/mermaid/src/config.type.ts:211](https://github.com/mermaid > `optional` **c4**: `C4DiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:208](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L208) +Defined in: [packages/mermaid/src/config.type.ts:223](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L223) --- @@ -61,7 +61,7 @@ Defined in: [packages/mermaid/src/config.type.ts:208](https://github.com/mermaid > `optional` **class**: `ClassDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:197](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L197) +Defined in: [packages/mermaid/src/config.type.ts:212](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L212) --- @@ -77,7 +77,7 @@ Defined in: [packages/mermaid/src/config.type.ts:123](https://github.com/mermaid > `optional` **deterministicIds**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:184](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L184) +Defined in: [packages/mermaid/src/config.type.ts:199](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L199) This option controls if the generated ids of nodes in the SVG are generated randomly or based on a seed. @@ -93,7 +93,7 @@ should not change unless content is changed. > `optional` **deterministicIDSeed**: `string` -Defined in: [packages/mermaid/src/config.type.ts:191](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L191) +Defined in: [packages/mermaid/src/config.type.ts:206](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L206) This option is the optional seed for deterministic ids. If set to `undefined` but deterministicIds is `true`, a simple number iterator is used. @@ -105,7 +105,7 @@ You can set this attribute to base the seed on a static string. > `optional` **dompurifyConfig**: `Config` -Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213) +Defined in: [packages/mermaid/src/config.type.ts:228](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L228) --- @@ -151,7 +151,7 @@ Elk specific option affecting how nodes are placed. > `optional` **er**: `ErDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:199](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L199) +Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214) --- @@ -159,7 +159,7 @@ Defined in: [packages/mermaid/src/config.type.ts:199](https://github.com/mermaid > `optional` **flowchart**: `FlowchartDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:192](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L192) +Defined in: [packages/mermaid/src/config.type.ts:207](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L207) --- @@ -179,7 +179,7 @@ See > `optional` **fontSize**: `number` -Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) +Defined in: [packages/mermaid/src/config.type.ts:230](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L230) --- @@ -187,7 +187,7 @@ Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid > `optional` **forceLegacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L173) +Defined in: [packages/mermaid/src/config.type.ts:188](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L188) This option forces Mermaid to rely on KaTeX's own stylesheet for rendering MathML. Due to differences between OS fonts and browser's MathML implementation, this option is recommended if consistent rendering is important. @@ -199,7 +199,7 @@ If set to true, ignores legacyMathML. > `optional` **gantt**: `GanttDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:194](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L194) +Defined in: [packages/mermaid/src/config.type.ts:209](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L209) --- @@ -207,7 +207,7 @@ Defined in: [packages/mermaid/src/config.type.ts:194](https://github.com/mermaid > `optional` **gitGraph**: `GitGraphDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:207](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L207) +Defined in: [packages/mermaid/src/config.type.ts:222](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L222) --- @@ -233,7 +233,7 @@ Defined in: [packages/mermaid/src/config.type.ts:124](https://github.com/mermaid > `optional` **journey**: `JourneyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:195](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L195) +Defined in: [packages/mermaid/src/config.type.ts:210](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L210) --- @@ -241,7 +241,7 @@ Defined in: [packages/mermaid/src/config.type.ts:195](https://github.com/mermaid > `optional` **kanban**: `KanbanDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:206](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L206) +Defined in: [packages/mermaid/src/config.type.ts:221](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L221) --- @@ -259,7 +259,7 @@ Defines which layout algorithm to use for rendering the diagram. > `optional` **legacyMathML**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:166](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L166) +Defined in: [packages/mermaid/src/config.type.ts:181](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L181) This option specifies if Mermaid can expect the dependent to include KaTeX stylesheets for browsers without their own MathML implementation. If this option is disabled and MathML is not supported, the math @@ -292,7 +292,7 @@ Defines which main look to use for the diagram. > `optional` **markdownAutoWrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216) +Defined in: [packages/mermaid/src/config.type.ts:231](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L231) --- @@ -320,7 +320,7 @@ The maximum allowed size of the users text diagram > `optional` **mindmap**: `MindmapDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:205](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L205) +Defined in: [packages/mermaid/src/config.type.ts:220](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L220) --- @@ -328,7 +328,7 @@ Defined in: [packages/mermaid/src/config.type.ts:205](https://github.com/mermaid > `optional` **packet**: `PacketDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:210](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L210) +Defined in: [packages/mermaid/src/config.type.ts:225](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L225) --- @@ -336,7 +336,7 @@ Defined in: [packages/mermaid/src/config.type.ts:210](https://github.com/mermaid > `optional` **pie**: `PieDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:200](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L200) +Defined in: [packages/mermaid/src/config.type.ts:215](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L215) --- @@ -344,7 +344,7 @@ Defined in: [packages/mermaid/src/config.type.ts:200](https://github.com/mermaid > `optional` **quadrantChart**: `QuadrantChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:201](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L201) +Defined in: [packages/mermaid/src/config.type.ts:216](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L216) --- @@ -352,7 +352,7 @@ Defined in: [packages/mermaid/src/config.type.ts:201](https://github.com/mermaid > `optional` **radar**: `RadarDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:212](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L212) +Defined in: [packages/mermaid/src/config.type.ts:227](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L227) --- @@ -360,7 +360,27 @@ Defined in: [packages/mermaid/src/config.type.ts:212](https://github.com/mermaid > `optional` **requirement**: `RequirementDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:203](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L203) +Defined in: [packages/mermaid/src/config.type.ts:218](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L218) + +--- + +### sandboxBaseUrl? + +> `optional` **sandboxBaseUrl**: `string` + +Defined in: [packages/mermaid/src/config.type.ts:156](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L156) + +Base URL for resolving relative links in sandbox mode. + +When securityLevel is 'sandbox', relative URLs in diagram links cannot be resolved +because data: URIs have no base URL context. Providing sandboxBaseUrl enables Mermaid +to pre-resolve all relative URLs to absolute URLs before embedding in the sandbox iframe. + +This option only applies when securityLevel is 'sandbox'. + +Example: If your page is at , +set sandboxBaseUrl to '' to enable relative links +like './details.html' to work correctly. --- @@ -368,7 +388,7 @@ Defined in: [packages/mermaid/src/config.type.ts:203](https://github.com/mermaid > `optional` **sankey**: `SankeyDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:209](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L209) +Defined in: [packages/mermaid/src/config.type.ts:224](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L224) --- @@ -376,7 +396,7 @@ Defined in: [packages/mermaid/src/config.type.ts:209](https://github.com/mermaid > `optional` **secure**: `string`\[] -Defined in: [packages/mermaid/src/config.type.ts:158](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L158) +Defined in: [packages/mermaid/src/config.type.ts:173](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L173) This option controls which `currentConfig` keys are considered secure and can only be changed via call to `mermaid.initialize`. @@ -398,7 +418,7 @@ Level of trust for parsed diagram > `optional` **sequence**: `SequenceDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:193](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L193) +Defined in: [packages/mermaid/src/config.type.ts:208](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L208) --- @@ -406,7 +426,7 @@ Defined in: [packages/mermaid/src/config.type.ts:193](https://github.com/mermaid > `optional` **startOnLoad**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:145](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L145) +Defined in: [packages/mermaid/src/config.type.ts:160](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L160) Dictates whether mermaid starts on Page load @@ -416,7 +436,7 @@ Dictates whether mermaid starts on Page load > `optional` **state**: `StateDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:198](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L198) +Defined in: [packages/mermaid/src/config.type.ts:213](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L213) --- @@ -424,7 +444,7 @@ Defined in: [packages/mermaid/src/config.type.ts:198](https://github.com/mermaid > `optional` **suppressErrorRendering**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:222](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L222) +Defined in: [packages/mermaid/src/config.type.ts:237](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L237) Suppresses inserting 'Syntax error' diagram in the DOM. This is useful when you want to control how to handle syntax errors in your application. @@ -462,7 +482,7 @@ Defined in: [packages/mermaid/src/config.type.ts:65](https://github.com/mermaid- > `optional` **timeline**: `TimelineDiagramConfig` -Defined in: [packages/mermaid/src/config.type.ts:196](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L196) +Defined in: [packages/mermaid/src/config.type.ts:211](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L211) --- @@ -470,7 +490,7 @@ Defined in: [packages/mermaid/src/config.type.ts:196](https://github.com/mermaid > `optional` **wrap**: `boolean` -Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L214) +Defined in: [packages/mermaid/src/config.type.ts:229](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L229) --- @@ -478,4 +498,4 @@ Defined in: [packages/mermaid/src/config.type.ts:214](https://github.com/mermaid > `optional` **xyChart**: `XYChartConfig` -Defined in: [packages/mermaid/src/config.type.ts:202](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L202) +Defined in: [packages/mermaid/src/config.type.ts:217](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/config.type.ts#L217) diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 79fadd19531..66eec8f65a3 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -139,6 +139,21 @@ export interface MermaidConfig { * Level of trust for parsed diagram */ securityLevel?: 'strict' | 'loose' | 'antiscript' | 'sandbox'; + /** + * Base URL for resolving relative links in sandbox mode. + * + * When securityLevel is 'sandbox', relative URLs in diagram links cannot be resolved + * because data: URIs have no base URL context. Providing sandboxBaseUrl enables Mermaid + * to pre-resolve all relative URLs to absolute URLs before embedding in the sandbox iframe. + * + * This option only applies when securityLevel is 'sandbox'. + * + * Example: If your page is at https://example.com/docs/diagrams/arch.html, + * set sandboxBaseUrl to 'https://example.com/docs/diagrams/' to enable relative links + * like './details.html' to work correctly. + * + */ + sandboxBaseUrl?: string; /** * Dictates whether mermaid starts on Page load */ diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index 80deda740fc..ea666757076 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -24,6 +24,7 @@ import theme from './themes/index.js'; import type { D3Element, ParseOptions, ParseResult, RenderResult } from './types.js'; import { decodeEntities } from './utils.js'; import { toBase64 } from './utils/base64.js'; +import { resolveRelativeUrls } from './utils/sandboxUrl.js'; const MAX_TEXTLENGTH = 50_000; const MAX_TEXTLENGTH_EXCEEDED_MSG = @@ -206,13 +207,27 @@ export const cleanUpSvgCode = ( * * @param svgCode - the svg code to put inside the iFrame * @param svgElement - the d3 node that has the current svgElement so we can get the height from it + * @param sandboxBaseUrl - optional base URL for resolving relative URLs in sandbox mode * @returns - the code with the iFrame that now contains the svgCode */ -export const putIntoIFrame = (svgCode = '', svgElement?: D3Element): string => { +export const putIntoIFrame = ( + svgCode = '', + svgElement?: D3Element, + sandboxBaseUrl?: string +): string => { const height = svgElement?.viewBox?.baseVal?.height ? svgElement.viewBox.baseVal.height + 'px' : IFRAME_HEIGHT; - const base64encodedSrc = toBase64(`${svgCode}`); + + // Resolve relative URLs if sandboxBaseUrl is provided + let processedSvgCode = svgCode; + if (sandboxBaseUrl) { + processedSvgCode = resolveRelativeUrls(svgCode, sandboxBaseUrl); + } + + const base64encodedSrc = toBase64( + `${processedSvgCode}` + ); return ``; @@ -447,7 +462,7 @@ const render = async function ( if (isSandboxed) { const svgEl = root.select(enclosingDivID_selector + ' svg').node(); - svgCode = putIntoIFrame(svgCode, svgEl); + svgCode = putIntoIFrame(svgCode, svgEl, config.sandboxBaseUrl); } else if (!isLooseSecurityLevel) { // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 4b75c9704bf..4aca02e8454 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -207,6 +207,20 @@ properties: This prevent any JavaScript from running in the context. This may hinder interactive functionality of the diagram, like scripts, popups in the sequence diagram, or links to other tabs or targets, etc. default: strict + sandboxBaseUrl: + description: | + Base URL for resolving relative links in sandbox mode. + + When securityLevel is 'sandbox', relative URLs in diagram links cannot be resolved + because data: URIs have no base URL context. Providing sandboxBaseUrl enables Mermaid + to pre-resolve all relative URLs to absolute URLs before embedding in the sandbox iframe. + + This option only applies when securityLevel is 'sandbox'. + + Example: If your page is at https://example.com/docs/diagrams/arch.html, + set sandboxBaseUrl to 'https://example.com/docs/diagrams/' to enable relative links + like './details.html' to work correctly. + type: string startOnLoad: description: Dictates whether mermaid starts on Page load type: boolean diff --git a/packages/mermaid/src/utils/sandboxUrl.spec.ts b/packages/mermaid/src/utils/sandboxUrl.spec.ts new file mode 100644 index 00000000000..eb753a866aa --- /dev/null +++ b/packages/mermaid/src/utils/sandboxUrl.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { isAbsoluteUrl, resolveRelativeUrls } from './sandboxUrl.js'; + +describe('isAbsoluteUrl', () => { + it('should return true for http:// URLs', () => { + expect(isAbsoluteUrl('http://example.com')).toBe(true); + expect(isAbsoluteUrl('http://example.com/page.html')).toBe(true); + }); + + it('should return true for https:// URLs', () => { + expect(isAbsoluteUrl('https://example.com')).toBe(true); + expect(isAbsoluteUrl('https://example.com/page.html')).toBe(true); + }); + + it('should return true for other protocol schemes', () => { + expect(isAbsoluteUrl('mailto:user@example.com')).toBe(true); + expect(isAbsoluteUrl('javascript:void(0)')).toBe(true); + expect(isAbsoluteUrl('data:text/html,

Hello

')).toBe(true); + expect(isAbsoluteUrl('ftp://example.com/file')).toBe(true); + }); + + it('should return true for protocol-relative URLs', () => { + expect(isAbsoluteUrl('//example.com')).toBe(true); + expect(isAbsoluteUrl('//cdn.example.com/resource.js')).toBe(true); + }); + + it('should return false for relative paths', () => { + expect(isAbsoluteUrl('./page.html')).toBe(false); + expect(isAbsoluteUrl('../other.html')).toBe(false); + expect(isAbsoluteUrl('page.html')).toBe(false); + expect(isAbsoluteUrl('folder/page.html')).toBe(false); + }); + + it('should return false for absolute paths (without protocol)', () => { + expect(isAbsoluteUrl('/root.html')).toBe(false); + expect(isAbsoluteUrl('/docs/guide.html')).toBe(false); + }); + + it('should return false for hash links', () => { + expect(isAbsoluteUrl('#section')).toBe(false); + expect(isAbsoluteUrl('#')).toBe(false); + }); +}); + +describe('resolveRelativeUrls', () => { + const baseUrl = 'https://example.com/docs/diagrams/'; + + it('should resolve relative paths (./) in href attributes', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://example.com/docs/diagrams/page.html"'); + }); + + it('should resolve relative paths (./) in xlink:href attributes', () => { + const svg = + 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('xlink:href="https://example.com/docs/diagrams/page.html"'); + }); + + it('should resolve parent directory paths (../)', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://example.com/docs/other.html"'); + }); + + it('should resolve absolute paths (/)', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://example.com/root.html"'); + }); + + it('should resolve hash links against base URL', () => { + const svg = 'Link'; + const baseUrlWithFile = 'https://example.com/docs/index.html'; + const result = resolveRelativeUrls(svg, baseUrlWithFile); + expect(result).toContain('href="https://example.com/docs/index.html#section"'); + }); + + it('should not modify absolute URLs with http://', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="http://other.com/page"'); + }); + + it('should not modify absolute URLs with https://', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://other.com/page"'); + }); + + it('should not modify protocol-relative URLs', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="//cdn.example.com/resource"'); + }); + + it('should not modify mailto: URLs', () => { + const svg = 'Email'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="mailto:user@example.com"'); + }); + + it('should not modify javascript: URLs', () => { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="javascript:void(0)"'); + }); + + it('should handle both href and xlink:href in the same SVG', () => { + const svg = ` + A + B + `; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://example.com/docs/diagrams/a.html"'); + expect(result).toContain('xlink:href="https://example.com/docs/diagrams/b.html"'); + }); + + it('should handle multiple links with mixed URLs', () => { + const svg = ` + Relative + Absolute + Parent + Protocol + `; + const result = resolveRelativeUrls(svg, baseUrl); + + expect(result).toContain('href="https://example.com/docs/diagrams/relative.html"'); + expect(result).toContain('href="https://absolute.com"'); + expect(result).toContain('xlink:href="https://example.com/docs/parent.html"'); + expect(result).toContain('href="//protocol-relative.com"'); + }); + + it('should return original content if SVG parsing fails', () => { + const invalidSvg = '>'; + const result = resolveRelativeUrls(invalidSvg, baseUrl); + expect(result).toBe(invalidSvg); + }); + + it('should handle empty SVG', () => { + const svg = ''; + const result = resolveRelativeUrls(svg, baseUrl); + // XMLSerializer may output self-closing tag or + expect(result).toMatch(/|<\/svg>/); + }); + + it('should handle SVG with no links', () => { + const svg = ''; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain(' { + const svg = 'Link'; + const result = resolveRelativeUrls(svg, baseUrl); + expect(result).toContain('href="https://example.com/docs/diagrams/page.html"'); + }); +}); diff --git a/packages/mermaid/src/utils/sandboxUrl.ts b/packages/mermaid/src/utils/sandboxUrl.ts new file mode 100644 index 00000000000..031bbeb8a98 --- /dev/null +++ b/packages/mermaid/src/utils/sandboxUrl.ts @@ -0,0 +1,93 @@ +/** + * Utilities for resolving relative URLs in sandbox mode. + * + * When securityLevel is 'sandbox', diagrams are rendered inside a data: URI iframe. + * Data URIs have no base URL context, so relative URLs in diagram links + * (e.g., "./page.html") cannot be resolved by the browser. + * + * This module provides functionality to pre-resolve relative URLs to absolute URLs + * before base64-encoding and embedding in the sandbox iframe. + */ + +const XLINK_NS = 'http://www.w3.org/1999/xlink'; + +/** + * Checks if a URL is absolute (should not be modified). + * + * A URL is considered absolute if it: + * - Starts with a protocol scheme (http://, https://, mailto:, etc.) + * - Starts with protocol-relative prefix (//) + * + * @param url - The URL to check + * @returns true if the URL is absolute + */ +export function isAbsoluteUrl(url: string): boolean { + // Protocol schemes (http:, https:, mailto:, javascript:, data:, etc.) + if (/^[A-Za-z][\d+.A-Za-z-]*:/.test(url)) { + return true; + } + // Protocol-relative URLs + if (url.startsWith('//')) { + return true; + } + return false; +} + +/** + * Resolves relative URLs in SVG content against a base URL. + * Used for sandbox mode to enable link navigation from data URI iframes. + * + * This function finds all elements with href or xlink:href attributes + * and resolves relative URLs to absolute URLs using the provided base URL. + * + * @param svgContent - The rendered SVG markup string + * @param baseUrl - The base URL to resolve relative URLs against + * @returns SVG markup with all relative URLs resolved to absolute + */ +export function resolveRelativeUrls(svgContent: string, baseUrl: string): string { + // Parse SVG as XML + const parser = new DOMParser(); + const doc = parser.parseFromString(svgContent, 'image/svg+xml'); + + // Check for parsing errors + const parserError = doc.querySelector('parsererror'); + if (parserError) { + // If parsing fails, return original content unchanged + return svgContent; + } + + // Find all elements with href or xlink:href attributes + // We need to handle both standard href and xlink:href (used in SVG) + const elementsWithHref = doc.querySelectorAll('[href]'); + const elementsWithXlinkHref = doc.querySelectorAll('[*|href]'); + + // Process regular href attributes + for (const element of elementsWithHref) { + const href = element.getAttribute('href'); + if (href && !isAbsoluteUrl(href)) { + try { + const absoluteUrl = new URL(href, baseUrl).href; + element.setAttribute('href', absoluteUrl); + } catch { + // If URL resolution fails, leave the original href unchanged + } + } + } + + // Process xlink:href attributes (common in SVG) + for (const element of elementsWithXlinkHref) { + const xlinkHref = element.getAttributeNS(XLINK_NS, 'href'); + if (xlinkHref && !isAbsoluteUrl(xlinkHref)) { + try { + const absoluteUrl = new URL(xlinkHref, baseUrl).href; + element.setAttributeNS(XLINK_NS, 'xlink:href', absoluteUrl); + } catch { + // If URL resolution fails, leave the original href unchanged + } + } + } + + // Serialize back to string + const serializer = new XMLSerializer(); + return serializer.serializeToString(doc); +} From de2fbe8a6cd980f25a756c0f6213137e8a9d12a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 13:28:54 +0000 Subject: [PATCH 2/7] chore: add node-compile-cache to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7eb55d5cbab..7931d1ed618 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage/ dist v8-compile-cache-0 +node-compile-cache/ yarn-error.log .npmrc From fd5018914b7d6d1a14a9d6017150114f2aae048c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 15:09:58 +0000 Subject: [PATCH 3/7] test: add sandboxBaseUrl feature test page --- demos/sandboxBaseUrl-test.html | 284 +++++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 demos/sandboxBaseUrl-test.html diff --git a/demos/sandboxBaseUrl-test.html b/demos/sandboxBaseUrl-test.html new file mode 100644 index 00000000000..a80fe1bd2e1 --- /dev/null +++ b/demos/sandboxBaseUrl-test.html @@ -0,0 +1,284 @@ + + + + + + sandboxBaseUrl Feature Test + + + +

🧪 sandboxBaseUrl Feature Test

+ +
+ What this tests: The sandboxBaseUrl config option enables + relative URLs in diagram links to work correctly when using + securityLevel: 'sandbox'. +
+ +
+ Before testing: You need to build mermaid first. Run from the repo root: +
pnpm install && pnpm build
+
+ + +
+

Test 1: With sandboxBaseUrl (should work)

+
+ Expected: Clicking "Details Page" should navigate to the resolved URL + (shown in browser console). Clicking "GitHub" should open GitHub. +
+
+
+flowchart TD
+    A[Home Page] --> B[Details Page]
+    A --> C[GitHub]
+
+    click A "./" "Go to current directory"
+    click B "./details.html" "View details page"
+    click C "https://github.com/mermaid-js/mermaid" "View GitHub"
+      
+
+
+ + +
+

Test 2: Without sandboxBaseUrl (relative links won't work)

+
+ Expected: Clicking "Details Page" will fail (navigate to + about:blank#blocked). Clicking "GitHub" should still work (absolute URL). +
+
+
+flowchart TD
+    A[Home Page] --> B[Details Page]
+    A --> C[GitHub]
+
+    click A "./" "Go to current directory"
+    click B "./details.html" "View details page"
+    click C "https://github.com/mermaid-js/mermaid" "View GitHub"
+      
+
+
+ + +
+

Test 3: Various URL Types with sandboxBaseUrl

+
+ Tests different URL patterns: relative (./, ../), absolute (/), hash (#), and external + (https://) +
+
+
+flowchart LR
+    A[Relative ./] --> B[Parent ../]
+    B --> C[Absolute /]
+    C --> D[Hash #section]
+    D --> E[External URL]
+
+    click A "./page.html" "Relative path"
+    click B "../parent.html" "Parent directory"
+    click C "/root.html" "Absolute path"
+    click D "#test-section" "Hash link"
+    click E "https://mermaid.js.org" "External site"
+      
+
+
+ + +
+

Test 4: Strict Mode (non-sandbox) - Links Disabled

+
+ In strict mode, click functionality is disabled entirely (this is expected + behavior). +
+
+
+flowchart TD
+    A[Click me] --> B[Won't work]
+    click A "https://github.com" "This link is disabled in strict mode"
+      
+
+
+ +
+

Console Output

+
+ Check the browser console (F12) for resolved URLs and any errors. +
+
+ Waiting for mermaid to load... +
+
+ + + + + + + From 9b5e468de72f568aef14aaeda11afdae77f44905 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 16:03:14 +0000 Subject: [PATCH 4/7] refactor: resolve URLs directly on DOM before innerHTML extraction Improves performance by eliminating parse/serialize round-trip: - Rename resolveRelativeUrls -> resolveRelativeUrlsInElement - Operate directly on live DOM element instead of parsing SVG string - Call URL resolution before innerHTML extraction in mermaidAPI - Remove sandboxBaseUrl parameter from putIntoIFrame - Update tests to work with DOM elements --- packages/mermaid/src/mermaidAPI.ts | 29 ++-- packages/mermaid/src/utils/sandboxUrl.spec.ts | 159 +++++++++++------- packages/mermaid/src/utils/sandboxUrl.ts | 41 ++--- 3 files changed, 118 insertions(+), 111 deletions(-) diff --git a/packages/mermaid/src/mermaidAPI.ts b/packages/mermaid/src/mermaidAPI.ts index ea666757076..2544b0ac8a2 100644 --- a/packages/mermaid/src/mermaidAPI.ts +++ b/packages/mermaid/src/mermaidAPI.ts @@ -24,7 +24,7 @@ import theme from './themes/index.js'; import type { D3Element, ParseOptions, ParseResult, RenderResult } from './types.js'; import { decodeEntities } from './utils.js'; import { toBase64 } from './utils/base64.js'; -import { resolveRelativeUrls } from './utils/sandboxUrl.js'; +import { resolveRelativeUrlsInElement } from './utils/sandboxUrl.js'; const MAX_TEXTLENGTH = 50_000; const MAX_TEXTLENGTH_EXCEEDED_MSG = @@ -207,27 +207,14 @@ export const cleanUpSvgCode = ( * * @param svgCode - the svg code to put inside the iFrame * @param svgElement - the d3 node that has the current svgElement so we can get the height from it - * @param sandboxBaseUrl - optional base URL for resolving relative URLs in sandbox mode * @returns - the code with the iFrame that now contains the svgCode */ -export const putIntoIFrame = ( - svgCode = '', - svgElement?: D3Element, - sandboxBaseUrl?: string -): string => { +export const putIntoIFrame = (svgCode = '', svgElement?: D3Element): string => { const height = svgElement?.viewBox?.baseVal?.height ? svgElement.viewBox.baseVal.height + 'px' : IFRAME_HEIGHT; - // Resolve relative URLs if sandboxBaseUrl is provided - let processedSvgCode = svgCode; - if (sandboxBaseUrl) { - processedSvgCode = resolveRelativeUrls(svgCode, sandboxBaseUrl); - } - - const base64encodedSrc = toBase64( - `${processedSvgCode}` - ); + const base64encodedSrc = toBase64(`${svgCode}`); return ``; @@ -454,6 +441,13 @@ const render = async function ( // Clean up SVG code root.select(`[id="${id}"]`).selectAll('foreignobject > *').attr('xmlns', XMLNS_XHTML_STD); + // Resolve relative URLs in sandbox mode before extracting innerHTML + // This must happen before serialization to avoid parse/serialize round-trip + const svgEl = root.select(enclosingDivID_selector + ' svg').node(); + if (isSandboxed && config.sandboxBaseUrl) { + resolveRelativeUrlsInElement(svgEl, config.sandboxBaseUrl); + } + // Fix for when the base tag is used let svgCode: string = root.select(enclosingDivID_selector).node().innerHTML; @@ -461,8 +455,7 @@ const render = async function ( svgCode = cleanUpSvgCode(svgCode, isSandboxed, evaluate(config.arrowMarkerAbsolute)); if (isSandboxed) { - const svgEl = root.select(enclosingDivID_selector + ' svg').node(); - svgCode = putIntoIFrame(svgCode, svgEl, config.sandboxBaseUrl); + svgCode = putIntoIFrame(svgCode, svgEl); } else if (!isLooseSecurityLevel) { // Sanitize the svgCode using DOMPurify svgCode = DOMPurify.sanitize(svgCode, { diff --git a/packages/mermaid/src/utils/sandboxUrl.spec.ts b/packages/mermaid/src/utils/sandboxUrl.spec.ts index eb753a866aa..a13bfae523e 100644 --- a/packages/mermaid/src/utils/sandboxUrl.spec.ts +++ b/packages/mermaid/src/utils/sandboxUrl.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isAbsoluteUrl, resolveRelativeUrls } from './sandboxUrl.js'; +import { isAbsoluteUrl, resolveRelativeUrlsInElement } from './sandboxUrl.js'; describe('isAbsoluteUrl', () => { it('should return true for http:// URLs', () => { @@ -42,118 +42,147 @@ describe('isAbsoluteUrl', () => { }); }); -describe('resolveRelativeUrls', () => { +describe('resolveRelativeUrlsInElement', () => { const baseUrl = 'https://example.com/docs/diagrams/'; + // Helper to create an SVG element from string + function createSvgElement(svgString: string): Element { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + return doc.documentElement; + } + + // Helper to get href from first anchor in element + function getFirstHref(element: Element): string | null { + const anchor = element.querySelector('a'); + return anchor?.getAttribute('href') ?? null; + } + + // Helper to get xlink:href from first anchor in element + function getFirstXlinkHref(element: Element): string | null { + const anchor = element.querySelector('a'); + return anchor?.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ?? null; + } + it('should resolve relative paths (./) in href attributes', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://example.com/docs/diagrams/page.html"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://example.com/docs/diagrams/page.html'); }); it('should resolve relative paths (./) in xlink:href attributes', () => { - const svg = - 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('xlink:href="https://example.com/docs/diagrams/page.html"'); + const svg = createSvgElement( + 'Link' + ); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstXlinkHref(svg)).toBe('https://example.com/docs/diagrams/page.html'); }); it('should resolve parent directory paths (../)', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://example.com/docs/other.html"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://example.com/docs/other.html'); }); it('should resolve absolute paths (/)', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://example.com/root.html"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://example.com/root.html'); }); it('should resolve hash links against base URL', () => { - const svg = 'Link'; + const svg = createSvgElement('Link'); const baseUrlWithFile = 'https://example.com/docs/index.html'; - const result = resolveRelativeUrls(svg, baseUrlWithFile); - expect(result).toContain('href="https://example.com/docs/index.html#section"'); + resolveRelativeUrlsInElement(svg, baseUrlWithFile); + expect(getFirstHref(svg)).toBe('https://example.com/docs/index.html#section'); }); it('should not modify absolute URLs with http://', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="http://other.com/page"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('http://other.com/page'); }); it('should not modify absolute URLs with https://', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://other.com/page"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://other.com/page'); }); it('should not modify protocol-relative URLs', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="//cdn.example.com/resource"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('//cdn.example.com/resource'); }); it('should not modify mailto: URLs', () => { - const svg = 'Email'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="mailto:user@example.com"'); + const svg = createSvgElement('Email'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('mailto:user@example.com'); }); it('should not modify javascript: URLs', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="javascript:void(0)"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('javascript:void(0)'); }); it('should handle both href and xlink:href in the same SVG', () => { - const svg = ` + const svg = createSvgElement(` A B - `; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://example.com/docs/diagrams/a.html"'); - expect(result).toContain('xlink:href="https://example.com/docs/diagrams/b.html"'); + `); + resolveRelativeUrlsInElement(svg, baseUrl); + + const anchors = svg.querySelectorAll('a'); + expect(anchors[0].getAttribute('href')).toBe('https://example.com/docs/diagrams/a.html'); + expect(anchors[1].getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe( + 'https://example.com/docs/diagrams/b.html' + ); }); it('should handle multiple links with mixed URLs', () => { - const svg = ` + const svg = createSvgElement(` Relative Absolute Parent Protocol - `; - const result = resolveRelativeUrls(svg, baseUrl); - - expect(result).toContain('href="https://example.com/docs/diagrams/relative.html"'); - expect(result).toContain('href="https://absolute.com"'); - expect(result).toContain('xlink:href="https://example.com/docs/parent.html"'); - expect(result).toContain('href="//protocol-relative.com"'); - }); - - it('should return original content if SVG parsing fails', () => { - const invalidSvg = '>'; - const result = resolveRelativeUrls(invalidSvg, baseUrl); - expect(result).toBe(invalidSvg); - }); + `); + resolveRelativeUrlsInElement(svg, baseUrl); - it('should handle empty SVG', () => { - const svg = ''; - const result = resolveRelativeUrls(svg, baseUrl); - // XMLSerializer may output self-closing tag or - expect(result).toMatch(/|<\/svg>/); + const anchors = svg.querySelectorAll('a'); + expect(anchors[0].getAttribute('href')).toBe('https://example.com/docs/diagrams/relative.html'); + expect(anchors[1].getAttribute('href')).toBe('https://absolute.com'); + expect(anchors[2].getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe( + 'https://example.com/docs/parent.html' + ); + expect(anchors[3].getAttributeNS('http://www.w3.org/1999/xlink', 'href')).toBe( + '//protocol-relative.com' + ); }); - it('should handle SVG with no links', () => { - const svg = ''; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain(' { + const svg = createSvgElement(''); + // Should not throw + resolveRelativeUrlsInElement(svg, baseUrl); + expect(svg.querySelector('rect')).not.toBeNull(); }); it('should resolve simple file names without ./', () => { - const svg = 'Link'; - const result = resolveRelativeUrls(svg, baseUrl); - expect(result).toContain('href="https://example.com/docs/diagrams/page.html"'); + const svg = createSvgElement('Link'); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://example.com/docs/diagrams/page.html'); + }); + + it('should handle nested elements with links', () => { + const svg = createSvgElement(` + + + Nested Link + + + `); + resolveRelativeUrlsInElement(svg, baseUrl); + expect(getFirstHref(svg)).toBe('https://example.com/docs/diagrams/nested.html'); }); }); diff --git a/packages/mermaid/src/utils/sandboxUrl.ts b/packages/mermaid/src/utils/sandboxUrl.ts index 031bbeb8a98..ece5569287a 100644 --- a/packages/mermaid/src/utils/sandboxUrl.ts +++ b/packages/mermaid/src/utils/sandboxUrl.ts @@ -6,7 +6,7 @@ * (e.g., "./page.html") cannot be resolved by the browser. * * This module provides functionality to pre-resolve relative URLs to absolute URLs - * before base64-encoding and embedding in the sandbox iframe. + * before the SVG is serialized and embedded in the sandbox iframe. */ const XLINK_NS = 'http://www.w3.org/1999/xlink'; @@ -34,40 +34,29 @@ export function isAbsoluteUrl(url: string): boolean { } /** - * Resolves relative URLs in SVG content against a base URL. + * Resolves relative URLs in a DOM element (in place) against a base URL. * Used for sandbox mode to enable link navigation from data URI iframes. * * This function finds all elements with href or xlink:href attributes * and resolves relative URLs to absolute URLs using the provided base URL. + * The element is mutated directly - no return value. * - * @param svgContent - The rendered SVG markup string + * @param element - The DOM element (typically an SVG) to process * @param baseUrl - The base URL to resolve relative URLs against - * @returns SVG markup with all relative URLs resolved to absolute */ -export function resolveRelativeUrls(svgContent: string, baseUrl: string): string { - // Parse SVG as XML - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, 'image/svg+xml'); - - // Check for parsing errors - const parserError = doc.querySelector('parsererror'); - if (parserError) { - // If parsing fails, return original content unchanged - return svgContent; - } - +export function resolveRelativeUrlsInElement(element: Element, baseUrl: string): void { // Find all elements with href or xlink:href attributes // We need to handle both standard href and xlink:href (used in SVG) - const elementsWithHref = doc.querySelectorAll('[href]'); - const elementsWithXlinkHref = doc.querySelectorAll('[*|href]'); + const elementsWithHref = element.querySelectorAll('[href]'); + const elementsWithXlinkHref = element.querySelectorAll('[*|href]'); // Process regular href attributes - for (const element of elementsWithHref) { - const href = element.getAttribute('href'); + for (const el of elementsWithHref) { + const href = el.getAttribute('href'); if (href && !isAbsoluteUrl(href)) { try { const absoluteUrl = new URL(href, baseUrl).href; - element.setAttribute('href', absoluteUrl); + el.setAttribute('href', absoluteUrl); } catch { // If URL resolution fails, leave the original href unchanged } @@ -75,19 +64,15 @@ export function resolveRelativeUrls(svgContent: string, baseUrl: string): string } // Process xlink:href attributes (common in SVG) - for (const element of elementsWithXlinkHref) { - const xlinkHref = element.getAttributeNS(XLINK_NS, 'href'); + for (const el of elementsWithXlinkHref) { + const xlinkHref = el.getAttributeNS(XLINK_NS, 'href'); if (xlinkHref && !isAbsoluteUrl(xlinkHref)) { try { const absoluteUrl = new URL(xlinkHref, baseUrl).href; - element.setAttributeNS(XLINK_NS, 'xlink:href', absoluteUrl); + el.setAttributeNS(XLINK_NS, 'xlink:href', absoluteUrl); } catch { // If URL resolution fails, leave the original href unchanged } } } - - // Serialize back to string - const serializer = new XMLSerializer(); - return serializer.serializeToString(doc); } From f77b8892a9f7caeb96ab2ef3a60d470fd83f7d81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Nov 2025 16:42:29 +0000 Subject: [PATCH 5/7] fix: address XSS vulnerability in demo file Replace innerHTML with DOM manipulation (createElement + textContent) to prevent potential XSS attacks flagged by CodeQL. --- demos/sandboxBaseUrl-test.html | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/demos/sandboxBaseUrl-test.html b/demos/sandboxBaseUrl-test.html index a80fe1bd2e1..3bec452da2b 100644 --- a/demos/sandboxBaseUrl-test.html +++ b/demos/sandboxBaseUrl-test.html @@ -179,10 +179,20 @@

Console Output