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('
');
   });
 });
+
+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/browser/mathjax.spec.ts b/test/browser/mathjax.spec.ts
new file mode 100644
index 000000000..093137a67
--- /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 000000000..7f95bfc95 --- /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 000000000..6a54a135d --- /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/command-injection.test.ts b/test/command-injection.test.ts new file mode 100644 index 000000000..3bc432266 --- /dev/null +++ b/test/command-injection.test.ts @@ -0,0 +1,157 @@ +/** + * Regression tests for Windows command injection via clicked preview links + * and diagram output filenames (reported by @byte16384). + * + * A crafted markdown link such as + * [x](mailto:hello@byte256.csrf.blog%26%20calc.exe) + * decodes to `mailto:hello@byte256.csrf.blog& calc.exe` and used to reach + * `child_process.exec('start ' + href)`, where cmd.exe interpreted `&` as a + * command separator — one-click RCE on Windows. `openFile` must never use a + * shell. Likewise the diagram `filename` attribute flows into image converters + * that shell out, so it must be sanitized. + */ +import * as child_process from 'child_process'; +import { openFile, sanitizeImageFilename } from '../src/utility'; + +jest.mock('child_process', () => ({ + execFile: jest.fn(), + exec: jest.fn(), +})); + +const execFileMock = child_process.execFile as unknown as jest.Mock; +const execMock = child_process.exec as unknown as jest.Mock; + +function withPlatform(platform: NodeJS.Platform, fn: () => void) { + const original = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + try { + fn(); + } finally { + if (original) { + Object.defineProperty(process, 'platform', original); + } + } +} + +describe('openFile never uses a shell (#byte16384 Windows RCE)', () => { + beforeEach(() => { + execFileMock.mockReset(); + execMock.mockReset(); + }); + + it('does not call child_process.exec on Windows', () => { + withPlatform('win32', () => { + openFile('mailto:hello@byte256.csrf.blog& calc.exe'); + }); + expect(execMock).not.toHaveBeenCalled(); + expect(execFileMock).toHaveBeenCalledTimes(1); + }); + + it('passes a malicious href as a single, unsplit argument to explorer.exe', () => { + withPlatform('win32', () => { + openFile('mailto:hello@byte256.csrf.blog& calc.exe'); + }); + const [command, args] = execFileMock.mock.calls[0]; + expect(command).toBe('explorer.exe'); + // The whole string is one argument — no shell, so `&` cannot split a + // second command. + expect(args).toEqual(['mailto:hello@byte256.csrf.blog& calc.exe']); + }); + + it('keeps other shell metacharacters inside one argument', () => { + withPlatform('win32', () => { + openFile('http://x/?a=1 && calc.exe | whoami'); + }); + const [, args] = execFileMock.mock.calls[0]; + expect(args).toHaveLength(1); + expect(args[0]).toBe('http://x/?a=1 && calc.exe | whoami'); + }); + + it('normalizes a drive path to a file:// URI and opens via explorer.exe', () => { + withPlatform('win32', () => { + openFile('C:\\Users\\me\\report.pdf'); + }); + expect(execFileMock).toHaveBeenCalledWith('explorer.exe', [ + 'file:///C:\\Users\\me\\report.pdf', + ]); + }); + + it('uses `open --` on macOS so a leading-dash arg is not a flag', () => { + withPlatform('darwin', () => { + openFile('-x'); + }); + expect(execMock).not.toHaveBeenCalled(); + expect(execFileMock).toHaveBeenCalledWith('open', ['--', '-x']); + }); + + it('uses xdg-open (no shell) on Linux', () => { + withPlatform('linux', () => { + openFile('mailto:a& calc'); + }); + expect(execMock).not.toHaveBeenCalled(); + expect(execFileMock).toHaveBeenCalledWith('xdg-open', ['mailto:a& calc']); + }); +}); + +describe('sanitizeImageFilename (diagram export filename injection)', () => { + it('accepts plain file names', () => { + expect(sanitizeImageFilename('diagram.png')).toBe('diagram.png'); + expect(sanitizeImageFilename('my-chart_v2.png')).toBe('my-chart_v2.png'); + }); + + it('accepts a subdirectory path', () => { + expect(sanitizeImageFilename('assets/diagram.png')).toBe( + 'assets/diagram.png', + ); + }); + + it('accepts non-ASCII (Unicode) filenames', () => { + // Unicode letters have no shell meaning; non-English filenames must work. + expect(sanitizeImageFilename('图表.png')).toBe('图表.png'); + expect(sanitizeImageFilename('schéma_électrique.png')).toBe( + 'schéma_électrique.png', + ); + expect(sanitizeImageFilename('диаграмма.png')).toBe('диаграмма.png'); + expect(sanitizeImageFilename('assets/図.png')).toBe('assets/図.png'); + }); + + it('accepts a leading-slash project-root path (MPE convention, not absolute)', () => { + // `/assets/x.png` means "relative to the project root" — the caller + // resolves it against projectDirectoryPath, not the filesystem root. + expect(sanitizeImageFilename('/assets/diagram.png')).toBe( + '/assets/diagram.png', + ); + }); + + it('rejects shell metacharacters', () => { + expect(sanitizeImageFilename('x.png && calc.exe')).toBe(''); + expect(sanitizeImageFilename('x.png; rm -rf /')).toBe(''); + expect(sanitizeImageFilename('$(calc).png')).toBe(''); + expect(sanitizeImageFilename('`calc`.png')).toBe(''); + expect(sanitizeImageFilename('a|b.png')).toBe(''); + expect(sanitizeImageFilename('"quoted".png')).toBe(''); + }); + + it('rejects whitespace and control characters', () => { + expect(sanitizeImageFilename('my diagram.png')).toBe(''); + expect(sanitizeImageFilename('a\tb.png')).toBe(''); + expect(sanitizeImageFilename('a\nb.png')).toBe(''); + expect(sanitizeImageFilename('a\x00b.png')).toBe(''); + // Unicode RTL override (filename-spoofing / control) is rejected. + expect(sanitizeImageFilename('evil‮gnp.png')).toBe(''); + }); + + it('rejects `..` path traversal (even with a leading slash)', () => { + expect(sanitizeImageFilename('../../etc/passwd')).toBe(''); + expect(sanitizeImageFilename('a/../../b.png')).toBe(''); + expect(sanitizeImageFilename('/../outside.png')).toBe(''); + }); + + it('returns empty for empty/undefined input (caller falls back to default)', () => { + expect(sanitizeImageFilename(undefined)).toBe(''); + expect(sanitizeImageFilename('')).toBe(''); + }); +}); diff --git a/test/math.test.ts b/test/math.test.ts index 87ce4838a..9ec3b3f29 100644 --- a/test/math.test.ts +++ b/test/math.test.ts @@ -11,7 +11,9 @@ * 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'; describe('Math rendering', () => { let notebook: Notebook; @@ -374,3 +376,57 @@ describe('Math rendering with custom delimiters', () => { expect(html).not.toContain('class="katex"'); }); }); + +describe('MathJax config defaults', () => { + 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.enableEnrichment).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); + }); +}); + +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 b9ac960ff..8a3098da5 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-parser.test.ts b/test/wavedrom-parser.test.ts new file mode 100644 index 000000000..5e04ebdaf --- /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 arrow-function IIFE', () => { + 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(); + }); + }); +}); diff --git a/test/wavedrom-source.test.ts b/test/wavedrom-source.test.ts new file mode 100644 index 000000000..6156eb9ca --- /dev/null +++ b/test/wavedrom-source.test.ts @@ -0,0 +1,91 @@ +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 a top-level array root', () => { + expect(normalizeWavedromSource('[{ name: "clk" }]')).toBeNull(); + }); + + it('returns null rather than silently corrupting non-finite numbers', () => { + // JSON5 accepts Infinity/NaN but strict JSON cannot represent them; + // drop the diagram instead of emitting `null` for the value. + expect(normalizeWavedromSource('{ max: Infinity }')).toBeNull(); + expect(normalizeWavedromSource('{ min: -Infinity }')).toBeNull(); + expect(normalizeWavedromSource('{ x: NaN }')).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