From 969dea1c8e8971d1357a80271841ef7afbf8b538 Mon Sep 17 00:00:00 2001 From: Yiyi Wang Date: Thu, 4 Jun 2026 18:44:59 +0800 Subject: [PATCH 01/13] fix: replace eval with JSON5.parse in WaveDrom and Bitfield renderers; improve MathJax 4 performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaveDrom (shd101wyy/vscode-markdown-preview-enhanced#2315): - Replace window.eval with JSON5.parse in renderWavedrom() to prevent arbitrary code execution from user-controlled fenced code blocks - JSON5.parse safely handles unquoted keys, comments, trailing commas, and single-quoted strings without executing code Bitfield: - Replace interpretJS (vm.runInNewContext) with JSON5.parse in renderBitfield() — bitfield register definitions are purely data MathJax 4 (shd101wyy/vscode-markdown-preview-enhanced#2312): - Disable enableAssistiveMml, enableMenu, enableExplorer in default MathJax 4 config to reduce per-formula overhead - Remove redundant typesetClear() and texReset() calls before typesetPromise() since DOM is freshly rebuilt on every render --- CHANGELOG.md | 6 ++ package.json | 1 + pnpm-lock.yaml | 3 + src/notebook/types.ts | 6 +- src/renderers/bitfield.ts | 4 +- src/webview/containers/preview.ts | 5 +- test/bitfield.test.ts | 64 +++++++++++++ test/math.test.ts | 34 +++++++ test/wavedrom-parser.test.ts | 150 ++++++++++++++++++++++++++++++ 9 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 test/wavedrom-parser.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e337e9..998db762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ## [Unreleased] +### Bug fixes + +- **Replace `window.eval` with `JSON5.parse` in WaveDrom renderer** — WaveDrom fenced code blocks were parsed using `window.eval()` on untrusted user input, enabling arbitrary JavaScript code execution in the webview context. Replaced with `JSON5.parse()` which safely handles WaveDrom's JSON5-like syntax (unquoted keys, comments, trailing commas, single-quoted strings) without executing arbitrary code. Fixes the security vulnerability reported in shd101wyy/vscode-markdown-preview-enhanced#2315. +- **Replace `interpretJS` with `JSON5.parse` in Bitfield renderer** — Bitfield fenced code blocks were parsed using `interpretJS()` which evaluates user input via `vm.runInNewContext`, enabling arbitrary code execution on the server side. Replaced with `JSON5.parse()` since bitfield register definitions are purely data (arrays of objects). +- **Improve MathJax 4 rendering performance** — Disabled `enableAssistiveMml`, `enableMenu`, and `enableExplorer` in the default MathJax 4 config to reduce per-formula overhead (hidden MathML generation, context menu setup, accessibility explorer). Removed redundant `typesetClear()` and `texReset()` calls before each `typesetPromise()` since the DOM is freshly rebuilt on every render. Addresses shd101wyy/vscode-markdown-preview-enhanced#2312. + ## [0.9.28] - 2026-05-24 ### Bug fixes diff --git a/package.json b/package.json index c9ad8a8d..76c20004 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "ignore": "^7.0.5", "imagemagick-cli": "^0.5.0", "jquery": "^3.7.1", + "json5": "^2.2.3", "katex": "^0.16.47", "less": "^4.2.0", "markdown-it": "^14.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92ca2526..815c43b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: jquery: specifier: ^3.7.1 version: 3.7.1 + json5: + specifier: ^2.2.3 + version: 2.2.3 katex: specifier: ^0.16.47 version: 0.16.47 diff --git a/src/notebook/types.ts b/src/notebook/types.ts index 3561740c..6f12ad5a 100644 --- a/src/notebook/types.ts +++ b/src/notebook/types.ts @@ -620,7 +620,11 @@ export function getDefaultMermaidConfig(): MermaidConfig { export function getDefaultMathjaxConfig(): JsonObject { return { tex: {}, - options: {}, + options: { + enableAssistiveMml: false, + enableMenu: false, + enableExplorer: false, + }, loader: {}, }; } diff --git a/src/renderers/bitfield.ts b/src/renderers/bitfield.ts index e5c642b8..aaac45bf 100644 --- a/src/renderers/bitfield.ts +++ b/src/renderers/bitfield.ts @@ -1,12 +1,12 @@ import { escape } from 'html-escaper'; +import JSON5 from 'json5'; import onml from 'onml'; import { JsonObject } from 'type-fest'; import render from 'bit-field/lib/render.js'; -import { interpretJS } from '../utility'; export async function renderBitfield(code: string, options: JsonObject) { try { - const reg = interpretJS(code); + const reg = JSON5.parse(code); const jsonml = render(reg, options); const html = onml.stringify(jsonml); return html; diff --git a/src/webview/containers/preview.ts b/src/webview/containers/preview.ts index 45c64900..9409ca76 100644 --- a/src/webview/containers/preview.ts +++ b/src/webview/containers/preview.ts @@ -1,6 +1,7 @@ import CryptoJS, { SHA256 } from 'crypto-js'; import { escape } from 'html-escaper'; import $ from 'jquery'; +import JSON5 from 'json5'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useContextMenu } from 'react-contexify'; import { createContainer } from 'unstated-next'; @@ -418,8 +419,6 @@ const PreviewContainer = createContainer(() => { return resolve(); } - window['MathJax'].typesetClear(); // Don't pass element here!!! - window['MathJax'].texReset(); window['MathJax'] .typesetPromise([hiddenPreviewElement.current]) .then(() => { @@ -466,7 +465,7 @@ const PreviewContainer = createContainer(() => { } try { - const content = window.eval(`(${text})`); + const content = JSON5.parse(text); window['WaveDrom'].RenderWaveForm(i, content, 'wavedrom'); newWavedromCache[text] = el.innerHTML; } catch (error) { diff --git a/test/bitfield.test.ts b/test/bitfield.test.ts index b0d545a5..4d1bb7f8 100644 --- a/test/bitfield.test.ts +++ b/test/bitfield.test.ts @@ -22,3 +22,67 @@ describe('Bitfield renderer', () => { expect(result).toContain('
');
   });
 });
+
+describe('Bitfield parser security', () => {
+  it('parses bitfield with unquoted keys', async () => {
+    const code = `[
+      { name: "IPO", bits: 8, attr: "RO" },
+      { bits: 7 }
+    ]`;
+    const result = await renderBitfield(code, {});
+    expect(typeof result).toBe('string');
+    expect(result).toContain(' {
+    const code = `[
+      { name: 'IPO', bits: 8, attr: 'RO' }
+    ]`;
+    const result = await renderBitfield(code, {});
+    expect(typeof result).toBe('string');
+    expect(result).toContain(' {
+    const code = `[
+      { name: "IPO", bits: 8, attr: "RO", },
+      { name: "BRK", bits: 5, attr: "RW", },
+      { bits: 7, },
+    ]`;
+    const result = await renderBitfield(code, {});
+    expect(typeof result).toBe('string');
+    expect(result).toContain(' {
+    const code = `[
+      // interrupt pending register
+      { name: "IPO", bits: 8, attr: "RO" },
+      { bits: 7 }
+    ]`;
+    const result = await renderBitfield(code, {});
+    expect(typeof result).toBe('string');
+    expect(result).toContain(' {
+    const code = `[
+      { name: "STATUS", bits: 8, addr: 0x0F }
+    ]`;
+    const result = await renderBitfield(code, {});
+    expect(typeof result).toBe('string');
+    expect(result).toContain(' {
+    const code = '(() => { throw new Error("pwned"); })()';
+    const result = await renderBitfield(code, {});
+    expect(result).toContain('
');
+  });
+
+  it('blocks code execution via constructor access', async () => {
+    const code = 'this.constructor.constructor("return process")()';
+    const result = await renderBitfield(code, {});
+    expect(result).toContain('
');
+  });
+});
diff --git a/test/math.test.ts b/test/math.test.ts
index 87ce4838..8b2d370e 100644
--- a/test/math.test.ts
+++ b/test/math.test.ts
@@ -12,6 +12,7 @@
  */
 import * as path from 'path';
 import { Notebook } from '../src/notebook/index';
+import { getDefaultMathjaxConfig } from '../src/notebook/types';
 
 describe('Math rendering', () => {
   let notebook: Notebook;
@@ -374,3 +375,36 @@ describe('Math rendering with custom delimiters', () => {
     expect(html).not.toContain('class="katex"');
   });
 });
+
+describe('MathJax config defaults', () => {
+  it('disables assistive MathML for performance', () => {
+    const config = getDefaultMathjaxConfig();
+    const options = config.options as Record;
+    expect(options.enableAssistiveMml).toBe(false);
+  });
+
+  it('disables the context menu for performance', () => {
+    const config = getDefaultMathjaxConfig();
+    const options = config.options as Record;
+    expect(options.enableMenu).toBe(false);
+  });
+
+  it('disables the accessibility explorer for performance', () => {
+    const config = getDefaultMathjaxConfig();
+    const options = config.options as Record;
+    expect(options.enableExplorer).toBe(false);
+  });
+
+  it('preserves tex and loader config sections', () => {
+    const config = getDefaultMathjaxConfig();
+    expect(config.tex).toEqual({});
+    expect(config.loader).toEqual({});
+  });
+
+  it('is a pure object (no shared mutable references)', () => {
+    const a = getDefaultMathjaxConfig();
+    const b = getDefaultMathjaxConfig();
+    expect(a).not.toBe(b);
+    expect(a).toEqual(b);
+  });
+});
diff --git a/test/wavedrom-parser.test.ts b/test/wavedrom-parser.test.ts
new file mode 100644
index 00000000..727f7687
--- /dev/null
+++ b/test/wavedrom-parser.test.ts
@@ -0,0 +1,150 @@
+import JSON5 from 'json5';
+
+describe('WaveDrom parser security (CVE-2026-wavedrom-eval)', () => {
+  describe('JSON5.parse correctly handles WaveDrom input', () => {
+    test('parses standard JSON (double-quoted keys)', () => {
+      const result = JSON5.parse(
+        '{"signal": [{"name": "clk", "wave": "p......"}]}',
+      );
+      expect(result).toEqual({
+        signal: [{ name: 'clk', wave: 'p......' }],
+      });
+    });
+
+    test('parses WaveDrom with unquoted keys', () => {
+      const result = JSON5.parse(`{ signal : [
+  { name: "clk",  wave: "p......" },
+  { name: "bus",  wave: "x.34.5x",   data: "head body tail" },
+  { name: "wire", wave: "0.1..0." },
+]}`);
+      expect(result.signal).toHaveLength(3);
+      expect(result.signal[0].name).toBe('clk');
+    });
+
+    test('parses WaveDrom with single-quoted strings', () => {
+      const result = JSON5.parse(`{
+  signal: [
+    { name: 'clk', wave: 'p......' },
+  ]
+}`);
+      expect(result.signal[0].name).toBe('clk');
+    });
+
+    test('parses WaveDrom with // comments', () => {
+      const result = JSON5.parse(`{
+  signal: [
+    // clock signal
+    { name: "clk", wave: "p......" },
+    // data bus
+    { name: "bus", wave: "x.34.5x", data: "head body tail" },
+  ]
+}`);
+      expect(result.signal).toHaveLength(2);
+      expect(result.signal[0].name).toBe('clk');
+    });
+
+    test('parses WaveDrom with /* */ comments', () => {
+      const result = JSON5.parse(`{
+  signal: [
+    /* main clock */
+    { name: "clk", wave: "p......" },
+  ]
+}`);
+      expect(result.signal[0].name).toBe('clk');
+    });
+
+    test('parses WaveDrom with trailing commas', () => {
+      const result = JSON5.parse(`{
+  signal: [
+    { name: "clk", wave: "p......", },
+  ],
+}`);
+      expect(result.signal[0].name).toBe('clk');
+    });
+
+    test('parses WaveDrom with hex numbers', () => {
+      const result = JSON5.parse('{ addr: 0xDEAD }');
+      expect(result.addr).toBe(0xdead);
+    });
+
+    test('parses WaveDrom with Infinity and NaN', () => {
+      const result = JSON5.parse(
+        '{ max: Infinity, min: -Infinity, value: NaN }',
+      );
+      expect(result.max).toBe(Infinity);
+      expect(result.min).toBe(-Infinity);
+      expect(isNaN(result.value)).toBe(true);
+    });
+
+    test('parses multiline WaveDrom input', () => {
+      const result = JSON5.parse(`{
+  signal: [
+    { name: "clk",     wave: "p.....|..." },
+    { name: "data",    wave: "x.345x|=.x", data: ["head", "body", "tail", "data"] },
+    { name: "req",     wave: "0.1..0|1.0" },
+    {},
+    { name: "ack",     wave: "1.....|01." },
+  ],
+  head: { text: "WaveDrom example", tick: 0 },
+  foot: { text: "Figure 1", tock: 9 },
+}`);
+      expect(result.signal).toHaveLength(5);
+      expect(result.head.text).toBe('WaveDrom example');
+    });
+  });
+
+  describe('JSON5.parse blocks code execution', () => {
+    test('throws on function calls', () => {
+      expect(() => JSON5.parse('alert("xss")')).toThrow();
+    });
+
+    test('throws on function expressions', () => {
+      expect(() => JSON5.parse('(function() { alert("xss") })()')).toThrow();
+    });
+
+    test('throws on variable assignments', () => {
+      expect(() => JSON5.parse('a = 1')).toThrow();
+    });
+
+    test('throws on arbitrary JavaScript payloads', () => {
+      const exploitPayload = `
+        (() => {
+          const vscodeApi = { postMessage: () => {} };
+          vscodeApi.postMessage({ command: 'updateMarkdown', args: ['/etc/passwd', 'evil'] });
+        })()
+      `;
+      expect(() => JSON5.parse(exploitPayload)).toThrow();
+    });
+
+    test('throws on template literal injection', () => {
+      expect(() => JSON5.parse('`${alert("xss")}`')).toThrow();
+    });
+
+    test('throws on new operator', () => {
+      expect(() => JSON5.parse('new Function("alert(1)")')).toThrow();
+    });
+
+    test('throws on backtick code execution', () => {
+      const payload = '(() => { return 1; })()';
+      expect(() => JSON5.parse(payload)).toThrow();
+    });
+
+    test('throws on empty string', () => {
+      expect(() => JSON5.parse('')).toThrow();
+    });
+  });
+
+  describe('safety: JSON5 does not execute code', () => {
+    test('does not execute side effects in object values', () => {
+      const sideEffect = false;
+      const input = '{"key": "value"}';
+      JSON5.parse(input);
+      expect(sideEffect).toBe(false);
+    });
+
+    test('rejects code disguised as a JSON value', () => {
+      const payload = '{"signal": (function(){ return []; })()}';
+      expect(() => JSON5.parse(payload)).toThrow();
+    });
+  });
+});

From b0e64c88936e581f2e59139d65b2d4d9de5c9113 Mon Sep 17 00:00:00 2001
From: Yiyi Wang 
Date: Fri, 5 Jun 2026 09:24:21 +0800
Subject: [PATCH 02/13] fix: close WaveDrom eval RCE across all render paths;
 fix MathJax 4 perf
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

WaveDrom (#2315): the previous fix only patched the live-preview path. The
bundled WaveDrom.ProcessAll()/eva() helpers used in presentation mode and HTML
export still eval'd  breakout, dropping non-data scripts), so no
downstream eval can execute attacker-controlled JavaScript.

MathJax (#2312): the prior config (enableAssistiveMml/enableMenu/enableExplorer)
gave ~0 speedup — MathJax ignores those flags in the config block, and
assistiveMml is already off by default. The real cost is a11y semantic
enrichment (speech-rule-engine), ~890ms vs ~42ms for 127 formulas in Chrome
(~21x). Disable it via options.enableEnrichment:false, re-applied onto the live
MathDocument through a startup.ready hook (buildMathJaxConfigScript), since the
config-block flag is otherwise reset during startup. Users can opt back in.
Also restore typesetClear()/texReset() in the webview: dropping them leaked
MathJax's internal math list across re-renders and broke equation numbering
("Label multiply defined").

Tests:
- unit: normalizeWavedromSource, sanitizer WaveDrom normalization,
  buildMathJaxConfigScript, getDefaultMathjaxConfig
- browser (Playwright, new `pnpm test:browser` + CI job): MathJax equation
  numbering across re-renders, enrichment-disabled-by-default/opt-in, and the
  WaveDrom security fix exercised through the real eval-based ProcessAll path

Co-Authored-By: Claude Opus 4.8 
---
 .github/workflows/test.yml            |  37 ++++++
 .gitignore                            |   6 +
 CHANGELOG.md                          |   4 +-
 jest.config.js                        |   3 +
 package.json                          |   3 +
 playwright.config.ts                  |  36 +++++
 pnpm-lock.yaml                        |  53 ++++++++
 src/markdown-engine/index.ts          |   9 +-
 src/markdown-engine/mathjax-config.ts |  67 ++++++++++
 src/markdown-engine/sanitize.ts       |  15 +++
 src/notebook/types.ts                 |  14 +-
 src/renderers/wavedrom-source.ts      |  41 ++++++
 src/webview/containers/preview.ts     |   8 +-
 src/webview/lib/sanitize.ts           |  15 +++
 test/browser/mathjax.spec.ts          | 183 ++++++++++++++++++++++++++
 test/browser/server.ts                |  91 +++++++++++++
 test/browser/wavedrom.spec.ts         | 108 +++++++++++++++
 test/math.test.ts                     |  50 +++++--
 test/sanitize.test.ts                 |  39 ++++++
 test/wavedrom-source.test.ts          |  79 +++++++++++
 20 files changed, 837 insertions(+), 24 deletions(-)
 create mode 100644 playwright.config.ts
 create mode 100644 src/markdown-engine/mathjax-config.ts
 create mode 100644 src/renderers/wavedrom-source.ts
 create mode 100644 test/browser/mathjax.spec.ts
 create mode 100644 test/browser/server.ts
 create mode 100644 test/browser/wavedrom.spec.ts
 create mode 100644 test/wavedrom-source.test.ts

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 94694936..7d20e50e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -50,3 +50,40 @@ jobs:
 
       - name: Build typedoc
         run: pnpm typedoc
+
+  browser-test:
+    name: Browser tests (MathJax)
+    runs-on: ubuntu-latest
+    steps:
+      - name: Check out code
+        uses: actions/checkout@v5
+        with:
+          fetch-depth: 0
+
+      - name: Install pnpm
+        uses: pnpm/action-setup@v4
+
+      - name: Install nodejs
+        uses: actions/setup-node@v6
+        with:
+          cache: pnpm
+          node-version-file: .tool-versions
+
+      - name: Install dependencies
+        run: pnpm install
+
+      - name: Install Playwright Chromium
+        run: pnpm exec playwright install --with-deps chromium
+
+      - name: Run browser tests
+        # Real-Chromium tests for the client-side MathJax pipeline: equation
+        # numbering across re-renders and a11y-enrichment performance (#2312).
+        run: pnpm test:browser
+
+      - name: Upload Playwright report on failure
+        if: ${{ failure() }}
+        uses: actions/upload-artifact@v4
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 7
diff --git a/.gitignore b/.gitignore
index 743df630..1808038d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,9 @@ styles/**/*.css
 !styles/markdown-it-callout.css
 !styles/twemoji.css
 docs
+
+# Playwright browser-test artifacts
+/test-results
+/playwright-report
+/blob-report
+/playwright/.cache
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 998db762..cce9031c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,9 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea
 
 ### Bug fixes
 
-- **Replace `window.eval` with `JSON5.parse` in WaveDrom renderer** — WaveDrom fenced code blocks were parsed using `window.eval()` on untrusted user input, enabling arbitrary JavaScript code execution in the webview context. Replaced with `JSON5.parse()` which safely handles WaveDrom's JSON5-like syntax (unquoted keys, comments, trailing commas, single-quoted strings) without executing arbitrary code. Fixes the security vulnerability reported in shd101wyy/vscode-markdown-preview-enhanced#2315.
+- **Eliminate arbitrary code execution in WaveDrom rendering** — WaveDrom diagrams were parsed by evaluating untrusted markdown content with `eval()`, enabling arbitrary JavaScript execution. This affected every render path: the live preview (`window.eval`), and presentation mode plus HTML export (the bundled `WaveDrom.ProcessAll()`/`eva()` helpers). The live preview now parses with `JSON5.parse()`, and — because a malicious ``;
+      )} `;
       scripts += ``;
     }
 
@@ -972,7 +973,7 @@ window["initRevealPresentation"] = async function() {
       if (options.offline) {
         mathStyle = `
         
         
         ` data containers. Historically the
+ * client evaluated this content with `eval("(" + source + ")")` (both in our
+ * own preview code and inside the bundled `WaveDrom.ProcessAll()` /
+ * `WaveDrom.eva()` helpers used for presentation mode and HTML export). Since
+ * the content comes straight from a user's markdown file, that allowed
+ * arbitrary JavaScript execution in the webview context
+ * (shd101wyy/vscode-markdown-preview-enhanced#2315).
+ *
+ * To neutralize every downstream consumer at once, we parse the source as
+ * JSON5 (WaveDrom's actual data syntax: unquoted keys, comments, trailing
+ * commas, single-quoted strings) and re-serialize it to *strict* JSON. After
+ * this, any later `eval`/`JSON.parse`/`ProcessAll` only ever sees inert data —
+ * a JSON object literal cannot execute code.
+ *
+ * The `<` escaping prevents a `` substring inside a string value from
+ * breaking out of the surrounding `` as the end of the script element).
+ *
+ * @returns the safe, strict-JSON string, or `null` if the source is not valid
+ * WaveDrom data (in which case callers should drop the diagram).
+ */
+export function normalizeWavedromSource(raw: string): string | null {
+  try {
+    const data = JSON5.parse(raw);
+    // WaveDrom roots are objects ({signal:[...]}, {reg:[...]}, {assign:[...]}).
+    // Anything else (bare numbers/strings/null) is not a valid diagram.
+    if (typeof data !== 'object' || data === null) {
+      return null;
+    }
+    return JSON.stringify(data).replace(/ {
           return resolve();
         }
 
+        // Clear MathJax's internal list of typeset items (it otherwise grows
+        // unbounded across re-renders, leaking the detached nodes from the
+        // freshly rebuilt hidden preview) and reset the equation numbering so
+        // numbered equations don't drift on every edit.
+        window['MathJax'].typesetClear(); // Don't pass element here!!!
+        window['MathJax'].texReset();
         window['MathJax']
           .typesetPromise([hiddenPreviewElement.current])
           .then(() => {
@@ -469,7 +475,7 @@ const PreviewContainer = createContainer(() => {
           window['WaveDrom'].RenderWaveForm(i, content, 'wavedrom');
           newWavedromCache[text] = el.innerHTML;
         } catch (error) {
-          el.innerText = 'Failed to eval WaveDrom code. ' + error;
+          el.innerText = 'Failed to render WaveDrom code. ' + error;
         }
       }
 
diff --git a/src/webview/lib/sanitize.ts b/src/webview/lib/sanitize.ts
index 67032247..402044cc 100644
--- a/src/webview/lib/sanitize.ts
+++ b/src/webview/lib/sanitize.ts
@@ -4,6 +4,7 @@
  */
 
 import DOMPurify from 'dompurify';
+import { normalizeWavedromSource } from '../../renderers/wavedrom-source';
 
 // Script types used as data containers by diagram renderers (not executable)
 const SAFE_SCRIPT_TYPES = new Set(['wavedrom', 'text/tikz']);
@@ -25,6 +26,20 @@ purify.addHook('uponSanitizeElement', (node, data) => {
     const scriptType = (el.getAttribute?.('type') || '').toLowerCase().trim();
     if (!SAFE_SCRIPT_TYPES.has(scriptType)) {
       el.parentNode?.removeChild(el);
+      return;
+    }
+
+    // WaveDrom data scripts are eval'd by the bundled WaveDrom renderer in
+    // presentation mode. Normalize the body to inert strict JSON so eval can
+    // never execute attacker controlled JavaScript
+    // (shd101wyy/vscode-markdown-preview-enhanced#2315).
+    if (scriptType === 'wavedrom') {
+      const safe = normalizeWavedromSource(el.textContent || '');
+      if (safe === null) {
+        el.parentNode?.removeChild(el);
+      } else {
+        el.textContent = safe;
+      }
     }
   }
 });
diff --git a/test/browser/mathjax.spec.ts b/test/browser/mathjax.spec.ts
new file mode 100644
index 00000000..093137a6
--- /dev/null
+++ b/test/browser/mathjax.spec.ts
@@ -0,0 +1,183 @@
+import { expect, Page, test } from '@playwright/test';
+import { buildMathJaxConfigScript } from '../../src/markdown-engine/mathjax-config';
+import { getDefaultMathjaxConfig } from '../../src/notebook/types';
+import { startServer, TestServer } from './server';
+
+/**
+ * Real-browser tests for the client-side MathJax pipeline.
+ *
+ *  - Equation numbering must stay correct across re-renders. The preview
+ *    rebuilds the hidden DOM and re-typesets on every edit; without
+ *    `typesetClear()` + `texReset()` MathJax keeps the previous run's labels
+ *    and emits "Label … multiply defined" errors with drifting numbers.
+ *  - Semantic enrichment (speech-rule-engine) must be disabled by default for
+ *    performance (#2312), and re-enableable via `mathjaxConfig`.
+ */
+
+// Minimal shape of the MathJax global used inside page.evaluate callbacks.
+interface MathJaxGlobal {
+  typesetClear: () => void;
+  texReset: () => void;
+  typesetPromise: (elements: Element[]) => Promise;
+  startup: {
+    promise: Promise;
+    document: { options: Record };
+  };
+}
+
+type WindowWithMathJax = Window &
+  typeof globalThis & { MathJax: MathJaxGlobal };
+
+let server: TestServer;
+
+test.beforeAll(async () => {
+  server = await startServer();
+});
+
+test.afterAll(async () => {
+  await server?.close();
+});
+
+const NUMBERED_DOC =
+  '

$$\\begin{equation}\\label{eq:one}a+b=c\\end{equation}$$

' + + '

$$\\begin{equation}\\label{eq:two}d+e=f\\end{equation}$$

'; + +function makeConfig(optionOverrides?: Record) { + const cfg = getDefaultMathjaxConfig() as Record; + cfg.tex = { + inlineMath: [['$', '$']], + displayMath: [['$$', '$$']], + tags: 'ams', + }; + cfg.startup = { typeset: false }; + if (optionOverrides) { + cfg.options = { + ...((cfg.options as Record) || {}), + ...optionOverrides, + }; + } + return cfg; +} + +/** + * Load a fresh MathJax instance into the page using the engine's real + * `buildMathJaxConfigScript` output, then wait for startup to finish. + */ +async function loadMathJax(page: Page, config: Record) { + await page.goto(server.url); + page.on('pageerror', (e) => { + throw e; + }); + await page.addScriptTag({ content: buildMathJaxConfigScript(config) }); + await page.addScriptTag({ url: `${server.url}/mathjax/tex-mml-chtml.js` }); + await page.evaluate(async () => { + await (window as WindowWithMathJax).MathJax.startup.promise; + }); +} + +/** Render the doc `rounds` times, mirroring the preview's renderMathJax. */ +async function renderRounds( + page: Page, + html: string, + withReset: boolean, + rounds: number = 3, +) { + return page.evaluate( + async ({ html, withReset, rounds }) => { + const MathJax = (window as WindowWithMathJax).MathJax; + const hidden = document.getElementById('hidden')!; + const results: { tags: string[]; errors: string[] }[] = []; + for (let r = 0; r < rounds; r++) { + hidden.innerHTML = html; + if (withReset) { + MathJax.typesetClear(); + MathJax.texReset(); + } + await MathJax.typesetPromise([hidden]); + // CHTML renders each equation's number inside an element, + // e.g. "(1)". + const tags = [...hidden.querySelectorAll('mjx-labels')] + .map((n) => (n.textContent || '').trim()) + .filter((t) => /^\(\d+\)$/.test(t)); + const errors = [...hidden.querySelectorAll('mjx-merror')].map( + (n) => n.getAttribute('data-mjx-error') || n.textContent || '', + ); + results.push({ tags, errors }); + hidden.innerHTML = ''; + } + return results; + }, + { html, withReset, rounds }, + ); +} + +test('equation numbering is stable across re-renders (with typesetClear/texReset)', async ({ + page, +}) => { + await loadMathJax(page, makeConfig()); + const rounds = await renderRounds(page, NUMBERED_DOC, true); + + for (const [i, round] of rounds.entries()) { + expect(round.errors, `render #${i} should have no MathJax errors`).toEqual( + [], + ); + // Two numbered equations → tags (1) and (2), reset to the same values + // on every render. + expect(round.tags, `render #${i} equation numbers`).toEqual(['(1)', '(2)']); + } +}); + +test('dropping typesetClear/texReset regresses numbering (guards why we keep them)', async ({ + page, +}) => { + await loadMathJax(page, makeConfig()); + const rounds = await renderRounds(page, NUMBERED_DOC, false); + + // First render is clean; the regression shows up once the same labels are + // typeset again without resetting MathJax's label/equation state. + const laterErrors = rounds.slice(1).flatMap((r) => r.errors); + expect(laterErrors.join('\n')).toMatch(/multiply defined/i); +}); + +test('semantic enrichment is disabled by default for performance (#2312)', async ({ + page, +}) => { + await loadMathJax(page, makeConfig()); + await renderRounds(page, NUMBERED_DOC, true, 1); + + const state = await page.evaluate(() => { + const MathJax = (window as WindowWithMathJax).MathJax; + const hidden = document.getElementById('hidden')!; + // Re-render so we can inspect the output DOM for enrichment artifacts. + hidden.innerHTML = '

$$x^2 + y^2 = z^2$$

'; + return MathJax.typesetPromise([hidden]).then(() => ({ + enableEnrichment: MathJax.startup.document.options.enableEnrichment, + semanticNodes: hidden.querySelectorAll( + '[data-semantic-id], [data-semantic-speech], mjx-assistive-mml', + ).length, + })); + }); + + expect(state.enableEnrichment).toBe(false); + // Enrichment off → no semantic tree / assistive MathML generated. + expect(state.semanticNodes).toBe(0); +}); + +test('semantic enrichment can be re-enabled via mathjaxConfig', async ({ + page, +}) => { + await loadMathJax(page, makeConfig({ enableEnrichment: true })); + + const enableEnrichment = await page.evaluate(() => { + const MathJax = (window as WindowWithMathJax).MathJax; + const hidden = document.getElementById('hidden')!; + hidden.innerHTML = '

$$x^2 + y^2 = z^2$$

'; + return MathJax.typesetPromise([hidden]).then( + () => MathJax.startup.document.options.enableEnrichment, + ); + }); + + // The startup.ready hook must copy the user's override onto the live + // document (MathJax ignores it in the config block). + expect(enableEnrichment).toBe(true); +}); diff --git a/test/browser/server.ts b/test/browser/server.ts new file mode 100644 index 00000000..7f95bfc9 --- /dev/null +++ b/test/browser/server.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; + +const MATHJAX_DIR = path.resolve(__dirname, '../../node_modules/mathjax'); +const WAVEDROM_DIR = path.resolve(__dirname, '../../dependencies/wavedrom'); + +const CONTENT_TYPES: Record = { + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.cjs': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.wasm': 'application/wasm', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.html': 'text/html; charset=utf-8', +}; + +const PAGE = ` + + +
+`; + +export interface TestServer { + url: string; + close(): Promise; +} + +/** + * Serve a blank preview page at `/` and the locally installed MathJax 4 + * package under `/mathjax/...` over real HTTP (so the speech-rule-engine's + * web worker, which won't run from a `file://` origin, works as it does in the + * actual webview). + */ +// URL prefix → on-disk root. Static assets are served from these so MathJax's +// component loader and WaveDrom's skins resolve their siblings over real HTTP. +const STATIC_MOUNTS: { prefix: string; root: string }[] = [ + { prefix: '/mathjax/', root: MATHJAX_DIR }, + { prefix: '/wavedrom/', root: WAVEDROM_DIR }, +]; + +export async function startServer(): Promise { + const server = http.createServer((req, res) => { + const url = (req.url || '/').split('?')[0]; + if (url === '/' || url === '/index.html') { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(PAGE); + return; + } + const mount = STATIC_MOUNTS.find((m) => url.startsWith(m.prefix)); + if (mount) { + const rel = decodeURIComponent(url.slice(mount.prefix.length)); + // Resolve within the mount root and refuse path traversal. + const filePath = path.resolve(mount.root, rel); + if (!filePath.startsWith(mount.root)) { + res.writeHead(403); + res.end('forbidden'); + return; + } + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end('not found'); + return; + } + const type = + CONTENT_TYPES[path.extname(filePath)] || 'application/octet-stream'; + res.writeHead(200, { 'content-type': type }); + res.end(data); + }); + return; + } + res.writeHead(404); + res.end('not found'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('failed to bind test server'); + } + return { + url: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ), + }; +} diff --git a/test/browser/wavedrom.spec.ts b/test/browser/wavedrom.spec.ts new file mode 100644 index 00000000..6a54a135 --- /dev/null +++ b/test/browser/wavedrom.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from '@playwright/test'; +import * as cheerio from 'cheerio'; +import { sanitizeRenderedHTML } from '../../src/markdown-engine/sanitize'; +import { startServer, TestServer } from './server'; + +/** + * Real-browser tests for WaveDrom rendering and the eval-based code-execution + * fix (shd101wyy/vscode-markdown-preview-enhanced#2315). + * + * The bundled WaveDrom renderer (used in presentation mode and HTML export) + * runs `WaveDrom.ProcessAll()`, which evaluates the body of every + * `'; + +const BENIGN_JSON5 = + '
'; + +test('UNSANITIZED malicious WaveDrom executes via ProcessAll (demonstrates the vuln)', async ({ + page, +}) => { + // This is the pre-fix behavior: ProcessAll's eval runs attacker JS. It + // guards *why* the sanitizer normalization below is necessary. + const result = await processAll(page, MALICIOUS); + expect(result.pwned).toBe(true); +}); + +test('SANITIZED malicious WaveDrom cannot execute (#2315 fix)', async ({ + page, +}) => { + // The IIFE is not valid WaveDrom data, so the sanitizer drops the script. + const safe = sanitize(MALICIOUS); + expect(safe).not.toContain('__pwned'); + const result = await processAll(page, safe); + expect(result.pwned).toBe(false); + expect(result.hasWavedromScript).toBe(0); +}); + +test('SANITIZED benign WaveDrom still renders an SVG', async ({ page }) => { + // JSON5 (unquoted keys, single quotes) is normalized to strict JSON and + // then rendered by ProcessAll's eval — which only ever sees inert data. + const safe = sanitize(BENIGN_JSON5); + expect(safe).toContain('"signal"'); + const result = await processAll(page, safe); + expect(result.pwned).toBe(false); + expect(result.svgCount).toBeGreaterThan(0); +}); diff --git a/test/math.test.ts b/test/math.test.ts index 8b2d370e..9ec3b3f2 100644 --- a/test/math.test.ts +++ b/test/math.test.ts @@ -11,6 +11,7 @@ * renderer's output. */ import * as path from 'path'; +import { buildMathJaxConfigScript } from '../src/markdown-engine/mathjax-config'; import { Notebook } from '../src/notebook/index'; import { getDefaultMathjaxConfig } from '../src/notebook/types'; @@ -377,22 +378,12 @@ describe('Math rendering with custom delimiters', () => { }); describe('MathJax config defaults', () => { - it('disables assistive MathML for performance', () => { + it('disables a11y semantic enrichment for performance (#2312)', () => { + // Enrichment (speech-rule-engine) is ~95% of MathJax 4's per-formula + // typeset cost; disabling it restores MathJax-3-like preview performance. const config = getDefaultMathjaxConfig(); const options = config.options as Record; - expect(options.enableAssistiveMml).toBe(false); - }); - - it('disables the context menu for performance', () => { - const config = getDefaultMathjaxConfig(); - const options = config.options as Record; - expect(options.enableMenu).toBe(false); - }); - - it('disables the accessibility explorer for performance', () => { - const config = getDefaultMathjaxConfig(); - const options = config.options as Record; - expect(options.enableExplorer).toBe(false); + expect(options.enableEnrichment).toBe(false); }); it('preserves tex and loader config sections', () => { @@ -408,3 +399,34 @@ describe('MathJax config defaults', () => { expect(a).toEqual(b); }); }); + +describe('buildMathJaxConfigScript (#2312 enrichment override)', () => { + it('serializes the config onto window.MathJax', () => { + const script = buildMathJaxConfigScript(getDefaultMathjaxConfig()); + expect(script).toContain('window.MathJax = ('); + expect(script).toContain('"enableEnrichment":false'); + }); + + it('wraps startup.ready to re-apply a11y toggles onto the live document', () => { + const script = buildMathJaxConfigScript(getDefaultMathjaxConfig()); + // The ready hook must run the default startup and then copy the + // configured a11y options onto the built MathDocument (where they + // actually take effect — MathJax ignores them in the config block). + expect(script).toContain('cfg.startup.ready = function'); + expect(script).toContain('defaultReady()'); + expect(script).toContain('doc.options[keys[i]] = wanted[keys[i]]'); + expect(script).toContain('enableEnrichment'); + }); + + it('preserves a user-provided ready hook', () => { + const script = buildMathJaxConfigScript({ options: {} }); + expect(script).toContain("typeof userReady === 'function'"); + expect(script).toContain('userReady()'); + }); + + it('produces a syntactically valid script', () => { + const script = buildMathJaxConfigScript(getDefaultMathjaxConfig()); + // Wrap so the bare `window.MathJax = (...)` assignment parses. + expect(() => new Function('window', script)).not.toThrow(); + }); +}); diff --git a/test/sanitize.test.ts b/test/sanitize.test.ts index b9ac960f..8a3098da 100644 --- a/test/sanitize.test.ts +++ b/test/sanitize.test.ts @@ -138,6 +138,45 @@ describe('sanitizeRenderedHTML (CVE-2025-65716)', () => { expect(result).toContain('{"signal":[]}'); }); + it('normalizes JSON5 WaveDrom data scripts to strict JSON', () => { + const result = sanitize( + `
`, + ); + expect(result).toContain('`, + ); + expect(result).not.toContain(' { + const result = sanitize( + `
`, + ); + expect(result).not.toContain(' value so it cannot break out after re-serialization', () => { + // The raw input uses a JSON escape (<\/script>) so the HTML parser does + // NOT treat it as a closing tag; JSON5 decodes it to a literal + // "" string value, which JSON.stringify would otherwise emit + // raw and break out of the ', + ); + expect(result).toContain(''); + }); + it('preserves TikZ scripts', () => { const tikzCode = '\\begin{tikzpicture}\\draw (0,0) -- (1,1);\\end{tikzpicture}'; diff --git a/test/wavedrom-source.test.ts b/test/wavedrom-source.test.ts new file mode 100644 index 00000000..63814504 --- /dev/null +++ b/test/wavedrom-source.test.ts @@ -0,0 +1,79 @@ +import { normalizeWavedromSource } from '../src/renderers/wavedrom-source'; + +describe('normalizeWavedromSource (shd101wyy/vscode-markdown-preview-enhanced#2315)', () => { + describe('accepts and normalizes valid WaveDrom data', () => { + it('passes through strict JSON unchanged in meaning', () => { + expect(normalizeWavedromSource('{"signal":[]}')).toBe('{"signal":[]}'); + }); + + it('normalizes unquoted keys to strict JSON', () => { + expect(normalizeWavedromSource('{ signal: [] }')).toBe('{"signal":[]}'); + }); + + it('normalizes single-quoted strings, trailing commas, and comments', () => { + const out = normalizeWavedromSource(`{ + // clock + signal: [ + { name: 'clk', wave: 'p..', }, + ], + }`); + expect(out).toBe('{"signal":[{"name":"clk","wave":"p.."}]}'); + }); + + it('accepts the reg root used by bitfield-style diagrams', () => { + expect(normalizeWavedromSource('{ reg: [ { bits: 8 } ] }')).toBe( + '{"reg":[{"bits":8}]}', + ); + }); + + it('preserves hex numbers as their decimal value', () => { + expect(normalizeWavedromSource('{ addr: 0xFF }')).toBe('{"addr":255}'); + }); + }); + + describe('rejects non-data / executable input', () => { + it('returns null for a function-call payload', () => { + expect(normalizeWavedromSource('alert(1)')).toBeNull(); + }); + + it('returns null for an IIFE', () => { + expect( + normalizeWavedromSource('(()=>{return globalThis.process})()'), + ).toBeNull(); + }); + + it('returns null for constructor-escape payloads', () => { + expect( + normalizeWavedromSource('this.constructor.constructor("return 1")()'), + ).toBeNull(); + }); + + it('returns null for bare scalars (not a diagram object)', () => { + expect(normalizeWavedromSource('42')).toBeNull(); + expect(normalizeWavedromSource('"clk"')).toBeNull(); + expect(normalizeWavedromSource('null')).toBeNull(); + }); + + it('returns null for empty / whitespace input', () => { + expect(normalizeWavedromSource('')).toBeNull(); + expect(normalizeWavedromSource(' ')).toBeNull(); + }); + }); + + describe('neutralizes breakout', () => { + it('escapes < so a string value cannot close the container', () => { + const out = normalizeWavedromSource( + '{ "name": "" }', + ); + expect(out).not.toBeNull(); + expect(out).not.toContain(''); + expect(out).toContain('\\u003c/script>'); + }); + + it('produces output that is still valid JSON round-tripping the data', () => { + const out = normalizeWavedromSource('{ "n": "a Date: Fri, 5 Jun 2026 09:41:38 +0800 Subject: [PATCH 03/13] fix: prevent Windows command injection from preview links and diagram filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by @byte16384: openFile() opened external/unsupported link targets with child_process.exec(`start ${href}`), which runs through cmd.exe. The href comes from the markdown file and is URL-decoded, so a crafted link like [x](mailto:a%26%20calc.exe) decoded to `mailto:a& calc.exe` and cmd.exe ran `calc.exe` as a second command — one-click RCE on Windows. - openFile(): spawn via execFile('explorer.exe', [target]) (no shell) so the target is always one uninterpreted argument; `open --` on macOS to block option injection. - Similar sinks: the diagram `filename` attribute flows into converters that shell out (ImageMagick via imagemagick-cli's child_process.exec; mermaid-cli spawned with shell:true). Added sanitizeImageFilename() (allowlist + no path traversal) applied where filenames are resolved in process-graphs. - pdf2svg (@import of a .pdf, auto-rendered on preview) no longer spawns with shell:true / manual quoting. Tests: test/command-injection.test.ts covers the byte16384 PoC (no shell, href passed as a single arg) across win32/darwin/linux, plus sanitizeImageFilename. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + src/converters/process-graphs.ts | 11 ++- src/tools/pdf.ts | 26 ++++--- src/utility.ts | 49 ++++++++++-- test/command-injection.test.ts | 130 +++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 test/command-injection.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cce9031c..7b7a1ffc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/relea ### Bug fixes +- **Fix Windows command injection via clicked preview links** — `openFile()` opened external/unsupported targets with `child_process.exec("start " + href)`, which runs through `cmd.exe`. Because the `href` comes from the markdown file and is URL-decoded, a crafted link such as `[x](mailto:hello@example.com%26%20calc.exe)` decoded to `mailto:hello@example.com& calc.exe`, and `cmd.exe` treated `&` as a command separator — one-click arbitrary code execution on Windows. `openFile()` now spawns via `child_process.execFile` (no shell) so the target is always passed as a single, uninterpreted argument. The diagram `filename` attribute, which flows into image converters that shell out (ImageMagick via `imagemagick-cli`, mermaid-cli), is now restricted to a safe relative name, and the `pdf2svg` import path no longer runs through a shell. Thanks to [@byte16384](https://github.com/byte16384) for the responsible disclosure. - **Eliminate arbitrary code execution in WaveDrom rendering** — WaveDrom diagrams were parsed by evaluating untrusted markdown content with `eval()`, enabling arbitrary JavaScript execution. This affected every render path: the live preview (`window.eval`), and presentation mode plus HTML export (the bundled `WaveDrom.ProcessAll()`/`eva()` helpers). The live preview now parses with `JSON5.parse()`, and — because a malicious `