diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 946949363..7d20e50e1 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 743df6301..871ba9177 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ out
test.js
test.mjs
.direnv
+.codegraph
tsconfig.tsbuildinfo
pnpm-debug.log
.mume
@@ -17,3 +18,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 04e337e9f..a24d0e6e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,14 @@
Please visit https://github.com/shd101wyy/vscode-markdown-preview-enhanced/releases for the more changelog
-## [Unreleased]
+## [0.9.29] - 2026-06-05
+
+### Bug fixes
+
+- **Harden external file/link opening against command injection** — Opening links and files from the preview no longer goes through a shell, and untrusted inputs (the diagram `filename` attribute, imported file paths, and the `latex_engine` code-chunk attribute) are passed as literal arguments or validated before use. This closes a security issue affecting Windows. Thanks to @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 ``;
+ )} `;
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, or a top-level array) is not a
+ // valid diagram.
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) {
+ return null;
+ }
+ // Re-serialize to strict JSON. JSON5 accepts `Infinity`/`-Infinity`/`NaN`,
+ // but `JSON.stringify` silently coerces them to `null`; throw instead so a
+ // diagram relying on those values is dropped rather than rendered with
+ // corrupted data.
+ const json = JSON.stringify(data, (_key, value) => {
+ if (typeof value === 'number' && !Number.isFinite(value)) {
+ throw new SyntaxError('WaveDrom data contains a non-finite number');
+ }
+ return value;
+ });
+ return json.replace(/ {
return new Promise((resolve, reject) => {
- const task = spawn(latexEngine, [`"${texFilePath}"`], {
+ // SECURITY: do NOT use `shell: true`. `latexEngine` comes from the
+ // `latex_engine` code-chunk attribute (attacker-controlled markdown), so a
+ // shell would let metacharacters chain extra commands. Spawning without a
+ // shell runs only the named engine and passes the temp path as one literal
+ // argument (the quotes that shell mode needed must be dropped). Code-chunk
+ // execution is additionally gated behind `enableScriptExecution`.
+ const task = spawn(latexEngine, [texFilePath], {
cwd: path.dirname(texFilePath),
- shell: true,
});
const chunks: Buffer[] = [];
diff --git a/src/tools/pdf.ts b/src/tools/pdf.ts
index 273d2f940..5c380fecc 100644
--- a/src/tools/pdf.ts
+++ b/src/tools/pdf.ts
@@ -36,18 +36,20 @@ export function toSVGMarkdown(
const svgFilePrefix = computeChecksum(pdfFilePath) + '_';
- const task = spawn(
- 'pdf2svg',
- [
- `"${pdfFilePath}"`,
- `"${path.resolve(
- svgDirectoryPath ?? `/tmp/crossnote_pdf`,
- svgFilePrefix + '%d.svg',
- )}"`,
- 'all',
- ],
- { shell: true },
- );
+ // SECURITY: do NOT use `shell: true`. `pdfFilePath` can come from a
+ // markdown `@import "…"` (auto-rendered on preview), so running through a
+ // shell would let metacharacters in the path inject commands. Spawning
+ // without a shell passes each path as a single literal argument (and the
+ // surrounding quotes that shell mode required must be dropped, or they
+ // become part of the filename).
+ const task = spawn('pdf2svg', [
+ pdfFilePath,
+ path.resolve(
+ svgDirectoryPath ?? `/tmp/crossnote_pdf`,
+ svgFilePrefix + '%d.svg',
+ ),
+ 'all',
+ ]);
const chunks: string[] = [];
task.stdout.on('data', (chunk) => {
chunks.push(chunk);
diff --git a/src/utility.ts b/src/utility.ts
index 3f776ef70..d1854bae0 100644
--- a/src/utility.ts
+++ b/src/utility.ts
@@ -21,6 +21,46 @@ export function tempOpen(options: temp.AffixOptions): Promise {
return temp.open(options);
}
+// Characters that are dangerous in a shell command line (the ImageMagick and
+// mermaid converters build/spawn shell strings), invalid in Windows paths, or
+// otherwise risky: shell metacharacters, quotes, whitespace, the backslash, and
+// any Unicode control/format character (`\p{C}`, e.g. NUL, RTL override).
+// Everything else — including Unicode letters such as CJK or accented Latin —
+// is allowed, so non-English filenames keep working.
+const UNSAFE_IMAGE_FILENAME_CHARS = /[\p{C}\s"'`<>:|?*\\$&;(){}[\]!^%~#]/u;
+
+/**
+ * Sanitize a user-supplied diagram `filename` attribute before it is used to
+ * build an output image path.
+ *
+ * The value comes from untrusted markdown (e.g. ```` ```mermaid {filename=…} ````)
+ * and is later joined into an output path that is handed to image converters
+ * which shell out — ImageMagick via `imagemagick-cli` (uses
+ * `child_process.exec`) and `@mermaid-js/mermaid-cli` (spawned with
+ * `shell: true`). Shell metacharacters in the name would therefore allow
+ * command injection on export. We reject names containing shell metacharacters,
+ * whitespace, control characters, or `..` traversal; anything unsafe returns
+ * `''` so the caller falls back to its auto-generated name. Unicode letters
+ * (CJK, accented Latin, …) are allowed — they are not shell-special.
+ *
+ * A leading `/` is permitted and kept: consistent with MPE's `imageFolderPath`
+ * convention, it means "relative to the project root" (resolved by the caller),
+ * NOT a filesystem-absolute path. The `..` rejection keeps it inside that root.
+ */
+export function sanitizeImageFilename(name: string | undefined): string {
+ if (!name) {
+ return '';
+ }
+ if (UNSAFE_IMAGE_FILENAME_CHARS.test(name)) {
+ return '';
+ }
+ // Refuse `..` segments so the resolved path can't escape its base directory.
+ if (name.split('/').includes('..')) {
+ return '';
+ }
+ return name;
+}
+
/**
* open html file in browser or open pdf file in reader ... etc
* @param filePath
@@ -31,13 +71,20 @@ export function openFile(filePath: string) {
// C:\ like url.
filePath = 'file:///' + filePath;
}
- if (filePath.startsWith('file:///')) {
- return child_process.execFile('explorer.exe', [filePath]);
- } else {
- return child_process.exec(`start ${filePath}`);
- }
+ // SECURITY: never pass `filePath` through a shell. `filePath` may be an
+ // attacker-controlled href from a clicked preview link, so the previous
+ // `child_process.exec('start ' + filePath)` ran via cmd.exe, where shell
+ // metacharacters injected commands — e.g. a markdown link
+ // `[x](mailto:a%26%20calc.exe)` decodes to `mailto:a& calc.exe` and the
+ // `&` runs `calc.exe` as a second command (one-click RCE on Windows,
+ // reported by @byte16384). `execFile` spawns without a shell, so `&`, `|`,
+ // `&&`, … are passed verbatim as a single argument to `explorer.exe`,
+ // which opens the file/URI via the OS default handler.
+ return child_process.execFile('explorer.exe', [filePath]);
} else if (process.platform === 'darwin') {
- child_process.execFile('open', [filePath]);
+ // `--` stops option parsing so a `filePath` starting with `-` can't be
+ // interpreted as a flag. `execFile` already avoids the shell.
+ child_process.execFile('open', ['--', filePath]);
} else {
child_process.execFile('xdg-open', [filePath]);
}
diff --git a/src/webview/containers/preview.ts b/src/webview/containers/preview.ts
index 45c649000..33bd6428f 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,6 +419,10 @@ const PreviewContainer = createContainer(() => {
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']
@@ -466,11 +471,11 @@ 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) {
- 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 67032247a..402044cc2 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/bitfield.test.ts b/test/bitfield.test.ts
index b0d545a5f..4d1bb7f86 100644
--- a/test/bitfield.test.ts
+++ b/test/bitfield.test.ts
@@ -22,3 +22,67 @@ describe('Bitfield renderer', () => {
expect(result).toContain('