SevyDevy

Guide · Frontend

Vitest's October 2026 Vulnerability Explained: How Test Servers Leaked Secrets

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The Vitest vulnerability CVE-2026-112948 let Vitest 5's built-in web servers hand out files that Vite normally blocks, such as .env files holding secret keys. Upgrade to Vitest 5.0.5.
  • Vitest is a test runner built on Vite, a development server for web projects. Only setups where Vitest opens a web server are affected: its results page (the UI), Browser Mode, or the api option, which opens a remote-control port.
  • The worst case was Browser Mode with test.projects, which splits tests into groups. On 5.0.0 to 5.0.4 it served .env, key files, .npmrc and .git to anyone who could reach it.
  • Severity is Moderate (6.5 out of 10). Someone must reach your test server's port, or a hostile page in your browser must request it while tests run. Plain vitest run and Vitest 4 are not affected.
  • Beginners can stop after the fix section. After it: why setting a Vite array drops its defaults, a localhost check that returned 200 on 5.0.4 and 403 on 5.0.5, and a trap in custom deny lists.

What goes wrong when a test server can read your .env file?

Your secret keys can leave your laptop without any code being deployed. The Vitest vulnerability made some Vitest 5 test servers send back files they should refuse, like .env, which usually holds API keys and passwords.

Picture a common setup. A frontend team runs component tests in Vitest's Browser Mode, split into a web group and a mobile group, and keeps a payment sandbox key in .env. The setup is made up, but the bug is real.

While the tests run, Vitest starts a small web server on the developer's machine so a browser can load the test pages. On Vitest 5.0.4, asking that server for /.env returned the file's contents. On 5.0.5 the same request is refused.

Nothing in the shipped app changes. The risk is limited to the machine running the tests, while the server is up. That is why the advisory rates it Moderate, not Critical, and why the fix is quick.

Which words do you need before reading the Vitest advisory?

You need seven terms. Each one gets a plain meaning and an example from the running setup.

  • Vite. A tool that runs a development web server for frontend code and serves your project files to the browser. Example: the team's app runs on Vite during development.
  • Vitest. A test runner built on Vite, so it reuses Vite's server and settings. Example: the team runs its component tests with Vitest 5.
  • Browser Mode and the UI. Browser Mode runs tests inside a real browser, so Vitest serves test pages over HTTP. The UI is a web page that shows test results.
  • test.projects. A Vitest setting that splits tests into groups, each with its own settings. Example: the web group and the mobile group are two projects.
  • server.fs.deny. A Vite setting that lists files the dev server must never send, such as .env. A request for a listed file gets HTTP 403, meaning forbidden.
  • The api option. A Vitest setting that opens a control port, so tools like the UI can watch and drive test runs. Example: the team does not use it directly.
  • API token. A random secret Vitest writes to a file named .vitest-secret-token. Whoever holds it can talk to Vitest's control channel, so it must stay private too.

What is the Vitest vulnerability CVE-2026-112948?

The Vitest vulnerability CVE-2026-112948 is a bug where Vitest 5's web servers did not apply Vite's list of blocked files as intended. Depending on the setup, .env files, private keys, .git data or Vitest's own API token could be read over HTTP.

It is tracked as GitHub advisory GHSA-p458-7748-mcp3. The advisory scores it 6.5 on CVSS 3.1, the common 0 to 10 severity scale, and labels it Moderate. Its vector says an attack needs user interaction, and it affects confidentiality only. The score covers the file read; the advisory notes a stolen token can go further.

Affected and fixed versions, from the GitHub advisory, the Vitest 5.0.5 release notes and npm publish times (as of 26 October 2026)
PackageAffected versionsFixed inFixed version on npm
vitest5.0.0-beta.6 to 5.0.4, when the UI, Browser Mode or api option is used5.0.515 October 2026
@vitest/ui5.0.0-beta.6 up to, not including, 5.0.55.0.515 October 2026
@vitest/browser5.0.0-beta.6 up to, not including, 5.0.55.0.515 October 2026
vite-plus1.0.0-rc.0 up to, not including, 1.3.01.3.016 October 2026
Vitest 4 and earlierNot affected, per the advisoryNothing to doNot applicable

The first affected pre-release, 5.0.0-beta.6, came out on 6 July 2026. The first stable release, 5.0.0, came out on 3 September 2026. So anyone on a stable Vitest 5 from 5.0.0 to 5.0.4 should upgrade.

Which Vitest setups are affected, and which are safe?

Only setups where Vitest listens on a network port are affected, and they leaked different things. Plain vitest and vitest run open no port at all, so they are safe.

We ran each setup below on Vitest 5.0.4 and 5.0.5 with Vite 8.3.5, on localhost, against a throwaway project with a fake value in .env. The token column needs one extra condition, explained after the table.

What each Vitest 5 setup served, tested on 26 October 2026 (200 means sent, 403 means refused; a 500 error reply can still carry the file)
SetupHow it startsGET /.env on 5.0.4Vitest token file on 5.0.4On 5.0.5
Plain watch or runvitest, vitest runNo server listeningNo server listeningNo server listening
UI or api optionvitest --ui403, refusedLeaked inside an HTTP 500 error reply, fallback folder onlyBoth refused
Browser Mode, one projectbrowser.enabled with no test.projects403, refused200, sent, fallback folder onlyBoth refused
Browser Mode with test.projectsbrowser.enabled inside a project200, full file sentRefused403 for .env and token

Vitest stores its token in your user data folder, such as ~/.local/share/vitest on Linux. Only when that folder cannot be written does it fall back to node_modules/.vitest inside the project. The leak needed that fallback.

The UI's 500 reply is a reminder to check the response body, not only the status code. Vite tried to parse the token file as JavaScript, failed, and put the file's text in its error message.

Which Vitest 5 setups exposed what (5.0.0 to 5.0.4)

Notice that only one branch serves .env. The UI branch keeps Vite's defaults and leaks just the token, and only from the fallback folder.

Diagram connections:

  • You start Vitest 5.0.4 to Does it open a port?
  • Does it open a port? to Nothing to reach (No)
  • Does it open a port? to Browser Mode in projects? (Yes)
  • Browser Mode in projects? to Root server (No: UI, api or one project)
  • Browser Mode in projects? to Project server (Yes)
  • Root server to Token file readable
  • Project server to .env, keys, .git readable
  • You start Vitest 5.0.4: In a project with a .env file
  • Does it open a port?: UI, Browser Mode or the api option
  • Nothing to reach: vitest and vitest run listen on no port
  • Browser Mode in projects?: test.projects with browser.enabled
  • Root server: Also the browser server for one project
  • Token file readable: Defaults kept; token only from the fallback
  • Project server: Deny list replaced by the token entry
  • .env, keys, .git readable: GET /.env answered with HTTP 200

How did Vitest 5 lose Vite's default deny list?

Two small mistakes in how Vitest edited Vite's settings caused it. One replaced Vite's list instead of adding to it. The other added to the list after Vite had already stopped reading it.

Think of a security guard at a building's exit with a printed list of items that must not leave. Vite prints that list at startup and hands it over.

Setting an array replaces Vite's defaults

When you give Vite your own server.fs.deny list, Vite uses yours and throws its default list away. It does not merge the two. In Vite 8.3.5's source, a setting you provide simply overwrites the default value.

Vitest 5.0.4's project servers, used for Browser Mode inside test.projects, set the list to just one entry, the token file. That one-line list replaced all six of Vite's defaults, so .env was no longer blocked. In the running setup, both the web and the mobile project servers would hand out the payment sandbox key.

In the guard picture, Vitest handed the guard a fresh sheet with one line on it. The guard did exactly as told and let everything else out.

Adding to the list after Vite compiled it does nothing

Vite turns the deny list into a matcher, a small function that checks paths, while it resolves its settings. After that, the list is never read again.

Vitest 5.0.4's root server, used by the UI, the api option and Browser Mode without test.projects, pushed the token entry onto the list after that step. The list in memory changed, but the matcher did not. So the token file stayed unprotected.

In the guard picture, someone wrote a new line on the sheet after the guard had memorised it. Where the picture breaks: a real guard might glance at the sheet again, but Vite's matcher never does.

Two ways the deny list went wrong in Vitest 5.0.4

Both paths pass through Vite's merge and compile steps. Notice where each goes wrong: the project path brings a one-line list into the merge, and the root path adds the token after the compile.

Diagram connections:

  • Your Vitest config to Project config hook (Browser Mode in projects)
  • Project config hook to Vite applies defaults (Deny set to token only)
  • Your Vitest config to Vite applies defaults (UI, api or one project)
  • Vite applies defaults to Vite compiles matcher
  • Vite compiles matcher to Project server (Project path: token only)
  • Vite compiles matcher to Root server adds token (Root path: six defaults)
  • Root server adds token to Root server (Too late to count)
  • Your Vitest config: No server.fs.deny set by you
  • Project config hook: Sets deny to the token file only
  • Vite applies defaults: Only if no list was set; a set list wins
  • Vite compiles matcher: Built once while settings resolve
  • Root server adds token: Pushed after the matcher exists
  • Project server: Matcher has only the token: .env sent
  • Root server: Matcher has six defaults: token leaks

Who could actually read the files?

Anyone who could send HTTP requests to the test server's port, and in one setup, a website open in your browser. By default the server listens only on localhost, your own machine, which keeps most people out.

The advisory describes these ways in. Each needs something extra, which is why the score is 6.5 and not higher.

  • Local access or network reach. Anything that reaches the port can read whole files over plain HTTP. That includes other users or containers on the machine, port forwarders, and a host setting such as api.host or browser.api.host that listens on your network.
  • A malicious website. In Browser Mode with test.projects, a page you visit can load .env as if it were a script. Lines that happen to be valid JavaScript can then leak.
  • Another local dev server. The advisory says Vite's cross-site rules, called CORS, allow pages from localhost, so a page served by another dev server on your machine could read whole files.
  • The token. Whoever reads the token can use Vitest's control channel. When writing and running are allowed, that channel can change and rerun test files.

The website read is partial. Per the advisory, an unquoted value stops it, and a syntax error such as a # comment stops the whole file. Recent Chrome versions may first ask for Local Network Access permission.

The advisory adds that Vite's cross-site checks block most other reads from public websites. The missing defaults also loosened Browser Mode's own file-access checks for reading, writing and removing files.

Only the machine running Vitest is exposed, per the advisory. Apps built or deployed from the project are not affected.

How do you fix the Vitest vulnerability?

Upgrade vitest and every @vitest package you use that has a 5.0.5 release to 5.0.5 or later, all to the same version, then restart any running test servers. If you use vite-plus, upgrade it to 1.3.0 or later.

The versions must match because vitest 5.0.5 pins @vitest/ui, the coverage packages and the preview and playwright providers at exactly 5.0.5 as peer dependencies, and they pin it back. Leaving one behind makes npm stop with an ERESOLVE error.

  1. Find your versions

    Run npm ls vitest @vitest/ui @vitest/browser vite-plus in the project folder. Any vitest, @vitest/ui or @vitest/browser from 5.0.0-beta.6 to 5.0.4 is affected.

  2. Upgrade together

    Run npm install -D with every vitest package you have at 5.0.5, for example [email protected] @vitest/[email protected] @vitest/[email protected]. Include providers such as @vitest/browser-playwright. Leave @vitest/browser-webdriverio as it is: it has no 5.0.5 release and accepts any Vitest 5.

  3. Upgrade vite-plus if you use it

    Install vite-plus 1.3.0 or later. Version 1.3.0 depends on vitest 5.0.5 and @vitest/browser 5.0.5.

  4. Restart and confirm

    Stop every running Vitest process, including ones in other terminals, then run npm ls again and check that each package shows 5.0.5. Old servers keep the old code until they stop.

  5. Decide whether to rotate secrets

    Rotate the keys in .env if other machines, users, containers or port forwarders could reach an affected server. Do the same if untrusted sites or other dev servers ran in your browser meanwhile.

Rotating means creating new keys at each provider and deleting the old ones. If only you could reach the port, and no untrusted site or other dev server was open meanwhile, the advisory's exposure paths did not apply.

One more step is our own advice, not the advisory's. Vitest reuses its stored token across restarts and upgrades, and makes a new one only when the file is missing. If your token sat in the workspace root's node_modules/.vitest while an affected server was reachable, delete .vitest-secret-token there, then restart Vitest so it makes a new one.

If you cannot upgrade yet

Set server.fs.deny yourself in the root Vitest config. List all six Vite 8.3 defaults and add .vitest-secret-token, as the advisory suggests. This is the file we ran on 5.0.4 with inline test.projects; copy only its server block into your existing root config.

javascript
import { defineConfig } from 'vitest/config'
import { preview } from '@vitest/browser-preview'

export default defineConfig({
  server: {
    fs: {
      // Vite 8.3 defaults, plus Vitest's API token file
      deny: [
        '.env',
        '.env.*',
        '*.{crt,pem,key,p12,pfx,cer,der}',
        '.npmrc',
        '.yarnrc.yml',
        '**/.git/**',
        '.vitest-secret-token',
      ],
    },
  },
  test: {
    projects: [
      {
        test: {
          name: 'web',
          include: ['src/**/*.test.js'],
          browser: {
            enabled: true,
            provider: preview(),
            instances: [{ browser: 'chromium' }],
          },
        },
      },
    ],
  },
})

With it, 5.0.4 refused /.env and the token file with HTTP 403. Projects kept in separate config files were not tested. On Vite 6.4 or 7.3 the built-in defaults are shorter, but using this longer list is stricter, not weaker.

How can you check on your own machine that the fix works?

Start Browser Mode with the check config below and ask your own server for /.env with curl. A fixed server answers 403. An affected one answers 200.

Only a test.projects setup tells versions apart. On 5.0.4, the UI and single-project Browser Mode already refuse /.env, so confirm your version with npm ls as well.

Do this only against localhost on your own machine, in a project whose .env holds a fake value. The config below is the one we ran. It adds a tiny plugin that prints the deny list each project server ends up with.

javascript
import { defineConfig } from 'vitest/config'
import { preview } from '@vitest/browser-preview'

const showDenyList = {
  name: 'show-deny-list',
  configResolved(config) {
    console.log('[deny]', JSON.stringify(config.server.fs.deny))
  },
}

export default defineConfig({
  test: {
    projects: [
      {
        plugins: [showDenyList],
        test: {
          name: 'web',
          include: ['src/**/*.test.js'],
          browser: {
            enabled: true,
            provider: preview(),
            instances: [{ browser: 'chromium' }],
          },
        },
      },
    ],
  },
})

The preview provider ships as @vitest/browser-preview. Swap in the provider you already use; the check works the same way.

Browser Mode's server listens on port 63315 by default. If an old Vitest server still holds that port, the new one moves to the next port, so read the port from the log.

  1. Make a fake secret

    Create a .env file containing DEMO_API_KEY="fake-demo-value-not-a-secret". Never use a real key for this check.

  2. Start Browser Mode

    Save the config as vitest.check.config.js and set include to match one of your test files. Run npx vitest --watch --config vitest.check.config.js and leave it running.

  3. Find the port

    The log shows a [deny] line and a line starting Browser runner started at, with the port. If that line never appears, no test file matched include.

  4. Ask for the file

    In a second terminal, run the curl command below, using the port from that log line. It prints only the HTTP status code, a three-digit number.

  5. Read the result

    403 means the server refused the file. 200 means it sent the file, so you are on an affected version or config. 000 means nothing is listening: check include and the port.

bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:63315/.env

Here is what we saw on 26 October 2026 with Vite 8.3.5. The [deny] lines are the matcher's input. HTTP codes are the curl output.

Results of the check, run on localhost with a fake .env value
VersionPrinted deny listcurl output
vitest 5.0.4[".vitest-secret-token"]200
vitest 5.0.5Six Vite defaults plus ".vitest-secret-token"403
vitest 5.0.4 with the workaround configSix Vite defaults plus the token (from a run with the print plugin added)403

On Windows, run curl.exe with the same arguments in PowerShell, because curl there can point to a different command. The status codes mean the same thing.

What does the Vitest 5.0.5 fix change in the code?

Vitest 5.0.5 now builds the full deny list itself, before Vite compiles it. It keeps your list if you set one, otherwise uses a copy of Vite's defaults, and always adds the token file.

The change is commit 28dc1ed from pull request 11578, merged on 14 October 2026. The 5.0.5 release notes list it as a bug fix: keep vite default fs.deny with api token file. It also adds a test that checks the resolved deny list for both server types.

The defaults are a copied list

Vite does not export its default list, so Vitest 5.0.5 hard-codes a copy taken from Vite 8.3. A code comment says to switch once Vite exports its own.

This has a side effect. Vite 6.4 and 7.3 default to a shorter list: .env, .env.*, *.{crt,pem} and .git. With Vite 7.3.2, we saw Vitest 5.0.5 apply the longer Vite 8.3 list, including .npmrc and *.key.

A trap in setting your own deny list

If you set server.fs.deny yourself, Vitest 5.0.5 keeps exactly your list and only adds the token. Vite's defaults are not added back, because Vite replaces them whenever you set the array.

We tested this on 5.0.5 with a root config of deny: ['secret.txt']. The project server's list became secret.txt plus the token, and GET /.env returned 200. The workaround list in the fix section avoids this by repeating every default.

How is this different from the Vite dev server attacks in 2026?

They are separate bugs in separate tools with the same lesson: a dev server is a real network service. The Vitest bug was a missing deny list. The earlier Vite bug was a way around a working deny list.

That Vite bug, CVE-2026-39364, let requests with extra query text such as ?raw get past server.fs.deny when the server was exposed to the network. It was published on 6 April 2026 and fixed in Vite 7.3.2 and 8.0.5.

A Cloud Security Alliance research note from 16 September 2026 cites F5 Labs data showing roughly a 20-fold rise in attacks on exposed Vite dev servers in August 2026. That is attackers scanning for Vite, not proof that this Vitest bug was used.

Two 2026 advisories that both involve server.fs.deny (different CVSS versions, so compare the ratings, not the numbers)
Vitest CVE-2026-112948Vite CVE-2026-39364
What brokeVitest's servers lost or misapplied the deny listQuery strings bypassed a working deny list
SeverityModerate, CVSS 3.1 score 6.5High, CVSS 4.0 score 8.2
Fixed invitest 5.0.5, vite-plus 1.3.0vite 7.3.2 and 8.0.5
What an attacker needsPort access, local or network, or a browser page in one setupNetwork access to an exposed server

Keep both patched, and keep dev and test servers on localhost unless you have a reason. If you must share one, put it behind a firewall or a tunnel that needs login, not a public port.

How do you explain the Vitest vulnerability in a system design interview?

Use it as a story about secure defaults and trust boundaries, not about one tool. Interviewers listen for how you find the exposure, contain it and stop it happening again.

  1. Name the asset

    Secrets in .env files and a control token on a developer's machine. The production app was never exposed.

  2. Name the boundary

    A local test server became reachable through a host setting, a forwarded port, a container port or a browser tab.

  3. Explain the mechanism

    Setting an array replaced the safe defaults, and a late edit missed a list that had already been compiled.

  4. Contain

    Upgrade everything to 5.0.5 together, restart servers, and rotate any secret another user, container, forwarded port or browser page could have reached.

  5. Prevent

    Bind dev servers to localhost, keep real secrets out of test projects, and add a test that asserts the server refuses /.env.

Questions people ask

Is vitest run in CI affected by CVE-2026-112948?
No, not on its own. The advisory says plain vitest and vitest run do not listen on any port, and our test found nothing listening on the UI port. A CI job is affected only if it starts the UI, Browser Mode or the api option, and something else can reach that port.
Was the Vitest .env leak fixed in 5.0.2?
No. The advisory names 5.0.5 for @vitest/ui and @vitest/browser, and the fix ships in vitest 5.0.5, released on npm on 15 October 2026. Some early posts said 5.0.2, but our localhost test on 5.0.4 still returned the .env file with HTTP 200 in Browser Mode with test.projects. Upgrade to 5.0.5 or later.
Do I need to rotate my API keys?
Only if an affected server could have been reached by someone else. That covers other machines, other users or containers on your machine, and forwarded or published ports. It also covers untrusted sites or other local dev servers open during Browser Mode runs with test.projects. If so, create new keys and delete the old ones. Also delete node_modules/.vitest/.vitest-secret-token if it exists, then restart Vitest, because it reuses a stored token.
Does this vulnerability affect my production website?
No. The advisory says only the machine running Vitest is exposed, and only while one of the affected servers runs. Apps built or deployed from the project are not affected, because Vitest's test servers are not part of a production build.
Is Vitest 4 affected?
No. The advisory says Vitest 4 and earlier are not affected. The bug arrived with Vitest 5 pre-releases, starting at 5.0.0-beta.6 on 6 July 2026. If you are still on Vitest 4, you have nothing to patch for this advisory.
I use vite-plus. What should I do?
Upgrade vite-plus to 1.3.0 or later. The advisory lists vite-plus from 1.0.0-rc.0 up to, but not including, 1.3.0 as affected. Version 1.3.0, published on 16 October 2026, depends on vitest 5.0.5 and @vitest/browser 5.0.5, which carry the fix.

Sources and further reading

Practise this with Lala

Practise