CVE-2025-31486
Description
Vite is a frontend tooling framework for javascript. The contents of arbitrary files can be returned to the browser. By adding ?.svg with ?.wasm?init or with sec-fetch-dest: script header, the server.fs.deny restriction was able to bypass. This bypass is only possible if the file is smaller than build.assetsInlineLimit (default: 4kB) and when using Vite 6.0+. Only apps explicitly exposing the Vite dev server to the network (using --host or server.host config option) are affected. This vulnerability is fixed in 4.5.12, 5.4.17, 6.0.14, 6.1.4, and 6.2.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vitenpm | >= 6.2.0, < 6.2.5 | 6.2.5 |
vitenpm | >= 6.1.0, < 6.1.4 | 6.1.4 |
vitenpm | >= 6.0.0, < 6.0.14 | 6.0.14 |
vitenpm | >= 5.0.0, < 5.4.17 | 5.4.17 |
vitenpm | < 4.5.12 | 4.5.12 |
Patches
66104add2ed020a2518a98d23f678baacaf22f4d34dc4cd1fc176acf70a1162d7e81ee189fix: fs check with svg and relative paths (#19782)
7 files changed · +125 −8
packages/vite/src/node/plugins/asset.ts+3 −2 modified@@ -286,8 +286,9 @@ export async function fileToDevUrl( // If is svg and it's inlined in build, also inline it in dev to match // the behaviour in build due to quote handling differences. - if (svgExtRE.test(id)) { - const file = publicFile || cleanUrl(id) + const cleanedId = cleanUrl(id) + if (svgExtRE.test(cleanedId)) { + const file = publicFile || cleanedId const content = await fsp.readFile(file) if (shouldInline(environment, file, id, content, undefined, undefined)) { return assetToDataURL(environment, file, content)
packages/vite/src/node/plugins/wasm.ts+3 −1 modified@@ -3,6 +3,8 @@ import { fileToUrl } from './asset' const wasmHelperId = '\0vite/wasm-helper.js' +const wasmInitRE = /(?<![?#].*)\.wasm\?init/ + const wasmHelper = async (opts = {}, url: string) => { let result if (url.startsWith('data:')) { @@ -63,7 +65,7 @@ export const wasmHelperPlugin = (): Plugin => { return `export default ${wasmHelperCode}` } - if (!id.endsWith('.wasm?init')) { + if (!wasmInitRE.test(id)) { return }
packages/vite/src/node/server/middlewares/transform.ts+30 −5 modified@@ -1,5 +1,6 @@ import path from 'node:path' import fsp from 'node:fs/promises' +import type { ServerResponse } from 'node:http' import type { Connect } from 'dep-types/connect' import colors from 'picocolors' import type { ExistingRawSourceMap } from 'rollup' @@ -16,7 +17,11 @@ import { removeTimestampQuery, } from '../../utils' import { send } from '../send' -import { ERR_LOAD_URL, transformRequest } from '../transformRequest' +import { + ERR_DENIED_ID, + ERR_LOAD_URL, + transformRequest, +} from '../transformRequest' import { applySourcemapIgnoreList } from '../sourcemap' import { isHTMLProxy } from '../../plugins/html' import { @@ -47,6 +52,22 @@ const trailingQuerySeparatorsRE = /[?&]+$/ const urlRE = /[?&]url\b/ const rawRE = /[?&]raw\b/ const inlineRE = /[?&]inline\b/ +const svgRE = /\.svg\b/ + +function deniedServingAccessForTransform( + url: string, + server: ViteDevServer, + res: ServerResponse, + next: Connect.NextFunction, +) { + return ( + (rawRE.test(url) || + urlRE.test(url) || + inlineRE.test(url) || + svgRE.test(url)) && + !ensureServingAccess(url, server, res, next) + ) +} /** * A middleware that short-circuits the middleware chain to serve cached transformed modules @@ -178,10 +199,7 @@ export function transformMiddleware( '', ) if ( - (rawRE.test(urlWithoutTrailingQuerySeparators) || - urlRE.test(urlWithoutTrailingQuerySeparators) || - inlineRE.test(urlWithoutTrailingQuerySeparators)) && - !ensureServingAccess( + deniedServingAccessForTransform( urlWithoutTrailingQuerySeparators, server, res, @@ -231,6 +249,9 @@ export function transformMiddleware( // resolve, load and transform using the plugin container const result = await transformRequest(environment, url, { html: req.headers.accept?.includes('text/html'), + allowId(id) { + return !deniedServingAccessForTransform(id, server, res, next) + }, }) if (result) { const depsOptimizer = environment.depsOptimizer @@ -301,6 +322,10 @@ export function transformMiddleware( // Let other middleware handle if we can't load the url via transformRequest return next() } + if (e?.code === ERR_DENIED_ID) { + // next() is called in ensureServingAccess + return + } return next(e) }
packages/vite/src/node/server/transformRequest.ts+11 −0 modified@@ -32,6 +32,7 @@ import type { DevEnvironment } from './environment' export const ERR_LOAD_URL = 'ERR_LOAD_URL' export const ERR_LOAD_PUBLIC_URL = 'ERR_LOAD_PUBLIC_URL' +export const ERR_DENIED_ID = 'ERR_DENIED_ID' const debugLoad = createDebugger('vite:load') const debugTransform = createDebugger('vite:transform') @@ -55,6 +56,10 @@ export interface TransformOptions { * @internal */ html?: boolean + /** + * @internal + */ + allowId?: (id: string) => boolean } // TODO: This function could be moved to the DevEnvironment class. @@ -248,6 +253,12 @@ async function loadAndTransform( const moduleGraph = environment.moduleGraph + if (options.allowId && !options.allowId(id)) { + const err: any = new Error(`Denied ID ${id}`) + err.code = ERR_DENIED_ID + throw err + } + let code: string | null = null let map: SourceDescription['map'] = null
playground/fs-serve/root/src/index.html+51 −0 modified@@ -25,6 +25,8 @@ <h2>Unsafe Fetch</h2> <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> @@ -49,13 +51,15 @@ <h2>Unsafe /@fs/ Fetch</h2> <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> <script type="module"> import '../../entry' @@ -182,6 +186,24 @@ <h2>Denied</h2> 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) => { @@ -298,6 +320,21 @@ <h2>Denied</h2> 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( @@ -368,6 +405,20 @@ <h2>Denied</h2> console.error(e) }) + // .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) + }) + function text(sel, text) { document.querySelector(sel).textContent = text }
playground/fs-serve/root/unsafe.svg+3 −0 added@@ -0,0 +1,3 @@ +<svg height="100" width="100" xmlns="http://www.w3.org/2000/svg"> + <circle r="45" cx="50" cy="50" fill="purple" /> +</svg>
playground/fs-serve/__tests__/fs-serve.spec.ts+24 −0 modified@@ -79,6 +79,16 @@ describe.runIf(isServe)('main', () => { ).toBe('403') }) + test('unsafe fetch ?.svg?import', async () => { + expect( + await page.textContent('.unsafe-fetch-query-dot-svg-import-status'), + ).toBe('403') + }) + + test('unsafe fetch .svg?import', async () => { + expect(await page.textContent('.unsafe-fetch-svg-status')).toBe('403') + }) + test('safe fs fetch', async () => { expect(await page.textContent('.safe-fs-fetch')).toBe(stringified) expect(await page.textContent('.safe-fs-fetch-status')).toBe('200') @@ -144,6 +154,14 @@ describe.runIf(isServe)('main', () => { ).toBe('403') }) + test('unsafe fs fetch with relative path after query status', async () => { + expect( + await page.textContent( + '.unsafe-fs-fetch-relative-path-after-query-status', + ), + ).toBe('403') + }) + test('nested entry', async () => { expect(await page.textContent('.nested-entry')).toBe('foobar') }) @@ -157,6 +175,12 @@ describe.runIf(isServe)('main', () => { const code = await page.textContent('.unsafe-dotEnV-casing') expect(code === '403' || code === '404').toBeTruthy() }) + + test('denied env with ?.svg?.wasm?init', async () => { + expect( + await page.textContent('.unsafe-dotenv-query-dot-svg-wasm-init'), + ).toBe('403') + }) }) describe('fetch', () => {
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
5- github.com/advisories/GHSA-xcj6-pq6g-qj4xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-31486ghsaADVISORY
- github.com/vitejs/vite/blob/037f801075ec35bb6e52145d659f71a23813c48f/packages/vite/src/node/plugins/asset.tsnvdWEB
- github.com/vitejs/vite/commit/62d7e81ee189d65899bb65f3263ddbd85247b647nvdWEB
- github.com/vitejs/vite/security/advisories/GHSA-xcj6-pq6g-qj4xnvdWEB
News mentions
0No linked articles in our index yet.