From e9e29758f24e41cfbc58930f2bd4534f8357e36e Mon Sep 17 00:00:00 2001 From: betterandbetterii Date: Tue, 12 May 2026 10:58:41 +0100 Subject: [PATCH] fix(web): pin mermaid 11.15.0 for gantt dos --- web/package.json | 1 + web/pnpm-lock.yaml | 31 +++-- web/scripts/check-security-overrides.mjs | 26 ++++- web/scripts/verify-mermaid-gantt-no-hang.mjs | 109 ++++++++++++++++++ .../markdown/mermaid-gantt-security.test.ts | 38 ++++++ 5 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 web/scripts/verify-mermaid-gantt-no-hang.mjs create mode 100644 web/src/lib/features/markdown/mermaid-gantt-security.test.ts diff --git a/web/package.json b/web/package.json index 183901a2..696d1f80 100644 --- a/web/package.json +++ b/web/package.json @@ -93,6 +93,7 @@ "cookie": "0.7.2", "dompurify": "3.4.0", "lodash-es": "4.18.1", + "mermaid": "11.15.0", "uuid": "14.0.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1ea33c5c..1b648a22 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: cookie: 0.7.2 dompurify: 3.4.0 lodash-es: 4.18.1 + mermaid: 11.15.0 uuid: 14.0.0 importers: @@ -929,10 +930,10 @@ packages: peerDependencies: svelte: '>=3 <5' - '@mermaid-js/parser@1.0.1': + '@mermaid-js/parser@1.1.1': resolution: { - integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==, + integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==, } '@napi-rs/wasm-runtime@0.2.12': @@ -3055,6 +3056,12 @@ packages: } engines: { node: '>= 0.4' } + es-toolkit@1.46.1: + resolution: + { + integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==, + } + es-module-lexer@2.0.0: resolution: { @@ -4343,10 +4350,10 @@ packages: integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==, } - mermaid@11.13.0: + mermaid@11.15.0: resolution: { - integrity: sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==, + integrity: sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==, } micromark-core-commonmark@2.0.3: @@ -6630,9 +6637,9 @@ snapshots: nanoid: 5.1.7 svelte: 5.54.0 - '@mermaid-js/parser@1.0.1': + '@mermaid-js/parser@1.1.1': dependencies: - langium: 4.2.1 + '@chevrotain/types': 11.1.2 '@napi-rs/wasm-runtime@0.2.12': dependencies: @@ -6824,7 +6831,7 @@ snapshots: dependencies: '@shikijs/langs': 3.23.0 '@shikijs/themes': 3.23.0 - mermaid: 11.13.0 + mermaid: 11.15.0 rehype-katex: 7.0.1 remark-cjk-friendly: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) remark-cjk-friendly-gfm-strikethrough: 2.0.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2)(unified@11.0.5) @@ -7965,6 +7972,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.46.1: {} + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -8875,11 +8884,11 @@ snapshots: mdn-data@2.27.1: {} - mermaid@11.13.0: + mermaid@11.15.0: dependencies: '@braintree/sanitize-url': 7.1.2 '@iconify/utils': 3.1.0 - '@mermaid-js/parser': 1.0.1 + '@mermaid-js/parser': 1.1.1 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 cytoscape: 3.33.1 @@ -8890,9 +8899,9 @@ snapshots: dagre-d3-es: 7.0.14 dayjs: 1.11.20 dompurify: 3.4.0 + es-toolkit: 1.46.1 katex: 0.16.44 khroma: 2.1.0 - lodash-es: 4.18.1 marked: 16.4.2 roughjs: 4.6.6 stylis: 4.3.6 @@ -9712,7 +9721,7 @@ snapshots: esm-env: 1.2.2 katex: 0.16.44 marked: 16.4.2 - mermaid: 11.13.0 + mermaid: 11.15.0 rehype-harden: 1.1.8 rehype-raw: 7.0.0 rehype-sanitize: 6.0.0 diff --git a/web/scripts/check-security-overrides.mjs b/web/scripts/check-security-overrides.mjs index 37dff8bd..f538b525 100644 --- a/web/scripts/check-security-overrides.mjs +++ b/web/scripts/check-security-overrides.mjs @@ -10,6 +10,10 @@ const requiredOverrides = { requiredVersion: '4.18.1', staleVersion: '4.17.23', }, + mermaid: { + requiredVersion: '11.15.0', + staleVersionPatterns: ['11\\.(?:[0-9]|1[0-4])(?:\\.\\d+)?(?:-[^\\s:]+)?'], + }, uuid: { requiredVersion: '14.0.0', staleVersion: '11.1.0', @@ -38,12 +42,22 @@ for (const [packageName, config] of Object.entries(requiredOverrides)) { problems.push(`pnpm-lock.yaml must record the ${packageName} override at ${requiredVersion}`) } - const staleVersionPattern = new RegExp( - `${escapeRegExp(packageName)}(?::|@)\\s*${escapeRegExp(staleVersion)}\\b|${escapeRegExp(packageName)}@${escapeRegExp(staleVersion)}:`, - 'm', - ) - if (staleVersionPattern.test(lockfile)) { - problems.push(`pnpm-lock.yaml still references stale ${packageName} ${staleVersion} entries`) + const staleVersionPatterns = config.staleVersionPatterns ?? [escapeRegExp(staleVersion)] + for (const staleVersionPattern of staleVersionPatterns) { + if (!staleVersionPattern) { + continue + } + const staleEntryPattern = new RegExp( + `${escapeRegExp(packageName)}(?::|@)\\s*(?:${staleVersionPattern})\\b|${escapeRegExp(packageName)}@(?:${staleVersionPattern}):`, + 'm', + ) + if (staleEntryPattern.test(lockfile)) { + const staleVersionLabel = staleVersion ?? `/${staleVersionPattern.replaceAll('\\\\', '\\')}/` + problems.push( + `pnpm-lock.yaml still references stale ${packageName} ${staleVersionLabel} entries`, + ) + break + } } const resolvedVersionPattern = new RegExp( diff --git a/web/scripts/verify-mermaid-gantt-no-hang.mjs b/web/scripts/verify-mermaid-gantt-no-hang.mjs new file mode 100644 index 00000000..4fe85d5f --- /dev/null +++ b/web/scripts/verify-mermaid-gantt-no-hang.mjs @@ -0,0 +1,109 @@ +import { readdir } from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { JSDOM } from 'jsdom' + +const timeoutMs = Number(process.env.MERMAID_RENDER_TIMEOUT_MS ?? '1500') +const mermaidPath = await resolveMermaidEntrypoint() +const { default: mermaid } = await import(pathToFileURL(mermaidPath).href) + +const dom = new JSDOM('
', { pretendToBeVisual: true }) +const { window } = dom + +installDomGlobals(window) + +mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'base' }) + +const hostileGanttChart = [ + 'gantt', + 'dateFormat YYYY-MM-DD', + 'excludes weekends monday,tuesday,wednesday,thursday,friday', + 'section Demo', + 'Task :2026-05-12, 1d', +].join('\n') + +const timeout = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`mermaid.render timed out after ${timeoutMs}ms`)) + }, timeoutMs) +}) + +const outcome = await Promise.race([ + mermaid + .render('test-graph', hostileGanttChart) + .then((result) => { + if (!result?.svg || !result.svg.includes(' { + return { + status: 'rejected', + errorMessage: error instanceof Error ? error.message : String(error), + } + }), + timeout, +]) + +process.stdout.write(`${JSON.stringify({ mermaidPath, timeoutMs, ...outcome })}\n`) + +function installDomGlobals(window) { + class MockCSSStyleSheet { + constructor() { + this.cssRules = [] + } + + insertRule(rule, index = this.cssRules.length) { + this.cssRules.splice(index, 0, { cssText: rule }) + return index + } + + replaceSync(text) { + this.cssRules = [{ cssText: text }] + } + } + + globalThis.window = window + globalThis.document = window.document + globalThis.HTMLElement = window.HTMLElement + globalThis.SVGElement = window.SVGElement + globalThis.Element = window.Element + globalThis.DOMParser = window.DOMParser + globalThis.CSSStyleSheet = MockCSSStyleSheet + globalThis.getComputedStyle = window.getComputedStyle.bind(window) + globalThis.requestAnimationFrame = window.requestAnimationFrame.bind(window) + globalThis.cancelAnimationFrame = window.cancelAnimationFrame.bind(window) + window.CSSStyleSheet = MockCSSStyleSheet + + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: window.navigator, + }) + + if (typeof SVGElement.prototype.getBBox !== 'function') { + SVGElement.prototype.getBBox = () => ({ x: 0, y: 0, width: 100, height: 100 }) + } +} + +async function resolveMermaidEntrypoint() { + const pnpmStoreDir = path.join(process.cwd(), 'node_modules', '.pnpm') + const storeEntries = await readdir(pnpmStoreDir) + const streamdownStoreDir = storeEntries.find((entry) => entry.startsWith('streamdown-svelte@')) + + if (!streamdownStoreDir) { + throw new Error(`Could not locate streamdown-svelte in ${pnpmStoreDir}.`) + } + + return path.join( + pnpmStoreDir, + streamdownStoreDir, + 'node_modules', + 'mermaid', + 'dist', + 'mermaid.core.mjs', + ) +} diff --git a/web/src/lib/features/markdown/mermaid-gantt-security.test.ts b/web/src/lib/features/markdown/mermaid-gantt-security.test.ts new file mode 100644 index 00000000..eb7ac5bf --- /dev/null +++ b/web/src/lib/features/markdown/mermaid-gantt-security.test.ts @@ -0,0 +1,38 @@ +import { execFile } from 'node:child_process' +import path from 'node:path' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const execFileAsync = promisify(execFile) + +describe('mermaid gantt security regression', () => { + it('renders hostile excludes input without hanging', async () => { + const scriptPath = path.resolve(process.cwd(), 'scripts/verify-mermaid-gantt-no-hang.mjs') + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + cwd: process.cwd(), + env: { + ...process.env, + MERMAID_RENDER_TIMEOUT_MS: '1500', + }, + timeout: 8000, + }) + + const result = JSON.parse(stdout.trim()) as { + mermaidPath: string + timeoutMs: number + status: 'rendered' | 'rejected' + svgLength?: number + errorMessage?: string + } + + expect(result.mermaidPath).toContain(`${path.sep}streamdown-svelte@`) + expect(result.timeoutMs).toBe(1500) + expect(['rendered', 'rejected']).toContain(result.status) + if (result.status === 'rendered') { + expect(result.svgLength).toBeGreaterThan(0) + return + } + + expect(result.errorMessage).toContain('excludes') + }, 5_000) +})