High severity7.5NVD Advisory· Published Apr 7, 2026· Updated Apr 30, 2026
CVE-2026-39364
CVE-2026-39364
Description
Vite is a frontend tooling framework for JavaScript. From 7.1.0 to before 7.3.2 and 8.0.5, on the Vite dev server, files that should be blocked by server.fs.deny (e.g., .env, *.crt) can be retrieved with HTTP 200 responses when query parameters such as ?raw, ?import&raw, or ?import&url&inline are appended. This vulnerability is fixed in 7.3.2 and 8.0.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vitenpm | >= 8.0.0, < 8.0.5 | 8.0.5 |
vitenpm | >= 7.1.0, < 7.3.2 | 7.3.2 |
Affected products
2Patches
1a9a3df299378fix: check `server.fs` after stripping query as well (#22160)
10 files changed · +679 −713
packages/vite/src/node/server/middlewares/transform.ts+11 −2 modified@@ -62,7 +62,10 @@ export function isServerAccessDeniedForTransform( id: string, ): boolean { if (rawRE.test(id) || urlRE.test(id) || inlineRE.test(id) || svgRE.test(id)) { - return checkLoadingAccess(config, id) !== 'allowed' + return ( + checkLoadingAccess(config, cleanUrl(id)) !== 'allowed' || + checkLoadingAccess(config, id) !== 'allowed' + ) } return false } @@ -319,7 +322,13 @@ export function transformMiddleware( } if (e?.code === ERR_DENIED_ID) { const id: string = e.id - const servingAccessResult = checkLoadingAccess(server.config, id) + let servingAccessResult = checkLoadingAccess( + server.config, + cleanUrl(id), + ) + if (servingAccessResult === 'allowed') { + servingAccessResult = checkLoadingAccess(server.config, id) + } if (servingAccessResult === 'denied') { respondWithAccessDenied(id, server, res) return true
playground/fs-serve/package.json+2 −0 modified@@ -16,6 +16,8 @@ "preview:deny": "vite preview root --config ./root/vite.config-deny.js" }, "devDependencies": { + "@types/escape-html": "^1.0.4", + "escape-html": "^1.0.3", "ws": "^8.20.0" } }
playground/fs-serve/root/matrixTestResultPlugin.ts+144 −0 added@@ -0,0 +1,144 @@ +import type { Plugin } from 'vite' +import escapeHtml from 'escape-html' + +const testIds = [ + 'safe', + 'safe-query', + 'safe-subdir', + 'safe-subdir-special-characters', + 'safe-subdir-special-characters2', + 'safe-imported', + 'safe-imported-query', + 'unsafe', + 'unsafe-json', + 'unsafe-html', + 'unsafe-html-outside-root', + 'unsafe-8498', + 'unsafe-8498-2', + 'unsafe-import-inline', + 'unsafe-raw-query-import', + 'unsafe-raw-import-raw-outside-root', + 'unsafe-raw-import-raw-outside-root1', + 'unsafe-raw-import-raw-outside-root2', + 'unsafe-url', + 'unsafe-query-dot-svg-import', + 'unsafe-svg', + 'unsafe-import-inline-wasm-init', + 'unsafe-relative-path-after-query', + 'unsafe-dotenv', + 'unsafe-dotenv-casing', + 'unsafe-dotenv-raw', + 'unsafe-dotenv-url', + 'unsafe-dotenv-inline', + 'unsafe-dotenv-query-dot-svg-wasm-init', + 'unsafe-dotenv-import-raw', +] + +export default function matrixTestResultPlugin(): Plugin { + return { + name: 'matrix-test-result', + transformIndexHtml(html) { + const summary = ` +<h1>FS Serve Matrix Test Summary</h1> +<div> + <table id="matrix-summary"> + <thead> + <tr> + <th>Name</th> + <th>Normal</th> + <th>/@fs/</th> + </tr> + </thead> + <tbody> + ${testIds + .map( + (id) => + ` + <tr> + <td>${escapeHtml(id)}</td> + ${['', '-fs'] + .map((variant) => + ` + <td> + <code class="fetch${variant}-${escapeHtml(id)}-path"></code> + <div> + <p class="fetch${variant}-${escapeHtml(id)}-status"></p> + <a href="#fetch${variant}-${escapeHtml(id)}-content">#</a> + </div> + </td> + `.trim(), + ) + .join('\n')} + </tr> + `, + ) + .join('\n')} + </tbody> + </table> +</div> + `.trim() + + const contents = ` +<h1>FS Serve Test Contents</h1> +<div id="matrix-contents"> + ${['', '-fs'] + .map((variant) => + testIds + .map((id) => + ` + <div> + <h2 id="fetch${variant}-${escapeHtml(id)}-content">${escapeHtml(id)}</h2> + <pre class="fetch${variant}-${escapeHtml(id)}-content"></pre> + </div> + `.trim(), + ) + .join('\n'), + ) + .join('\n')} +</div> + `.trim() + + const style = ` +<style> +#matrix-summary { + tr td > div { + display: flex; + align-items: baseline; + gap: 0.3em; + } + tr:nth-child(even) { + background-color: #eeeeee; + } + th { + background-color: #6b1eb9; + color: white; + } + th, td { + padding: 0.2em; + } + td { + max-width: 20vw; + } + p { + margin: 0; + } + code { + word-break: break-all; + } +} +#matrix-contents { + h2 { + font-size: 1.2rem; + font-weight: bold; + } +} +</style> + `.trim() + + return html.replace( + '<!-- REPLACE WITH MATRIX TEST RESULT -->', + `\n${summary}\n${contents}\n${style}\n`, + ) + }, + } +}
playground/fs-serve/root/src/index.html+166 −431 modified@@ -1,72 +1,15 @@ <link rel="icon" href="/src/favicon.ico" /> -<h2>Normal Import</h2> -<pre class="full"></pre> -<pre class="named"></pre> +<h1>FS Serve Normal Tests</h1> +<div> + <h2>Normal Import</h2> + <pre class="full"></pre> + <pre class="named"></pre> + <pre class="nested-entry"></pre> + <pre class="virtual-svg"></pre> +</div> -<h2>Safe Fetch</h2> -<pre class="safe-fetch-status"></pre> -<pre class="safe-fetch"></pre> -<pre class="safe-fetch-query-status"></pre> -<pre class="safe-fetch-query"></pre> - -<h2>Safe Fetch Subdirectory</h2> -<pre class="safe-fetch-subdir-status"></pre> -<pre class="safe-fetch-subdir"></pre> -<pre class="safe-fetch-subdir-special-characters-status"></pre> -<pre class="safe-fetch-subdir-special-characters"></pre> - -<h2>Unsafe Fetch</h2> -<pre class="unsafe-fetch-status"></pre> -<pre class="unsafe-fetch"></pre> -<pre class="unsafe-fetch-html-status"></pre> -<pre class="unsafe-fetch-html"></pre> -<pre class="unsafe-fetch-8498-status"></pre> -<pre class="unsafe-fetch-8498"></pre> -<pre class="unsafe-fetch-8498-2-status"></pre> -<pre class="unsafe-fetch-8498-2"></pre> -<pre class="unsafe-fetch-import-inline-status"></pre> -<pre class="unsafe-fetch-raw-query-import-status"></pre> -<pre class="unsafe-fetch-query-dot-svg-import-status"></pre> -<pre class="unsafe-fetch-svg-status"></pre> - -<h2>Safe /@fs/ Fetch</h2> -<pre class="safe-fs-fetch-status"></pre> -<pre class="safe-fs-fetch"></pre> -<pre class="safe-fs-fetch-query-status"></pre> -<pre class="safe-fs-fetch-query"></pre> -<pre class="safe-fs-fetch-special-characters-status"></pre> -<pre class="safe-fs-fetch-special-characters"></pre> - -<h2>Unsafe /@fs/ Fetch</h2> -<pre class="unsafe-fs-fetch-status"></pre> -<pre class="unsafe-fs-fetch"></pre> -<pre class="unsafe-fs-fetch-html-status"></pre> -<pre class="unsafe-fs-fetch-html"></pre> -<pre class="unsafe-fs-fetch-raw-status"></pre> -<pre class="unsafe-fs-fetch-raw"></pre> -<pre class="unsafe-fs-fetch-raw-query1-status"></pre> -<pre class="unsafe-fs-fetch-raw-query1"></pre> -<pre class="unsafe-fs-fetch-raw-query2-status"></pre> -<pre class="unsafe-fs-fetch-raw-query2"></pre> -<pre class="unsafe-fs-fetch-8498-status"></pre> -<pre class="unsafe-fs-fetch-8498"></pre> -<pre class="unsafe-fs-fetch-8498-2-status"></pre> -<pre class="unsafe-fs-fetch-8498-2"></pre> -<pre class="unsafe-fs-fetch-import-inline-status"></pre> -<pre class="unsafe-fs-fetch-import-inline-wasm-init-status"></pre> -<pre class="unsafe-fs-fetch-relative-path-after-query-status"></pre> - -<h2>Nested Entry</h2> -<pre class="nested-entry"></pre> - -<h2>Denied</h2> -<pre class="unsafe-dotenv"></pre> -<pre class="unsafe-dotEnV-casing"></pre> -<pre class="unsafe-dotenv-query-dot-svg-wasm-init"></pre> - -<d2>Virtual SVG module</d2> -<pre class="virtual-svg"></pre> +<!-- REPLACE WITH MATRIX TEST RESULT --> <script type="module"> import '../../entry' @@ -76,6 +19,21 @@ <h2>Denied</h2> // Check virtual svg module still works import fooSvg from 'virtual:foo.svg' + text('.full', JSON.stringify(json)) + text('.named', msg) + text('.virtual-svg', fooSvg) + + function text(sel, text) { + document.querySelector(sel).textContent = text + } +</script> + +<script type="module"> + // matrix tests + + const base = typeof BASE !== 'undefined' ? BASE : '' + const fsBase = joinUrlSegments('/@fs/', ROOT) + function joinUrlSegments(a, b) { if (!a || !b) { return a || b || '' @@ -89,372 +47,149 @@ <h2>Denied</h2> return a + b } - text('.full', JSON.stringify(json)) - text('.named', msg) - text('.virtual-svg', fooSvg) - - const base = typeof BASE !== 'undefined' ? BASE : '' - - // inside allowed dir, safe fetch - fetch(joinUrlSegments(base, '/src/safe.txt')) - .then((r) => { - text('.safe-fetch-status', r.status) - return r.text() - }) - .then((data) => { - text('.safe-fetch', JSON.stringify(data)) - }) - - // inside allowed dir with query, safe fetch - fetch(joinUrlSegments(base, '/src/safe.txt?query')) - .then((r) => { - text('.safe-fetch-query-status', r.status) - return r.text() - }) - .then((data) => { - text('.safe-fetch-query', JSON.stringify(data)) - }) - - // inside allowed dir, safe fetch - fetch(joinUrlSegments(base, '/src/subdir/safe.txt')) - .then((r) => { - text('.safe-fetch-subdir-status', r.status) - return r.text() - }) - .then((data) => { - text('.safe-fetch-subdir', JSON.stringify(data)) - }) - - // inside allowed dir, with special characters, safe fetch - fetch( - joinUrlSegments( - base, - '/src/special%20characters%20%C3%A5%C3%A4%C3%B6/safe.txt', - ), - ) - .then((r) => { - text('.safe-fetch-subdir-special-characters-status', r.status) - return r.text() - }) - .then((data) => { - text('.safe-fetch-subdir-special-characters', JSON.stringify(data)) - }) - - // outside of allowed dir, treated as unsafe - fetch(joinUrlSegments(base, '/unsafe.txt')) - .then((r) => { - text('.unsafe-fetch-status', r.status) - return r.text() - }) - .then((data) => { - text('.unsafe-fetch', data) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir, treated as unsafe - fetch(joinUrlSegments(base, '/unsafe.html')) - .then((r) => { - text('.unsafe-fetch-html-status', r.status) - return r.text() - }) - .then((data) => { - text('.unsafe-fetch-html', data) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir with special characters #8498 - fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt')) - .then((r) => { - text('.unsafe-fetch-8498-status', r.status) - return r.text() - }) - .then((data) => { - text('.unsafe-fetch-8498', data) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir with special characters 2 #8498 - fetch(joinUrlSegments(base, '/src/%252e%252e%252funsafe%252etxt')) - .then((r) => { - text('.unsafe-fetch-8498-2-status', r.status) - return r.text() - }) - .then((data) => { - text('.unsafe-fetch-8498-2', data) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir with import inline - fetch(joinUrlSegments(base, '/unsafe.txt?import&inline')) - .then((r) => { - text('.unsafe-fetch-import-inline-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir with raw query import - fetch(joinUrlSegments(base, '/unsafe.txt?raw?import')) - .then((r) => { - text('.unsafe-fetch-raw-query-import-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // outside of allowed dir with .svg query import - fetch(joinUrlSegments(base, '/unsafe.txt?.svg?import')) - .then((r) => { - text('.unsafe-fetch-query-dot-svg-import-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // svg outside of allowed dir, treated as unsafe - fetch(joinUrlSegments(base, '/unsafe.svg?import')) - .then((r) => { - text('.unsafe-fetch-svg-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // imported before, should be treated as safe - fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/safe.json')) - .then((r) => { - text('.safe-fs-fetch-status', r.status) - return r.json() - }) - .then((data) => { - text('.safe-fs-fetch', JSON.stringify(data)) - }) - - // imported before with query, should be treated as safe - fetch( - joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/safe.json?query'), - ) - .then((r) => { - text('.safe-fs-fetch-query-status', r.status) - return r.json() - }) - .then((data) => { - text('.safe-fs-fetch-query', JSON.stringify(data)) - }) - - // not imported before, outside of root, treated as unsafe - fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.json')) - .then((r) => { - text('.unsafe-fs-fetch-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch', JSON.stringify(data)) - }) - .catch((e) => { - console.error(e) - }) - - // not imported before, outside of root, treated as unsafe - fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html')) - .then((r) => { - text('.unsafe-fs-fetch-html-status', r.status) - return r.text() - }) - .then((data) => { - text('.unsafe-fs-fetch-html', data) - }) - .catch((e) => { - console.error(e) - }) - - // not imported before, outside of root, treated as unsafe - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + '/unsafe.json?import&raw', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-raw-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch-raw', JSON.stringify(data)) - }) - .catch((e) => { - console.error(e) - }) - - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + '/unsafe.json?import&raw??', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-raw-query1-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch-raw-query1', JSON.stringify(data)) - }) - .catch((e) => { - console.error(e) - }) - - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + '/unsafe.json?import&raw?&', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-raw-query2-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch-raw-query2', JSON.stringify(data)) - }) - .catch((e) => { - console.error(e) - }) - - // outside of root inline - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + '/root/unsafe.txt?import&inline', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-import-inline-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // outside of root inline, faux wasm?init - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + - '/root/unsafe.txt?import&?inline=1.wasm?init', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-import-inline-wasm-init-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // outside of root with relative path after query - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + - '/root/src/?/../../unsafe.txt?import&raw', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-relative-path-after-query-status', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // outside root with special characters #8498 - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + - '/root/src/%2e%2e%2f%2e%2e%2funsafe%2ejson', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-8498-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch-8498', JSON.stringify(data)) - }) - - // outside root with special characters 2 #8498 - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + - '/root/src/%252e%252e%252f%252e%252e%252funsafe%252ejson', - ), - ) - .then((r) => { - text('.unsafe-fs-fetch-8498-2-status', r.status) - return r.json() - }) - .then((data) => { - text('.unsafe-fs-fetch-8498-2', JSON.stringify(data)) - }) - - // not imported before, inside root with special characters, treated as safe - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + - '/root/src/special%20characters%20%C3%A5%C3%A4%C3%B6/safe.json', - ), - ) - .then((r) => { - text('.safe-fs-fetch-special-characters-status', r.status) - return r.json() - }) - .then((data) => { - text('.safe-fs-fetch-special-characters', JSON.stringify(data)) - }) - - // .env, denied by default - fetch( - joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/root/src/.env'), - ) - .then((r) => { - text('.unsafe-dotenv', r.status) - }) - .catch((e) => { - console.error(e) - }) - - // .env, for case insensitive file systems - fetch( - joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/root/src/.EnV'), - ) - .then((r) => { - text('.unsafe-dotEnV-casing', r.status) - }) - .catch((e) => { - console.error(e) - }) + const paths = [ + // inside allowed dir + { testId: 'safe', path: '/root/src/safe.txt' }, + // inside allowed dir with query + { testId: 'safe-query', path: '/root/src/safe.txt?query' }, + // inside allowed dir + { testId: 'safe-subdir', path: '/root/src/subdir/safe.txt' }, + // inside allowed dir with special characters + { + testId: 'safe-subdir-special-characters', + path: '/root/src/special%20characters%20%C3%A5%C3%A4%C3%B6/safe.txt', + }, + // not imported before but inside allowed dir with special characters + { + testId: 'safe-subdir-special-characters2', + path: '/root/src/special%20characters%20%C3%A5%C3%A4%C3%B6/safe.json', + }, + // imported before + { testId: 'safe-imported', path: '/safe.json' }, + // imported before with query + { testId: 'safe-imported-query', path: '/safe.json?query' }, + + // outside of allowed dir + { testId: 'unsafe', path: '/root/unsafe.txt' }, + // outside of allowed dir and root + { testId: 'unsafe-json', path: '/unsafe.json' }, + // outside of allowed dir + { testId: 'unsafe-html', path: '/root/unsafe.html' }, + // outside of allowed dir and root + { testId: 'unsafe-html-outside-root', path: '/unsafe.html' }, + // outside of allowed dir with special characters #8498 + { testId: 'unsafe-8498', path: '/root/src/%2e%2e%2funsafe%2etxt' }, + // outside of allowed dir with special characters 2 #8498 + { + testId: 'unsafe-8498-2', + path: '/root/src/%252e%252e%252funsafe%252etxt', + }, + // outside of allowed dir with import inline + { + testId: 'unsafe-import-inline', + path: '/root/unsafe.txt?import&inline', + }, + // outside of allowed dir with raw query import + { + testId: 'unsafe-raw-query-import', + path: '/root/unsafe.txt?raw?import', + }, + { + testId: 'unsafe-raw-import-raw-outside-root', + path: '/unsafe.json?import&raw', + }, + { + testId: 'unsafe-raw-import-raw-outside-root1', + path: '/unsafe.json?import&raw??', + }, + { + testId: 'unsafe-raw-import-raw-outside-root2', + path: '/unsafe.json?import&raw?&', + }, + // outside of allowed dir with ?url + { testId: 'unsafe-url', path: '/root/unsafe.txt?import&url' }, + // outside of allowed dir with .svg query import + { + testId: 'unsafe-query-dot-svg-import', + path: '/root/unsafe.txt?.svg?import', + }, + // outside of allowed dir, svg + { + testId: 'unsafe-svg', + path: '/root/unsafe.svg?import', + }, + // outside of allowed dir, inline faux wasm?init + { + testId: 'unsafe-import-inline-wasm-init', + path: '/root/unsafe.txt?import&?inline=1.wasm?init', + }, + // outside of allowed dir, relative path after query + { + testId: 'unsafe-relative-path-after-query', + path: '/root/src/?/../../unsafe.txt?import&raw', + }, + // .env, denied by default + { + testId: 'unsafe-dotenv', + path: '/root/src/.env', + }, + // .env, for case insensitive file systems + { + testId: 'unsafe-dotenv-casing', + path: '/root/src/.EnV', + }, + // .env, with raw query + { + testId: 'unsafe-dotenv-raw', + path: '/root/src/.env?raw', + }, + // .env, with url query + { testId: 'unsafe-dotenv-url', path: '/root/src/.env?url' }, + // .env with inline query + { testId: 'unsafe-dotenv-inline', path: '/root/src/.env?inline' }, + // .env with .svg?.wasm?init + { + testId: 'unsafe-dotenv-query-dot-svg-wasm-init', + path: '/root/src/.env?.svg?.wasm?init', + }, + // .env with ?import&raw + { testId: 'unsafe-dotenv-import-raw', path: '/root/src/.env?import&raw' }, + ] + const variants = { + '': (path) => + path.startsWith('/root/') + ? joinUrlSegments(base, path.replace(/^\/root/, '')) + : false, + '-fs': (path) => joinUrlSegments(base, fsBase + path), + } - // .env with .svg?.wasm?init - fetch( - joinUrlSegments( - base, - joinUrlSegments('/@fs/', ROOT) + '/root/src/.env?.svg?.wasm?init', - ), - ) - .then((r) => { - text('.unsafe-dotenv-query-dot-svg-wasm-init', r.status) - }) - .catch((e) => { - console.error(e) - }) + for (const { testId, path } of paths) { + for (const [variantName, transformPath] of Object.entries(variants)) { + const baseSelector = `.fetch${variantName}-${testId}` + const resolvedPath = transformPath(path) + if (resolvedPath === false) { + text(`${baseSelector}-path`, '--') + document.querySelector( + `a[href="#${baseSelector.slice(1)}-content"]`, + ).textContent = '' + text(`${baseSelector}-content`, '--') + continue + } + + text(`${baseSelector}-path`, resolvedPath) + fetch(resolvedPath) + .then((r) => { + text(`${baseSelector}-status`, r.status) + return r.text() + }) + .then((data) => { + text(`${baseSelector}-content`, data) + }) + .catch((e) => { + console.error(e) + }) + } + } function text(sel, text) { document.querySelector(sel).textContent = text
playground/fs-serve/root/vite.config-base.js+2 −1 modified@@ -1,6 +1,7 @@ import path from 'node:path' import { defineConfig } from 'vite' import svgVirtualModulePlugin from './svgVirtualModulePlugin' +import matrixTestResultPlugin from './matrixTestResultPlugin' const BASE = '/base/' @@ -35,5 +36,5 @@ export default defineConfig({ ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')), BASE: JSON.stringify(BASE), }, - plugins: [svgVirtualModulePlugin()], + plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()], })
playground/fs-serve/root/vite.config.js+2 −1 modified@@ -1,6 +1,7 @@ import path from 'node:path' import { defineConfig } from 'vite' import svgVirtualModulePlugin from './svgVirtualModulePlugin' +import matrixTestResultPlugin from './matrixTestResultPlugin' export default defineConfig({ build: { @@ -31,5 +32,5 @@ export default defineConfig({ define: { ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')), }, - plugins: [svgVirtualModulePlugin()], + plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()], })
playground/fs-serve/__tests__/commonTests.ts+263 −200 modified@@ -1,5 +1,6 @@ import http from 'node:http' import path from 'node:path' +import fs from 'node:fs' import { pathToFileURL } from 'node:url' import { setTimeout } from 'node:timers/promises' import { @@ -21,13 +22,17 @@ const getViteTestIndexHtmlUrl = () => { return viteTestUrl + srcPrefix + 'src/' } +const safeJsonContent = fs.readFileSync( + path.resolve(import.meta.dirname, '../safe.json'), + 'utf-8', +) const stringified = JSON.stringify(testJSON) -describe.runIf(isServe)('main', () => { - beforeAll(async () => { - await page.goto(getViteTestIndexHtmlUrl()) - }) +beforeAll(async () => { + await page.goto(getViteTestIndexHtmlUrl()) +}) +describe.runIf(isServe)('normal', () => { test('default import', async () => { await expect.poll(() => page.textContent('.full')).toBe(stringified) }) @@ -36,208 +41,264 @@ describe.runIf(isServe)('main', () => { await expect.poll(() => page.textContent('.named')).toBe(testJSON.msg) }) - test('virtual svg module', async () => { - await expect.poll(() => page.textContent('.virtual-svg')).toMatch('<svg') - }) - - test('safe fetch', async () => { - await expect.poll(() => page.textContent('.safe-fetch')).toMatch('KEY=safe') - await expect.poll(() => page.textContent('.safe-fetch-status')).toBe('200') - }) - - test('safe fetch with query', async () => { - await expect - .poll(() => page.textContent('.safe-fetch-query')) - .toMatch('KEY=safe') - await expect - .poll(() => page.textContent('.safe-fetch-query-status')) - .toBe('200') - }) - - test('safe fetch with special characters', async () => { - await expect - .poll(() => page.textContent('.safe-fetch-subdir-special-characters')) - .toMatch('KEY=safe') - await expect - .poll(() => - page.textContent('.safe-fetch-subdir-special-characters-status'), - ) - .toBe('200') - }) - - test('unsafe fetch', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch')) - .toMatch('403 Restricted') - await expect - .poll(() => page.textContent('.unsafe-fetch-status')) - .toBe('403') - }) - - test('unsafe HTML fetch', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch-html')) - .toMatch('403 Restricted') - await expect - .poll(() => page.textContent('.unsafe-fetch-html-status')) - .toBe('403') - }) - - test('unsafe fetch with special characters (#8498)', async () => { - await expect.poll(() => page.textContent('.unsafe-fetch-8498')).toBe('') - await expect - .poll(() => page.textContent('.unsafe-fetch-8498-status')) - .toBe('404') - }) - - test('unsafe fetch with special characters 2 (#8498)', async () => { - await expect.poll(() => page.textContent('.unsafe-fetch-8498-2')).toBe('') - await expect - .poll(() => page.textContent('.unsafe-fetch-8498-2-status')) - .toBe('404') - }) - - test('unsafe fetch import inline', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch-import-inline-status')) - .toBe('403') - }) - - test('unsafe fetch raw query import', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch-raw-query-import-status')) - .toBe('403') - }) - - test('unsafe fetch ?.svg?import', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch-query-dot-svg-import-status')) - .toBe('403') - }) - - test('unsafe fetch .svg?import', async () => { - await expect - .poll(() => page.textContent('.unsafe-fetch-svg-status')) - .toBe('403') - }) - - test('safe fs fetch', async () => { - await expect - .poll(() => page.textContent('.safe-fs-fetch')) - .toBe(stringified) - await expect - .poll(() => page.textContent('.safe-fs-fetch-status')) - .toBe('200') - }) - - test('safe fs fetch', async () => { - await expect - .poll(() => page.textContent('.safe-fs-fetch-query')) - .toBe(stringified) - await expect - .poll(() => page.textContent('.safe-fs-fetch-query-status')) - .toBe('200') - }) - - test('safe fs fetch with special characters', async () => { - await expect - .poll(() => page.textContent('.safe-fs-fetch-special-characters')) - .toBe(stringified) - await expect - .poll(() => page.textContent('.safe-fs-fetch-special-characters-status')) - .toBe('200') - }) - - test('unsafe fs fetch', async () => { - await expect.poll(() => page.textContent('.unsafe-fs-fetch')).toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-status')) - .toBe('403') - }) - - test('unsafe fs fetch', async () => { - await expect.poll(() => page.textContent('.unsafe-fs-fetch-raw')).toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-raw-status')) - .toBe('403') - }) - - test('unsafe fs fetch query 1', async () => { - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-raw-query1')) - .toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-raw-query1-status')) - .toBe('403') - }) - - test('unsafe fs fetch query 2', async () => { - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-raw-query2')) - .toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-raw-query2-status')) - .toBe('403') - }) - - test('unsafe fs fetch with special characters (#8498)', async () => { - await expect.poll(() => page.textContent('.unsafe-fs-fetch-8498')).toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-8498-status')) - .toBe('404') - }) - - test('unsafe fs fetch with special characters 2 (#8498)', async () => { - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-8498-2')) - .toBe('') - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-8498-2-status')) - .toBe('404') - }) - - test('unsafe fs fetch import inline', async () => { - await expect - .poll(() => page.textContent('.unsafe-fs-fetch-import-inline-status')) - .toBe('403') - }) - - test('unsafe fs fetch import inline wasm init', async () => { - await expect - .poll(() => - page.textContent('.unsafe-fs-fetch-import-inline-wasm-init-status'), - ) - .toBe('403') - }) - - test('unsafe fs fetch with relative path after query status', async () => { - await expect - .poll(() => - page.textContent('.unsafe-fs-fetch-relative-path-after-query-status'), - ) - .toBe('404') - }) - test('nested entry', async () => { await expect.poll(() => page.textContent('.nested-entry')).toBe('foobar') }) - test('denied', async () => { - await expect.poll(() => page.textContent('.unsafe-dotenv')).toBe('403') + test('virtual svg module', async () => { + await expect.poll(() => page.textContent('.virtual-svg')).toMatch('<svg') }) +}) - test('denied EnV casing', async () => { +describe.runIf(isServe)('matrix', () => { + const variants = [ + { variantId: '', variantName: 'normal' }, + { variantId: '-fs', variantName: '/@fs/' }, + ] as const + type VariantId = (typeof variants)[number]['variantId'] + const cases: Array<{ + name: string + testId: string + content: string | RegExp + status: string | string[] + skipVariants?: VariantId[] + isSPAFallback?: boolean + }> = [ + { + name: 'safe fetch', + testId: 'safe', + content: /KEY=safe/, + status: '200', + }, + { + name: 'safe fetch with query', + testId: 'safe-query', + content: /KEY=safe/, + status: '200', + }, + { + name: 'safe fetch in subdir', + testId: 'safe-subdir', + content: /KEY=safe/, + status: '200', + }, + { + name: 'safe fetch with special characters', + testId: 'safe-subdir-special-characters', + content: /KEY=safe/, + status: '200', + }, + { + name: 'safe fetch with special characters 2', + testId: 'safe-subdir-special-characters2', + content: safeJsonContent, + status: '200', + }, + { + name: 'safe fetch imported', + testId: 'safe-imported', + content: safeJsonContent, + status: '200', + skipVariants: [''], + }, + { + name: 'safe fetch imported with query', + testId: 'safe-imported-query', + content: safeJsonContent, + status: '200', + skipVariants: [''], + }, + + { + name: 'unsafe fetch', + testId: 'unsafe', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe JSON fetch', + testId: 'unsafe-json', + content: /403 Restricted/, + status: '403', + skipVariants: [''], + }, + { + name: 'unsafe HTML fetch', + testId: 'unsafe-html', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe HTML fetch outside root', + testId: 'unsafe-html-outside-root', + content: /403 Restricted/, + status: '403', + skipVariants: [''], + }, + { + name: 'unsafe fetch with special characters (#8498)', + testId: 'unsafe-8498', + content: '', + status: '404', + }, + { + name: 'unsafe fetch with special characters 2 (#8498)', + testId: 'unsafe-8498-2', + content: '', + status: '404', + }, + { + name: 'unsafe fetch import inline', + testId: 'unsafe-import-inline', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe fetch raw query import', + testId: 'unsafe-raw-query-import', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe fetch raw import raw outside root', + testId: 'unsafe-raw-import-raw-outside-root', + content: /403 Restricted/, + status: '403', + skipVariants: [''], + }, + { + name: 'unsafe fetch raw import raw outside root 1', + testId: 'unsafe-raw-import-raw-outside-root1', + content: /403 Restricted/, + status: '403', + skipVariants: [''], + }, + { + name: 'unsafe fetch raw import raw outside root 2', + testId: 'unsafe-raw-import-raw-outside-root2', + content: /403 Restricted/, + status: '403', + skipVariants: [''], + }, + { + name: 'unsafe fetch with ?url query', + testId: 'unsafe-url', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe fetch ?.svg?import', + testId: 'unsafe-query-dot-svg-import', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe fetch .svg?import', + testId: 'unsafe-svg', + content: /403 Restricted/, + status: '403', + }, + { + name: 'unsafe fetch import inline wasm init', + testId: 'unsafe-import-inline-wasm-init', + content: /403 Restricted/, + status: '403', + }, + // It is 404 in `fs-serve/base` test, 403 in `fs-serve` test + { + name: 'unsafe fetch with relative path after query', + testId: 'unsafe-relative-path-after-query', + content: /403 Restricted|^$/, + status: ['403', '404'], + isSPAFallback: true, + }, + { + name: 'denied .env', + testId: 'unsafe-dotenv', + content: /403 Restricted/, + status: '403', + }, // It is 403 in case insensitive system, 404 in others - await expect - .poll(() => page.textContent('.unsafe-dotEnV-casing')) - .toStrictEqual(expect.toBeOneOf(['403', '404'])) - }) + { + name: 'denied env casing', + testId: 'unsafe-dotenv-casing', + content: /403 Restricted|^$/, + status: ['403', '404'], + }, + { + name: 'denied .env with raw query', + testId: 'unsafe-dotenv-raw', + content: /403 Restricted/, + status: '403', + }, + { + name: 'denied .env with url query', + testId: 'unsafe-dotenv-url', + content: /403 Restricted/, + status: '403', + }, + { + name: 'denied .env with inline query', + testId: 'unsafe-dotenv-inline', + content: /403 Restricted/, + status: '403', + }, + { + name: 'denied env with ?.svg?.wasm?init', + testId: 'unsafe-dotenv-query-dot-svg-wasm-init', + content: /403 Restricted/, + status: '403', + }, + { + name: 'denied .env with import and raw query', + testId: 'unsafe-dotenv-import-raw', + content: /403 Restricted/, + status: '403', + }, + ] + + for (const { + name, + testId, + content, + status, + skipVariants, + isSPAFallback, + } of cases) { + for (const { variantId, variantName } of variants) { + if (skipVariants?.includes(variantId)) { + continue + } - test('denied env with ?.svg?.wasm?init', async () => { - await expect - .poll(() => page.textContent('.unsafe-dotenv-query-dot-svg-wasm-init')) - .toBe('403') - }) + test.concurrent(`${name} (${variantName})`, async ({ expect }) => { + const baseSelector = `.fetch${variantId}-${testId}` + const actualStatus = expect.poll(() => + page.textContent(`${baseSelector}-status`), + ) + const actualContent = expect.poll(() => + page.textContent(`${baseSelector}-content`), + ) + + if (variantName === 'normal' && isSPAFallback) { + await actualStatus.toBe('200') + await actualContent.toContain('<h1>FS Serve Matrix Test Summary</h1>') + return + } + + if (typeof status === 'string') { + await actualStatus.toBe(status) + } else { + await actualStatus.toBeOneOf(status) + } + + if (typeof content === 'string') { + await actualContent.toBe(content) + } else { + await actualContent.toMatch(content) + } + }) + } + } }) describe('fetch', () => { @@ -530,10 +591,12 @@ describe.runIf(isServe)('fetchModule via WebSocket', () => { describe.runIf(!isServe)('preview HTML', () => { test('unsafe HTML fetch', async () => { - await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('') await expect - .poll(() => page.textContent('.unsafe-fetch-html-status')) + .poll(() => page.textContent('.fetch-unsafe-html-status')) .toBe('404') + await expect + .poll(() => page.textContent('.fetch-unsafe-html-content')) + .toBe('') }) })
playground/fs-serve/__tests__/deny/fs-serve-deny.spec.ts+19 −12 modified@@ -2,16 +2,23 @@ import { describe, expect, test } from 'vitest' import { isServe, page, viteTestUrl } from '~utils' describe.runIf(isServe)('main', () => { - test('**/deny/** should deny src/deny/deny.txt', async () => { - const res = await page.request.fetch( - new URL('/src/deny/deny.txt', viteTestUrl).href, - ) - expect(res.status()).toBe(403) - }) - test('**/deny/** should deny src/deny/.deny', async () => { - const res = await page.request.fetch( - new URL('/src/deny/.deny', viteTestUrl).href, - ) - expect(res.status()).toBe(403) - }) + for (const { name, urlPath } of [ + { name: 'src/deny/deny.txt', urlPath: '/src/deny/deny.txt' }, + { name: 'src/deny/.deny', urlPath: '/src/deny/.deny' }, + { name: 'src/deny/deny.txt?raw', urlPath: '/src/deny/deny.txt?raw' }, + { name: 'src/deny/deny.txt?url', urlPath: '/src/deny/deny.txt?url' }, + { + name: 'src/deny/deny.txt?import&raw', + urlPath: '/src/deny/deny.txt?import&raw', + }, + { + name: 'src/deny/.deny?.svg?import', + urlPath: '/src/deny/.deny?.svg?import', + }, + ]) { + test(`**/deny/** should deny ${name}`, async () => { + const res = await page.request.fetch(new URL(urlPath, viteTestUrl).href) + expect(res.status()).toBe(403) + }) + } })
playground/fs-serve/__tests__/fs-serve.spec.ts+64 −66 modified@@ -40,71 +40,69 @@ describe.runIf(isServe)('invalid request', () => { const root = path .resolve(import.meta.dirname.replace('playground', 'playground-temp'), '..') .replace(/\\/g, '/') - - test('request with sendRawRequest should work', async () => { - const response = await sendRawRequest(viteTestUrl, '/src/safe.txt') - expect(response).toContain('HTTP/1.1 200 OK') - expect(response).toContain('KEY=safe') - }) - - test('request with sendRawRequest should work with /@fs/', async () => { - const response = await sendRawRequest( - viteTestUrl, - path.posix.join('/@fs/', root, 'root/src/safe.txt'), - ) - expect(response).toContain('HTTP/1.1 200 OK') - expect(response).toContain('KEY=safe') - }) - - test('should reject request that has # in request-target', async () => { - const response = await sendRawRequest( - viteTestUrl, - '/src/safe.txt#/../../unsafe.txt', - ) - expect(response).toContain('HTTP/1.1 400 Bad Request') - }) - - test('should reject request that has # in request-target with /@fs/', async () => { - const response = await sendRawRequest( - viteTestUrl, - path.posix.join('/@fs/', root, 'root/src/safe.txt') + + const testCases: Array<{ + name: string + target: string + status: string + content?: string + }> = [ + { + name: 'basic request', + target: '/src/safe.txt', + status: 'HTTP/1.1 200 OK', + content: 'KEY=safe', + }, + { + name: 'request with /@fs/', + target: path.posix.join('/@fs/', root, 'root/src/safe.txt'), + status: 'HTTP/1.1 200 OK', + content: 'KEY=safe', + }, + { + name: '# in request-target', + target: '/src/safe.txt#/../../unsafe.txt', + status: 'HTTP/1.1 400 Bad Request', + }, + { + name: '# in request-target with /@fs/', + target: + path.posix.join('/@fs/', root, 'root/src/safe.txt') + '#/../../unsafe.txt', - ) - expect(response).toContain('HTTP/1.1 400 Bad Request') - }) - - test('should deny request to denied file when a request has /.', async () => { - const response = await sendRawRequest(viteTestUrl, '/src/dummy.crt/.') - expect(response).toContain('HTTP/1.1 403 Forbidden') - }) - - test('should deny request to denied file when a request ends with \\', async () => { - const response = await sendRawRequest(viteTestUrl, '/src/.env\\') - expect(response).toContain( - isWindows ? 'HTTP/1.1 403 Forbidden' : 'HTTP/1.1 404 Not Found', - ) - }) - - test('should deny request to denied file when a request ends with \\ with /@fs/', async () => { - const response = await sendRawRequest( - viteTestUrl, - path.posix.join('/@fs/', root, 'root/src/.env') + '\\', - ) - expect(response).toContain( - isWindows ? 'HTTP/1.1 403 Forbidden' : 'HTTP/1.1 404 Not Found', - ) - }) - - test('should deny request with /@fs/ to denied file when a request has /.', async () => { - const response = await sendRawRequest( - viteTestUrl, - path.posix.join('/@fs/', root, 'root/src/dummy.crt/') + '.', - ) - expect(response).toContain('HTTP/1.1 403 Forbidden') - }) - - test('should deny request to HTML file outside root by default with relative path', async () => { - const response = await sendRawRequest(viteTestUrl, '/../unsafe.html') - expect(response).toContain('HTTP/1.1 403 Forbidden') - }) + status: 'HTTP/1.1 400 Bad Request', + }, + { + name: 'denied file with /.', + target: '/src/dummy.crt/.', + status: 'HTTP/1.1 403 Forbidden', + }, + { + name: 'denied file ending with \\', + target: '/src/.env\\', + status: isWindows ? 'HTTP/1.1 403 Forbidden' : 'HTTP/1.1 404 Not Found', + }, + { + name: 'denied file ending with \\ with /@fs/', + target: path.posix.join('/@fs/', root, 'root/src/.env') + '\\', + status: isWindows ? 'HTTP/1.1 403 Forbidden' : 'HTTP/1.1 404 Not Found', + }, + { + name: 'denied file with /. with /@fs/', + target: path.posix.join('/@fs/', root, 'root/src/dummy.crt/') + '.', + status: 'HTTP/1.1 403 Forbidden', + }, + { + name: 'HTML outside root with relative path', + target: '/../unsafe.html', + status: 'HTTP/1.1 403 Forbidden', + }, + ] + for (const { name, target, status, content } of testCases) { + test(name, async () => { + const response = await sendRawRequest(viteTestUrl, target) + expect(response).toContain(status) + if (content !== undefined) { + expect(response).toContain(content) + } + }) + } })
pnpm-lock.yaml+6 −0 modified@@ -849,6 +849,12 @@ importers: playground/fs-serve: devDependencies: + '@types/escape-html': + specifier: ^1.0.4 + version: 1.0.4 + escape-html: + specifier: ^1.0.3 + version: 1.0.3 ws: specifier: ^8.20.0 version: 8.20.0
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/vitejs/vite/security/advisories/GHSA-v2wj-q39q-566rnvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-v2wj-q39q-566rghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39364ghsaADVISORY
- github.com/vitejs/vite/commit/a9a3df299378d9cbc5f069e3536a369f8188c8ffghsaWEB
- github.com/vitejs/vite/pull/22160ghsaWEB
- github.com/vitejs/vite/releases/tag/v7.3.2ghsaWEB
- github.com/vitejs/vite/releases/tag/v8.0.5ghsaWEB
News mentions
0No linked articles in our index yet.