Skip to content
Merged
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
1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
"cookie": "0.7.2",
"dompurify": "3.4.0",
"lodash-es": "4.18.1",
"mermaid": "11.15.0",
"postcss": "8.5.10",
"uuid": "14.0.0"
}
Expand Down
31 changes: 20 additions & 11 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 20 additions & 5 deletions web/scripts/check-security-overrides.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ const requiredOverrides = {
requiredVersion: '8.5.10',
staleVersions: ['8.5.8', '8.5.9'],
},
mermaid: {
requiredVersion: '11.15.0',
staleVersionPatterns: ['11\\.(?:[0-9]|1[0-4])(?:\\.\\d+)?(?:-[^\\s:]+)?'],
},
uuid: {
requiredVersion: '14.0.0',
staleVersions: ['11.1.0'],
Expand Down Expand Up @@ -43,13 +47,24 @@ for (const [packageName, config] of Object.entries(requiredOverrides)) {
problems.push(`pnpm-lock.yaml must record the ${packageName} override at ${requiredVersion}`)
}

for (const staleVersion of staleVersions) {
const staleVersionPattern = new RegExp(
`${escapeRegExp(packageName)}(?::|@)\\s*${escapeRegExp(staleVersion)}\\b|${escapeRegExp(packageName)}@${escapeRegExp(staleVersion)}:`,
const staleVersionPatterns =
config.staleVersionPatterns ?? staleVersions.map((staleVersion) => escapeRegExp(staleVersion))
for (const staleVersionPattern of staleVersionPatterns) {
if (!staleVersionPattern) {
continue
}
const staleEntryPattern = new RegExp(
`${escapeRegExp(packageName)}(?::|@)\\s*(?:${staleVersionPattern})\\b|${escapeRegExp(packageName)}@(?:${staleVersionPattern}):`,
'm',
)
if (staleVersionPattern.test(lockfile)) {
problems.push(`pnpm-lock.yaml still references stale ${packageName} ${staleVersion} entries`)
if (staleEntryPattern.test(lockfile)) {
const staleVersionLabel =
staleVersions.find((staleVersion) => escapeRegExp(staleVersion) === staleVersionPattern) ??
`/${staleVersionPattern.replaceAll('\\\\', '\\')}/`
problems.push(
`pnpm-lock.yaml still references stale ${packageName} ${staleVersionLabel} entries`,
)
break
}
}

Expand Down
109 changes: 109 additions & 0 deletions web/scripts/verify-mermaid-gantt-no-hang.mjs
Original file line number Diff line number Diff line change
@@ -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('<div id="root"></div>', { 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('<svg')) {
throw new Error('Expected mermaid to return SVG markup for the hostile gantt chart.')
}
return {
status: 'rendered',
svgLength: result.svg.length,
}
})
.catch((error) => {
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',
)
}
38 changes: 38 additions & 0 deletions web/src/lib/features/markdown/mermaid-gantt-security.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading