Auditing Vite Dev Servers for .env Exposure via /@fs and the HMR Socket

Auditing Vite Dev Servers for .env Exposure via /@fs and the HMR Socket

pr0h0
vitesecurityjavascriptcloud-securitydevsecops
AI Usage (77%)

Why Vite Dev Server .env Exposure Matters

CyberSecurityNews reported on September 14, 2026 that publicly reachable Vite dev servers were being mass-scanned for AWS and Azure credentials. I'm treating that as the report's claim, not an established fact — I haven't seen the raw scan telemetry, a vendor advisory, or a cloud provider bulletin that confirms the campaign at that scale. What I can check cheaply is the exposure the whole thing depends on. So this post audits how /@fs and the HMR socket expose .env and cloud credentials, and what actually closes it.

My position: the default Vite config isn't the bug. Binding a dev server to 0.0.0.0 while long-lived cloud keys sit in .env is the bug, and "it's only dev" is a deployment assumption that keeps falling over. The subtler problem is that teams who do think about the dev server usually reach for the wrong control. server.fs.deny reads like a security boundary. It's a four-pattern blocklist, and the fs.allow bypass advisories published in 2025 mean you can't assume it gets patched correctly either.

I reproduced what I could on a throwaway project on my own machine: loopback-only, obviously fake credentials, no cloud account involved.

What a Vite Dev Server Actually Serves

A dev server is a build tool with an HTTP listener bolted on. It transforms source files on demand and keeps an open channel to the browser so edits land without a reload. Two consequences follow from that design:

  • the read surface is intentionally wide — resolving /src/App.jsx and its imports means walking the filesystem from the project root outward;
  • the auth surface is intentionally absent — it was built for one developer on one laptop.

/@fs/ and the HMR socket aren't vulnerabilities by themselves. The problem starts when that listener becomes reachable from a network you don't control.

The /@fs File-Read Endpoint

Vite exposes a filesystem path prefix, /@fs/, so the browser can request modules outside the served root — the classic case being a linked package in a monorepo. The gate is server.fs.allow, which defaults to the workspace root: Vite walks up looking for a lockfile or workspace marker, so in a pnpm monorepo the allowed root is the monorepo, not your app directory.

Anything inside that root that server.fs.deny doesn't match can be fetched. The default deny list is short:

['.env', '.env.*', '*.{crt,pem}', '**/.git/**']

Query strings change what comes back. ?raw returns the file as a JS string module; ?import forces module resolution and yields the default export; ?inline returns the asset without emitting a file. That's the mechanism behind the 2025 advisories — the deny check and the file read could be made to disagree about which path was being requested.

Here's what I want you to sit with: .env is denied by name. .npmrc, token.json, deploy.key, terraform.tfstate, and service-account.json are not. The deny list blocks one filename shape, and that isn't the shape most modern secrets actually ship in.

The HMR WebSocket

The HMR channel shares the dev server's port. The browser connects to the same origin over a WebSocket upgrade, so there's no second port to firewall and no separate listener to notice. WebSocket handshakes also skip CORS preflight, which means any origin check has to be explicit on the server side.

A caveat here: cross-origin and Host-header handling in the dev server has been the subject of Vite advisories, and server.allowedHosts exists in current server options precisely because Host validation was missing. I wouldn't assume my version's behaviour generalizes to yours. Read the advisory list for your own release line instead of trusting a blog post — this one included.

Why Your Vite Version and Patch Level Matter

Patch level isn't a side note; it's half the fix. The advisories covering /@fs path normalization bypasses — the ?raw??, ?inline&import, ?raw?import shapes and relatives — spell out affected and fixed ranges per major line. I'm deliberately not restating CVE IDs or version ranges from memory, because getting them wrong is worse than pointing you at the source. Check the Vite GitHub Security Advisories, match your installed version, and don't forget your plugins.

Reproducing the Exposure in a Lab

Everything below runs against a throwaway project on a machine I own.

Lab Setup

npm create vite@latest vite-leak-lab -- --template react
cd vite-leak-lab
npm install

cat > .env <<'EOF'
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
DATABASE_URL=postgres://lab:[email protected]:5432/lab
EOF

echo '//registry.npmjs.org/:_authToken=npm_FAKEFAKEFAKEFAKEFAKEFAKEFAKEFAKE' > .npmrc

npm run dev -- --host 0.0.0.0
  VITE v7.1.3  ready in 231 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: http://192.168.1.24:5173/
  ➜  press h + enter to show help & options

That Network: line is the tell. If you see it, the listener is not loopback-bound. Record your versions — npx vite --version gave me vite/7.1.3 linux-x64 node-v22.14.0 — because the patch number is what decides several of the results below.

Reading .env Through /@fs

curl -i -s 'http://127.0.0.1:5173/@fs/home/lab/vite-leak-lab/.env' | head -n 8
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8

The request url "/@fs/home/lab/vite-leak-lab/.env" is denied by server.fs.deny

Now the query variants the 2025 advisories turned on:

for q in '?raw' '?raw??' '?inline&import' '?raw?import'; do
  printf '%-16s -> ' "$q"
  curl -s -o /dev/null -w '%{http_code}\n' \
    "http://127.0.0.1:5173/@fs/home/lab/vite-leak-lab/.env$q"
done

Closing the Exposure

The fix is boring. Bind the dev server to 127.0.0.1 — the default — instead of 0.0.0.0, and tunnel in over VPN or SSH when you need remote access. Patch Vite on your release line so the /@fs bypasses are fixed, but don't treat server.fs.deny as a boundary. Rotate every key that ever sat in a .env behind a reachable dev server.

Share this post

More posts

Comments