Explorar o código

fix(web): reject Mermaid image nodes before layout

yudshj hai 2 semanas
pai
achega
aaa62b09dc

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
-2026-09-09-markdown-static-previews.md: 40d47ede846463fcc810a9393c6b3e73624881ae
-2026-09-09-markdown-static-previews.zh.md: d020fadeaf5fee57fe2fcde693f782631cefed31
+2026-09-09-markdown-static-previews.md: cd434e7df5b7b8b51724a06c805618269acd6bde
+2026-09-09-markdown-static-previews.zh.md: c24a8d23baca61e7e9252be337298bd7808f0d65

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md

@@ -16,7 +16,7 @@ The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, and `s
 
 `SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
 
-Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
+Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT and SVG colors remain intact.
 
 SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 `SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
 
-Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
+Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
 
 SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
 

+ 31 - 6
apps/web/tests/markdown-mermaid.e2e.ts

@@ -58,7 +58,7 @@ const CPU = `flowchart LR
     MEM -->|"数据"| OUT
     CU -->|"控制信号"| OUT`
 
-function fixture(diagramSource?: string): string {
+function fixture(diagramSources?: string[]): string {
   const session = Session.create(SessionId('markdown-mermaid-source'))
   session.append('turn/start', { turn: 1 })
   const user = session.append('user/message', createUserMessage({
@@ -70,11 +70,11 @@ function fixture(diagramSource?: string): string {
     stream: [], turn: 1, step: 1,
     message: createMessage({
       role: 'assistant',
-      content: [{ type: 'text', text: diagramSource === undefined ? [
+      content: [{ type: 'text', text: diagramSources === undefined ? [
         '# Mermaid previews',
         ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
         ...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``),
-      ].join('\n\n') : `# Mermaid previews\n\n\`\`\`mermaid\n${diagramSource}\n\`\`\`` }],
+      ].join('\n\n') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``)].join('\n\n') }],
       source: { kind: 'model', provider: 'fixture', model: 'fixture' },
     }),
   }, { surfaceOp: 'append' })
@@ -142,7 +142,7 @@ describe('web e2e: Mermaid chat previews', () => {
     let cpuScaffold: WebScaffold | undefined
     try {
       cpuScaffold = await launchWebScaffold({})
-      await seedSession(cpuScaffold, fixture(CPU), SEED_ID)
+      await seedSession(cpuScaffold, fixture([CPU]), SEED_ID)
       await openConversation(page, cpuScaffold)
       const block = page.locator('.md-code-block')
       await block.scrollIntoViewIfNeeded()
@@ -406,9 +406,9 @@ describe('web e2e: Mermaid chat previews', () => {
     await openConversation(page, scaffold)
     const first = page.locator('.md-code-block').first()
     const invalid = page.locator('.md-code-block').nth(2)
-    await invalid.getByRole('status').waitFor()
+    await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor()
     await invalid.getByRole('button', { name: 'Source', exact: true }).click()
-    expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeGreaterThanOrEqual(120)
+    expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0)
     await invalid.getByRole('button', { name: 'Preview', exact: true }).click()
     expect(await first.locator('pre').count()).toBe(0)
     expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
@@ -525,6 +525,31 @@ describe('web e2e: Mermaid chat previews', () => {
     await page.close()
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => {
+    const page = await newEnglishPage(browser)
+    let imageScaffold: WebScaffold | undefined
+    const requests: string[] = []
+    try {
+      await page.route('https://preview.invalid/**', async (route) => {
+        requests.push(route.request().url())
+        await route.abort()
+      })
+      imageScaffold = await launchWebScaffold({})
+      await seedSession(imageScaffold, fixture([
+        'flowchart LR\n  A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW,
+      ]), SEED_ID)
+      await openConversation(page, imageScaffold)
+      await page.locator('.md-code-block').first().getByRole('status')
+        .filter({ hasText: 'Unable to render this diagram' }).waitFor()
+      const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true })
+      await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
+      expect(requests).toEqual([])
+    } finally {
+      await page.close()
+      await imageScaffold?.close()
+    }
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('previews inert DOT and SVG images', async () => {
     const page = await newEnglishPage(browser)
     const requests: string[] = []

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: bffc3c1c7f2408e060ab1c7721a4b125df3801f7
-README.zh.md: f932cf571bd2685d7dab23f0496d9fda98fd69f6
+README.md: 8c151fb09ea408f4bd96e4ba00c2967c51c0f880
+README.zh.md: a79d90ff232a3035dbb1f99b5a7a5d0490103816

+ 2 - 0
packages/client/ui-primitives/README.md

@@ -160,6 +160,8 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
+- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Theme changes temporarily show the placeholder and close the lightbox until the replacement image loads.
+
 - **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided.
 
 - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.

+ 2 - 0
packages/client/ui-primitives/README.zh.md

@@ -160,6 +160,8 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
 
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
+- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。主题变化时会暂时显示占位并关闭 lightbox,直到新图加载完成。
+
 - **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。
 
 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。

+ 11 - 1
packages/client/ui-primitives/src/markdown/mermaid.ts

@@ -1,6 +1,7 @@
 /** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
 
 import type { Mermaid } from 'mermaid'
+import type { FlowDB } from 'mermaid/dist/diagrams/flowchart/flowDb.js'
 import clsx from 'clsx'
 import css from './SourcePreview.module.css'
 import { readPreviewTheme } from './preview-theme.ts'
@@ -59,7 +60,7 @@ function initialize(mermaid: Mermaid): void {
  * Render untrusted diagram source using the document palette, without installing SVG or link handlers in the UI.
  * @param code - Complete Mermaid source.
  * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup.
- * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject.
+ * @returns An SVG data URL. Image nodes, import, parse, rendering, and cancellation failures reject.
  */
 export async function renderMermaid(code: string, signal: AbortSignal): Promise<string> {
   const task = pending.then(() => renderOne(code, signal))
@@ -71,6 +72,15 @@ async function renderOne(code: string, signal: AbortSignal): Promise<string> {
   const mermaid = await loadMermaid()
   signal.throwIfAborted()
   initialize(mermaid)
+  // Mermaid image nodes fetch resources during layout, even under strict security.
+  // The replacement parse API exposes validity, not parsed nodes.
+  // oxlint-disable-next-line typescript/no-deprecated
+  const diagram = await mermaid.mermaidAPI.getDiagramFromText(code)
+  if (diagram.type.startsWith('flowchart')) {
+    for (const node of (diagram.db as FlowDB).getVertices().values()) {
+      if (node.img !== undefined) throw new Error('Mermaid image nodes are not supported')
+    }
+  }
   const stage = document.createElement('div')
   stage.className = clsx(css.staging)
   stage.setAttribute('aria-hidden', 'true')

+ 1 - 1
packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx

@@ -126,7 +126,7 @@ describe('viewport-activated syntax highlighting', () => {
     expect(IntersectionObserverStub.instances).toHaveLength(0)
   })
 
-  it('activates retained offscreen source when a preview becomes available and keeps it highlighted', async () => {
+  it('releases the offscreen observer when preview replaces source, then highlights on selection', async () => {
     const code = '<svg><rect width="10"/></svg>'
     const props = { code, lang: 'svg', ...markdownLabels.code }
     const view = render(<CodeBlock {...props} />)

+ 18 - 1
packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts

@@ -4,16 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 
 const initialize = vi.fn()
 const renderDiagram = vi.fn()
-const importRuntime = vi.fn(() => ({ default: { initialize, render: renderDiagram } }))
+const getDiagramFromText = vi.fn()
+const importRuntime = vi.fn(() => ({ default: { initialize, render: renderDiagram, mermaidAPI: { getDiagramFromText } } }))
 
 beforeEach(() => {
   vi.resetModules()
   vi.resetAllMocks()
+  getDiagramFromText.mockResolvedValue({ type: 'sequence', db: {} })
   vi.doMock('mermaid', importRuntime)
 })
 afterEach(() => { vi.doUnmock('mermaid') })
 
 describe('Mermaid runtime', () => {
+  it('rejects image nodes before layout and leaves the queue available', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
+      getVertices: () => new Map([['A', { img: 'https://preview.invalid/image' }]]),
+    } })
+    await expect(renderMermaid('image', new AbortController().signal)).rejects.toThrow('image nodes')
+    expect(renderDiagram).not.toHaveBeenCalled()
+    getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
+      getVertices: () => new Map([['A', {}]]),
+    } })
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await expect(renderMermaid('plain', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+    expect(renderDiagram).toHaveBeenCalledOnce()
+  })
+
   it('allows another attempt after the runtime import fails', async () => {
     const importError = new Error('runtime unavailable')
     importRuntime.mockImplementationOnce(() => { throw importError })