Supply Chain Security Analysis of a 9.5M
Supply Chain Security Analysis of a 9.5M-Install VS Code Extension
In this post
- A WaveDrom block that runs as JavaScript
- From a Markdown file to arbitrary file write
- The deeper audit: four more CVEs
- Configuration files as code execution (CVE-2026-54566)
- A workspace file as a webview backdoor (CVE-2026-54701)
- The shared infrastructure: dispatch and file write (CVE-2026-54702, CVE-2026-54703)
- Why this is a supply-chain problem
- How Neo found it
- Fixing this class of bug
- Timeline
- Related reading
Authors
Your code editor extensions auto-update and run with your privileges on the machine that holds your source code, your SSH keys, and your publishing credentials, but they rarely show up in a software bill of materials.
Using Neo we audited one of the most popular ones, Markdown Preview Enhanced has roughly 9.5 million installs. The neo found five CVEs across two attack surfaces. A WaveDrom rendering bug turned an ordinary Markdown file into JavaScript execution inside the preview, then into arbitrary file write on disk (CVE-2026-50733, fixed in v0.8.28). A VM sandbox escape gave a workspace configuration file direct OS command execution (CVE-2026-54566, fixed in v0.8.29). An unsanitized HTML injection provided a second XSS entry point (CVE-2026-54701) and the webview-to-host messaging channel that made it all land on disk earned two more (CVE-2026-54702, CVE-2026-54703). All five are now fixed.
Below is the chain from "open a README" to arbitrary file write, the four additional vulnerabilities the same audit turned up, and why a Markdown bug in a developer tool counts as supply-chain risk.
A WaveDrom block that runs as JavaScript
Markdown Preview Enhanced renders rich content (math, diagrams, exports) inside a VS Code webview. WaveDrom is one of its diagram formats. Before the fix, the renderer took the raw text of a WaveDrom block and evaluated it as JavaScript:
typescript
1const content = window.eval(`(${text})`);
text
comes straight from the Markdown file. Open a crafted file, render the preview, and you hit this path. Attacker-controlled JavaScript runs inside the preview webview. No code-chunk opt-in, no setting to enable.
The extension did sanitize. After an earlier XSS fix, it added Cheerio and DOMPurify passes. Diagram renderers need their data preserved, though, so the sanitizer kept a small allowlist of script-like diagram types (new Set(['wavedrom', 'text/tikz'])
) and let the WaveDrom carrier through. Sanitization held at one layer; a later feature treated that preserved content as code anyway.
From a Markdown file to arbitrary file write
Execution inside a preview is, on its own, just webview XSS. It gets serious at the second boundary: webview-to-extension messaging. VS Code isolates webviews from the extension host. They talk via postMessage
, which is only safe if the host treats those messages as untrusted. This one did not. It dispatched a command straight from the message:
typescript
1previewPanel.webview.onDidReceiveMessage((message) => {
2 vscode.commands.executeCommand(
3 `_crossnote.${message.command}`, // attacker-controlled command name
4 ...message.args, // attacker-controlled arguments
5 );
6});
One reachable command, _crossnote.updateMarkdown
, wrote content to a URI with no containment check:
typescript
1async function updateMarkdown(uri: string, markdown: string) {
2 const sourceUri = vscode.Uri.parse(uri);
3 await vscode.workspace.fs.writeFile(sourceUri, Buffer.from(markdown));
4 // any file:// URI, any content, no workspace boundary check
5}
Four links in the chain: Markdown hits eval
in the preview. The injected JS recovers the webview API handle the preview already holds. That handle reaches command dispatch via postMessage
. Dispatch lands on arbitrary file write through updateMarkdown
.
The deeper audit: four more CVEs
The WaveDrom chain relied on two pieces of shared infrastructure: a dispatcher that forwarded any webview message to the VS Code command system, and a command that wrote to any file path without validation. Both are independently dangerous any JavaScript executing in the webview can reach them, not just WaveDrom. And the audit found two more entry points that reached the extension host or the webview through workspace configuration files rather than Markdown content.
Configuration files as code execution (CVE-2026-54566)
Markdown Preview Enhanced loads .crossnote/config.js
and .crossnote/parser.js
from any workspace that contains them. In v0.8.28, both were evaluated with Node.js vm.runInNewContext()
:
typescript
1const result = vm.runInNewContext(code, sandbox, { timeout: 10000 });
Node's documentation explicitly states that vm
is not a security mechanism. The sandbox object lives in the host V8 realm, so its prototype chain leads back to the host's constructors:
javascript
1(() => {
2 var F = this.constructor.constructor;
3 var p = F('return process')();
4 p.getBuiltinModule('child_process').execSync('id > /tmp/pwned.txt');
5 return {};
6})()
An arrow-function IIFE preserves the outer-realm this
, and this.constructor.constructor
yields the host Function
constructor. getBuiltinModule
works on Node 22.3+ without needing require
, which is module-scoped and unavailable in VS Code's extension host. Place this in .crossnote/config.js
, open any Markdown file in the same workspace, and MPE evaluates it during preview initialization. Arbitrary OS commands run under the developer's account. No preview interaction, no opt-in, no dialog.
The web extension path was equally vulnerable. It used sval
, a JS-in-JS interpreter that shares the host's object prototypes. The same ({}).constructor.constructor('return process')()
escape worked there. Any in-process evaluator that shares a realm with the host is escapable.
This was fixed in v0.8.29 (crossnote 0.9.30). Both files are now evaluated inside QuickJS compiled to WebAssembly. The guest runs in a complete JavaScript engine with its own intrinsics and its own heap inside WASM linear memory β the host's process
, Function
, and Object
do not exist in that realm. Only explicitly marshaled values cross the boundary. Security-sensitive config keys (enableScriptExecution
, chromePath
, pandocPath
) are stripped from untrusted config output as defense-in-depth.
Advisory: GHSA-427h-jhpr-8jch
A workspace file as a webview backdoor (CVE-2026-54701)
.crossnote/head.html
is a per-workspace file that lets users inject custom styles or scripts into the preview. The extension reads it via getHeaderIncludes()
and injects the raw content directly into the
of the webview HTML template:
typescript
1// config-helper.ts β reads the file with no sanitization
2const headerContent = await fs.readFile(headHtmlPath, 'utf-8');
html
1
2
3 ...styles and scripts...
4 ${await this.resolvePathsInHeader(this.notebook.config.includeInHeader)}
5
6...
7
resolvePathsInHeader()
resolves relative
paths. It does not strip scripts, sanitize HTML, or filter anything.
The extension has two sanitization layers: a server-side Cheerio pass and a client-side DOMPurify pass. Neither blocks this vector. Both process the rendered Markdown body. head.html
content is injected into
and executes at page parse time β before the React app, and therefore before DOMPurify, has initialized.
VS Code injects acquireVsCodeApi
as a global before any webview HTML parses. A script in head.html
can proxy that function β replacing it with a wrapper that captures the API object when the preview's React app calls it β and use the result to reach the same postMessage
β dispatch β file-write chain the WaveDrom exploit used. The preview renders normally. The attack is invisible.
This was fixed in v0.8.30 (crossnote 0.9.31). resolvePathsInHeader()
now strips all
tags from head.html
before injection.
Advisory: GHSA-mcwg-4j78-qwv3
The shared infrastructure: dispatch and file write (CVE-2026-54702, CVE-2026-54703)
The message dispatcher and the updateMarkdown
command are shown in the WaveDrom chain above. They received separate CVEs because they are independently exploitable infrastructure, not just enabling links in one chain.
CVE-2026-54702 (blind command dispatch): The dispatcher forwards any webview message to vscode.commands.executeCommand('_crossnote.' + message.command, ...message.args)
with no allowlist. Over thirty registered commands are reachable, including runCodeChunk
, runAllCodeChunks
, chromeExport
, pandocExport
, and openInBrowser
. Any future XSS in the extension β from Markdown content, a diagram renderer, or a third-party dependency β immediately escalates to host-level command invocation through this channel.
CVE-2026-54703 (arbitrary file write): updateMarkdown
accepts any file://
URI and writes any content via vscode.workspace.fs.writeFile()
. It checks neither the path nor the content. It was designed as an internal API for the preview to sync edits back to the source Markdown file, but without containment it becomes an arbitrary file-write primitive available to any code executing in the webview.
Both were fixed in v0.8.30. The dispatcher now validates commands against a 37-entry allowlist and pins updateMarkdown
writes to the previewed file's URI. updateMarkdown
additionally validates the target file extension against markdownFileExtensions
.
Why this is a supply-chain problem
"Write any file" sounds tamer than "run any command," but on a developer box they amount to the same thing once the write targets something the system later reads and trusts:
- Shell startup files (
~/.bashrc
,~/.zshrc
): classic file-write-to-RCE pivot on the next shell - Git hooks (
.git/hooks/pre-commit
,post-checkout
): runs on the next ordinary Git operation - Build and tooling config (
.vscode/tasks.json
, lint/test configs, dependency manifests): runs on the next build or install - SSH config (
~/.ssh/authorized_keys
): append a key, get persistent remote access. The original report used this to show impact.
Any of those writes puts an attacker upstream of CI, registries, and the artifacts you ship. One vulnerable build of a 9.5M-install extension is a shared dependency across millions of environments that auto-update together.
Open a Markdown file in preview. READMEs, dependency source, PR descriptions, and generated reports all flow through that path. A poisoned README.md
in a dependency or a starred sample repo is enough to get from "open a file" to a foothold on the machine that publishes your software.
Preview eval
, an open webview channel, blind command dispatch, and an uncontained file write had to line up. Remove any one link and the chain breaks.
How Neo found it
Neo ran this as a multi-step source audit, not a grep-for-eval
pass. The prompt pushed back on weak findings: Workspace Trust is a real boundary, resource-scoped settings are not automatically bugs, and a credible report needs pre-trust impact or actual privilege escalation. The initial deep run logged 217 steps, 322 tool calls, and 9 agent triggers.
What made it worth reporting: the input was a plain Markdown file with no opt-in, execution happened in a webview with a live channel back to the host, and the file write was demonstrated rather than hand-waved. "There is an eval
" is not a useful report. "This input reaches eval
, this JS crosses this boundary, this command writes this file, here is the fix" is.
Fixing this class of bug
All five CVEs are now fixed. CVE-2026-50733 (WaveDrom eval) was resolved in v0.8.28 (crossnote 0.9.29). The fix removed eval
entirely: the preview now parses WaveDrom with JSON5.parse
, and a new normalizeWavedromSource()
helper validates each block and re-serializes to inert strict JSON. CVE-2026-54566 (VM sandbox escape) was resolved in v0.8.29 (crossnote 0.9.30). The fix replaced vm.runInNewContext()
and sval
with QuickJS compiled to WebAssembly β a separate engine with its own heap in WASM linear memory, no prototype chain back to the host.
The remaining three CVEs were fixed in v0.8.30 (crossnote 0.9.31): the unsanitized head.html
injection (CVE-2026-54701) was addressed by stripping
tags in resolvePathsInHeader()
, the unvalidated message dispatcher (CVE-2026-54702) now uses a strict command allowlist with URI pinning, and the unconstrained file write (CVE-2026-54703) now validates file extensions. Update to 0.8.30 or later to get all fixes.
If you build extensions with a rich preview surface:
- Treat rendered content as hostile input. Markdown shows up via repos, PRs, docs, and generated reports.
- Never
eval
a diagram format. Parse withJSON.parse
or a strict parser. Isolate any legacy renderer that wants JS literals. - Never evaluate workspace files in the host realm.
vm.runInNewContext()
,sval
, and any in-process evaluator that shares the host's prototypes are not security boundaries. Use a WASM-isolated engine or a subprocess with restricted capabilities. - Sanitize workspace-sourced HTML before injection. If a feature allows custom HTML in the preview, strip
tags and event handlers before injecting it into the webview template. Do not rely on client-side sanitizers that initialize after the injected content executes. - Validate every webview message. Allowlist command names, argument types, and URI schemes. Enforce workspace containment. Do not dispatch arbitrary command names.
- Scope internal commands. If a command writes content back to a file, validate that the target is within the workspace and that the file type matches expectations.
- Enforce a real CSP. No
unsafe-eval
orunsafe-inline
. Tight script sources, narrow resource roots. A backstop, not a substitute for input validation.
Timeline
- CVE-2026-50733 - WaveDrom
eval()
code injection. Reported Jun 2, fixed Jun 5 in v0.8.28 - CVE-2026-54566 - VM sandbox escape in config.js/parser.js. Reported Jun 6, fixed Jun 6 in v0.8.29
- CVE-2026-54701 - Stored XSS via unsanitized head.html. Reported Jun 6, fixed Jun 8 in v0.8.30
- CVE-2026-54702 - Blind command dispatch via webview messages. Reported Jun 6, fixed Jun 8 in v0.8.30
- CVE-2026-54703 - Arbitrary file write via updateMarkdown. Reported Jun 6, fixed Jun 8 in v0.8.30
Related reading
Other writeups on editor extensions, preview webviews, and turning XSS into code execution on a developer machine:
- Escaping misconfigured VSCode extensions (Trail of Bits)
- Breaking Out of Restricted Mode: XSS to RCE in Visual Studio Code (STAR Labs)
- 1-Click GitHub Token Stealing via a VSCode Bug (Ammar Askar)
- XSS in Live Preview, a VS Code extension with 11M downloads (OX Security)
Neo is ProjectDiscovery's agentic platform for security work: vulnerability research, code review, pentesting, triage, and verification.
Related stories
Explore more stories, research, and updates from the ProjectDiscovery team.
How it works
Once you click Generate, Ollama reads this article and crafts 5 comprehension questions. Your answers are graded against the article content β general knowledge won't be enough. Score 70+ to count toward your certificate.
Questions are cached β you'll always get the same 5 for this article.