Research
CVE-2026-112947: Reading Past Vite's server.fs Boundary Through a WebAssembly Error
Vite is the dev server and build tool underneath a large share of front-end projects. During development it serves your source tree over HTTP, and one setting, server.fs, decides which files that HTTP server may hand out: server.fs.allow lists the directories it can serve from, and server.fs.deny blocks names like .env and *.pem inside them.
On 4 September we reported to the Vite team that one query string, ?vite-wasm-instance, skipped that check. A request carrying it made Vite read any file on the host and try to compile it as WebAssembly, and the compile error sent the file’s first four bytes back to the requester. GitHub assigned CVE-2026-112947 today, two weeks after the fix shipped.
- Advisory: GHSA-vfpm-58rq-9qcg, CVSS 4.0 6.3 (Medium)
- Fixed in: Vite 8.2.4 and 8.3.3, released 6 October 2026, and vite-plus 1.1.0
Four bytes isn’t much, and we’ll be plain about that below. The bug is still worth a post, because of what the error message carried besides those bytes and because it got past both halves of server.fs at once.
Who is affected
Every Vite 8.2 release before 8.2.4 and every 8.3 release before 8.3.3. Vite 8.1 and earlier don’t have the query. The advisory also lists vite-plus 0.2.8 through 1.0.0, which bundles Vite. Frameworks pull Vite in as a dependency, so check what’s actually installed rather than what your package.json names:
npm ls vite
An attacker has to be able to reach the dev server. Vite listens on localhost by default, and its default CORS policy stops a page on another origin from reading the responses, so the realistic exposure is a dev server started with --host or server.host, a cloud development environment that forwards the port publicly, or someone already on the same machine. Production builds aren’t affected; the output of vite build doesn’t contain the dev server.
The leak depends on the runtime’s WebAssembly error text. According to the advisory, Node.js and Deno include the bytes and Bun doesn’t. We tested on Node 22.
How Vite checks module requests
Every module request to the Vite dev server goes through a plugin pipeline: resolve the id, load it, transform it. Query strings change what plugins do. ?raw returns a file as a string, ?url returns its URL, and ?inline embeds it as base64. Each of those turns file contents into a response, and each has had its own server.fs bypass since early 2025.
Vite’s answer has been a gate in the transform step that checks server.fs when the id matches a known-dangerous pattern. In 8.2.2 it reads:
function isServerAccessDeniedForTransform(config, id) {
if (rawRE.test(id) || urlRE.test(id) || inlineRE.test(id) || svgRE.test(id))
return checkLoadingAccess(config, cleanUrl(id)) !== "allowed" ||
checkLoadingAccess(config, id) !== "allowed";
return false;
}
Earlier fixes shaped that list. Vite 6.2.3 checked two patterns, ?raw and ?url. The fix for CVE-2025-31125 added ?inline in 6.2.4, the fix for CVE-2025-31486 added .svg in 6.2.5, and the fix for CVE-2026-39364 in 8.0.5 started checking each id both with and without its query string.
An id that matches none of the patterns gets false, which means no check at all. Vite does have a second check further down, but it only guards Vite’s own fallback, the code that reads a file from disk when no plugin’s load hook returned anything. A plugin that reads the file itself never reaches it.
Where ?vite-wasm-instance comes from
Vite 8.2 changed how it handles WebAssembly modules that export globals. For those, Vite generates a small wrapper and has that wrapper import the real instance under an internal id, <file>?vite-wasm-instance. The suffix arrived in 8.2.0 with PR #22674, which taught the plugin to unwrap WebAssembly.Global exports.
The id was meant for code Vite writes itself. But the dev server builds ids from request URLs, so anyone who can reach the server can send it. The Wasm plugin’s load hook matches the query anywhere in the id, strips it, and hands the path to parseWasm():
async function parseWasm(wasmFilePath) {
try {
const wasmBinary = await fsp.readFile(wasmFilePath);
const wasmModule = await WebAssembly.compile(wasmBinary, wasmCompileOptions);
// ... collect imports and exports
} catch (e) {
throw new Error(`Failed to parse WASM file "${wasmFilePath}": ${e.message}`, { cause: e });
}
}
Nothing requires a .wasm extension, so the path can just as well be .env or /etc/passwd. And since vite-wasm-instance matches none of the four patterns, server.fs never gets a say before the read.
How four bytes get out
The plugin doesn’t return the file, so the read alone leaks nothing. The error does. Every Wasm binary starts with the magic number 00 61 73 6d (\0asm), and when V8 rejects a buffer its message reports what it found there instead:
WebAssembly.compile(): expected magic word 00 61 73 6d, found 53 45 43 52 @+0
parseWasm wraps that message in its own error, and the dev server includes the error text in its 500 response, as it does for any failed transform. The first four bytes of the file come back as hex. A file that really does begin with \0asm passes the magic check and fails on the version field instead, which gives up bytes 4 to 7:
WebAssembly.compile(): expected version 01 00 00 00, found 41 42 43 44 @+4
When readFile itself fails, the Node error goes back the same way. ENOENT, EACCES and EISDIR together answer three questions about any absolute path on the host: does it exist, can the dev server’s user read it, and is it a directory.
One routing detail matters for reach. Vite treats a URL with no file extension as a JavaScript module request, so /.env and /@fs/etc/passwd go straight into the plugin pipeline.
Reproduction
We re-ran the report today against vite@8.2.2 from npm on Node 22, with the boundary pinned in the config and the default fs.deny list in effect:
export default {
root: '<root>',
server: { fs: { strict: true, allow: ['<root>'] } },
}
The known query forms are blocked, which is the control:
GET /.env?raw -> 403 Restricted
GET /.env?import&raw -> 403 Restricted
GET /@fs/etc/passwd?raw -> 403 Restricted
The same files with ?vite-wasm-instance:
GET /.env?vite-wasm-instance
-> 500 ... expected magic word 00 61 73 6d, found 53 45 43 52 @+0 ("SECR")
GET /@fs/etc/passwd?vite-wasm-instance
-> 500 ... found 72 6f 6f 74 @+0 ("root")
GET /@fs/etc/shadow?vite-wasm-instance
-> 500 EACCES: permission denied, open '/etc/shadow'
GET /@fs/etc/nonexistent-xyz?vite-wasm-instance
-> 500 ENOENT: no such file or directory
GET /@fs/etc?vite-wasm-instance
-> 500 EISDIR: illegal operation on a directory
.env here held SECRET_KEY=abc123, and it’s on the deny list. /etc/passwd is outside the allow list entirely. Against 8.2.4 and 8.3.3, the .env and /etc/passwd requests return 403 Restricted, the same answer ?raw gets.
How bad is it
Not very, on its own. Four bytes rarely hold a secret: SECR tells you a .env file starts with the word SECRET, and every PEM file starts with dashes. The earlier server.fs bypasses returned whole files, and this one doesn’t come close to that.
What made it worth reporting is scope. It broke fs.allow as well as fs.deny, on every platform, so the reachable area was the whole filesystem. The oracle let a network attacker with no credentials confirm guessed usernames by probing /home/<name> and check whether files like ~/.aws/credentials or an SSH private key exist. That’s reconnaissance rather than compromise, but it’s reconnaissance against a developer workstation, which usually holds credentials for everything else the developer touches.
We scored it 5.3 under CVSS 3.1. The Vite team published 6.3 under CVSS 4.0, with AT:P for the precondition that the server has to be exposed. Both are Medium, and we think that’s right.
The fix
sapphi-red of the Vite team wrote the fix in PR #23655 (commit ba8b7ab), and bluwy reviewed it. It adds the query to the gate:
+const wasmInstanceRE = /[?&]vite-wasm-instance(?:&|$)/
export function isServerAccessDeniedForTransform(
config: ResolvedConfig,
id: string,
): boolean {
- if (rawRE.test(id) || urlRE.test(id) || inlineRE.test(id) || svgRE.test(id)) {
+ if (
+ rawRE.test(id) ||
+ urlRE.test(id) ||
+ inlineRE.test(id) ||
+ svgRE.test(id) ||
+ wasmInstanceRE.test(id)
+ ) {
It also adds two cases to Vite’s fs-serve test matrix, one for a file outside the allow list and one for a denied .env, so this query can’t quietly regress.
With the patch, a ?vite-wasm-instance request for a file outside the boundary gets the same 403 Restricted as ?raw. We confirmed that on both fixed releases.
What to do
Upgrade
Move to Vite 8.2.4 or later on the 8.2 line, or 8.3.3 or later on 8.3; the current release is 8.3.4. vite-plus users need 1.1.0. Frameworks install Vite for you, so check the version that actually resolved rather than the one your package.json names:
npm ls vite # or: pnpm why vite
Then shrink what the dev server can reach
Upgrading fixes this bug. A few settings decide how much any dev server bug can reach, and who is in a position to try:
- Keep the dev server on localhost. Use
--hostonly on a network you trust, and drop it once you’ve finished testing from a phone. In cloud workspaces such as GitHub Codespaces, keep forwarded ports private. - Leave the browser-facing defaults alone.
server.cors: truelets any website read your dev server’s responses, andserver.allowedHosts: trueturns off the host check that stops DNS rebinding. Either one lets a web page the developer happens to visit reach a server that only listens on localhost. - Run the dev server where it can’t see your home directory. This bug reached
/etc/passwdbecause the Node process could. A dev container that mounts only the project keeps a file-read bug in any dev tool confined to the project. - Keep
server.fs.stricton andserver.fs.allownarrow. That boundary is what the fix restores, and pointing it at your home directory or/gives it away. - Keep production credentials out of local
.envfiles. When a dev machine only holds development keys, a partial leak is a nuisance and not an incident.
If you write Vite plugins
If your plugin has a load hook that reads a file based on the requested id, check the path against server.fs yourself before reading it. Vite exports isFileLoadingAllowed() for this; it expects a normalised absolute path and doesn’t validate its input, so normalise first:
import { readFile } from 'node:fs/promises'
import { isFileLoadingAllowed, normalizePath } from 'vite'
export function byteCount() {
return {
name: 'byte-count',
load: {
filter: { id: /[?&]byte-count(?:&|$)/ },
async handler(id) {
const file = normalizePath(id.split('?')[0])
if (this.environment.mode === 'dev' &&
!isFileLoadingAllowed(this.environment.getTopLevelConfig(), file)) {
this.error('byte-count: file is outside server.fs')
}
const buf = await readFile(file)
return `export default ${buf.length}`
},
},
}
}
We ran this against Vite 8.3.3. A request for /notes?byte-count inside the project returns the module, while /.env?byte-count and /@fs/etc/passwd?byte-count stop at the plugin’s own error before anything is read. And keep file contents and raw parser messages out of errors that reach the browser; in this CVE, that message is what turned a read into a leak.
Disclosure timeline
- 4 September 2026: reported through GitHub private vulnerability reporting
- 25 September 2026: the Vite team acknowledged the report
- 6 October 2026: fix merged; Vite 8.3.3 and 8.2.4 released; GHSA-vfpm-58rq-9qcg published
- 20 October 2026: GitHub assigned CVE-2026-112947 and published the CVE record
Thanks to sapphi-red for the fix and the test cases, and to bluwy for the review.
References
- GHSA-vfpm-58rq-9qcg, the Vite advisory
- CVE-2026-112947
- Fix: PR #23655, commit
ba8b7ab - PR #22674, which introduced
?vite-wasm-instance - Earlier
server.fsbypasses: CVE-2025-31125, CVE-2025-31486, CVE-2026-39364 server.fsdocumentation
If your developers run dev servers in cloud workspaces or on shared networks and you want to know what those servers and your build pipeline expose, that’s part of our CI/CD security work.