CVE-2026-39363
Description
Vite is a frontend tooling framework for JavaScript. From 6.0.0 to before 6.4.2, 7.3.2, and 8.0.5, if it is possible to connect to the Vite dev server’s WebSocket without an Origin header, an attacker can invoke fetchModule via the custom WebSocket event vite:invoke and combine file://... with ?raw (or ?inline) to retrieve the contents of arbitrary files on the server as a JavaScript string (e.g., export default "..."). The access control enforced in the HTTP request path (such as server.fs.allow) is not applied to this WebSocket-based execution path. This vulnerability is fixed in 6.4.2, 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.0.0, < 7.3.2 | 7.3.2 |
vitenpm | >= 6.0.0, < 6.4.2 | 6.4.2 |
Affected products
2Patches
1f02d9fde0b19fix: apply server.fs check to env transport (#22159)
13 files changed · +213 −27
docs/guide/api-environment-runtimes.md+4 −0 modified@@ -156,6 +156,8 @@ function createWorkerdDevEnvironment( } ``` +By default, `HotChannel` transports have `server.fs` restrictions applied, meaning only files within the allowed directories can be served. If your transport is not exposed over the network (e.g., it communicates via worker threads or in-process calls), you can set `skipFsCheck: true` on the `HotChannel` to bypass these restrictions. + There are [multiple communication levels for the `DevEnvironment`](/guide/api-environment-frameworks#devenvironment-communication-levels). To make it easier for frameworks to write runtime agnostic code, we recommend to implement the most flexible communication level possible. ## `ModuleRunner` @@ -369,6 +371,8 @@ function createWorkerEnvironment(name, config, context) { } const workerHotChannel = { + // Worker threads post messages are not exposed over the network, skip server.fs checks + skipFsCheck: true, send: (data) => worker.postMessage(data), on: (event, handler) => { // client is already connected
packages/vite/src/node/config.ts+1 −0 modified@@ -254,6 +254,7 @@ function defaultCreateClientDevEnvironment( return new DevEnvironment(name, config, { hot: true, transport: context.ws, + disableFetchModule: true, }) }
packages/vite/src/node/server/environment.ts+18 −11 modified@@ -26,10 +26,7 @@ import type { NormalizedHotChannelClient, } from './hmr' import { getShortName, normalizeHotChannel, updateModules } from './hmr' -import type { - TransformOptionsInternal, - TransformResult, -} from './transformRequest' +import type { TransformResult } from './transformRequest' import { transformRequest } from './transformRequest' import type { EnvironmentPluginContainer } from './pluginContainer' import { @@ -48,6 +45,8 @@ export interface DevEnvironmentContext { inlineSourceMap?: boolean } depsOptimizer?: DepsOptimizer + /** @internal used for client environment */ + disableFetchModule?: boolean /** @internal used for full bundle mode */ disableDepsOptimizer?: boolean } @@ -61,6 +60,10 @@ export class DevEnvironment extends BaseEnvironment { * @internal */ _remoteRunnerOptions: DevEnvironmentContext['remoteRunner'] + /** + * @internal + */ + _skipFsCheck: boolean get pluginContainer(): EnvironmentPluginContainer<DevEnvironment> { if (!this._pluginContainer) @@ -128,6 +131,11 @@ export class DevEnvironment extends BaseEnvironment { this._crawlEndFinder = setupOnCrawlEnd() this._remoteRunnerOptions = context.remoteRunner ?? {} + this._skipFsCheck = !!( + context.transport && + !(isWebSocketServer in context.transport) && + context.transport.skipFsCheck + ) this.hot = context.transport ? isWebSocketServer in context.transport @@ -137,6 +145,9 @@ export class DevEnvironment extends BaseEnvironment { this.hot.setInvokeHandler({ fetchModule: (id, importer, options) => { + if (context.disableFetchModule) { + throw new Error('fetchModule is disabled in this environment') + } return this.fetchModule(id, importer, options) }, getBuiltins: async () => { @@ -233,17 +244,13 @@ export class DevEnvironment extends BaseEnvironment { } } - transformRequest( - url: string, - /** @internal */ - options?: TransformOptionsInternal, - ): Promise<TransformResult | null> { - return transformRequest(this, url, options) + transformRequest(url: string): Promise<TransformResult | null> { + return transformRequest(this, url, { skipFsCheck: this._skipFsCheck }) } async warmupRequest(url: string): Promise<void> { try { - await this.transformRequest(url) + await transformRequest(this, url, { skipFsCheck: true }) } catch (e) { if ( e?.code === ERR_OUTDATED_OPTIMIZED_DEP ||
packages/vite/src/node/server/hmr.ts+6 −0 modified@@ -89,6 +89,11 @@ export type HotChannelListener<T extends string = string> = ( ) => void export interface HotChannel<Api = any> { + /** + * When true, the fs access check is skipped in fetchModule. + * Set this for transports that is not exposed over the network. + */ + skipFsCheck?: boolean /** * Broadcast events to all clients */ @@ -1130,6 +1135,7 @@ export function createServerHotChannel(): ServerHotChannel { const outsideEmitter = new EventEmitter() return { + skipFsCheck: true, send(payload: HotPayload) { outsideEmitter.emit('send', payload) },
packages/vite/src/node/server/middlewares/transform.ts+5 −9 modified@@ -57,7 +57,10 @@ const rawRE = /[?&]raw\b/ const inlineRE = /[?&]inline\b/ const svgRE = /\.svg\b/ -function isServerAccessDeniedForTransform(config: ResolvedConfig, id: string) { +export function isServerAccessDeniedForTransform( + config: ResolvedConfig, + id: string, +): boolean { if (rawRE.test(id) || urlRE.test(id) || inlineRE.test(id) || svgRE.test(id)) { return checkLoadingAccess(config, id) !== 'allowed' } @@ -244,14 +247,7 @@ export function transformMiddleware( } // resolve, load and transform using the plugin container - const result = await environment.transformRequest(url, { - allowId(id) { - return ( - id[0] === '\0' || - !isServerAccessDeniedForTransform(server.config, id) - ) - }, - }) + const result = await environment.transformRequest(url) if (result) { const depsOptimizer = environment.depsOptimizer const type = isDirectCSSRequest(url) ? 'css' : 'js'
packages/vite/src/node/server/transformRequest.ts+11 −6 modified@@ -35,6 +35,7 @@ import { import { isFileLoadingAllowed } from './middlewares/static' import { throwClosedServerError } from './pluginContainer' import type { DevEnvironment } from './environment' +import { isServerAccessDeniedForTransform } from './middlewares/transform' export const ERR_LOAD_URL = 'ERR_LOAD_URL' export const ERR_LOAD_PUBLIC_URL = 'ERR_LOAD_PUBLIC_URL' @@ -60,11 +61,11 @@ export interface TransformOptions { ssr?: boolean } -export interface TransformOptionsInternal { +interface TransformOptionsInternal { /** - * @internal + * Whether to skip the `server.fs` check. */ - allowId?: (id: string) => boolean + skipFsCheck: boolean } // TODO: This function could be moved to the DevEnvironment class. @@ -77,7 +78,7 @@ export interface TransformOptionsInternal { export function transformRequest( environment: DevEnvironment, url: string, - options: TransformOptionsInternal = {}, + options: TransformOptionsInternal, ): Promise<TransformResult | null> { if (environment._closing && environment.config.dev.recoverable) throwClosedServerError() @@ -248,7 +249,11 @@ async function loadAndTransform( const moduleGraph = environment.moduleGraph - if (options.allowId && !options.allowId(id)) { + if ( + !options.skipFsCheck && + id[0] !== '\0' && + isServerAccessDeniedForTransform(config, id) + ) { const err: any = new Error(`Denied ID ${id}`) err.code = ERR_DENIED_ID err.id = id @@ -272,7 +277,7 @@ async function loadAndTransform( // only try the fallback if access is allowed, skip for out of root url // like /service-worker.js or /api/users if ( - environment.config.consumer === 'server' || + options.skipFsCheck || isFileLoadingAllowed(environment.getTopLevelConfig(), slash(file)) ) { try {
packages/vite/src/node/ssr/runtime/__tests__/fixture-outside.js+1 −0 added@@ -0,0 +1 @@ +export default 'error'
packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts+16 −1 modified@@ -1,5 +1,5 @@ import { existsSync, readdirSync } from 'node:fs' -import { posix, win32 } from 'node:path' +import { posix, resolve, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { setTimeout } from 'node:timers/promises' import { describe, expect, it, vi } from 'vitest' @@ -625,3 +625,18 @@ describe('full-reload during close', () => { ).toBe(false) }) }) + +describe('server.fs check', async () => { + const it = await createModuleRunnerTester({ + server: { + fs: { + allow: [resolve(import.meta.dirname, './fixtures/circular')], + }, + }, + }) + + it('it is not applied to the server module runner', async ({ runner }) => { + const mod = await runner.import('/fixtures/basic.js') + expect(mod.name).toBe('basic') + }) +})
packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.invoke.spec.ts+13 −0 modified@@ -1,4 +1,5 @@ import { BroadcastChannel, Worker } from 'node:worker_threads' +import path from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { BirpcReturn } from 'birpc' import { createBirpc } from 'birpc' @@ -34,6 +35,9 @@ describe('running module runner inside a worker and using the ModuleRunnerTransp hmr: { port: 9610, }, + fs: { + allow: [path.resolve(import.meta.dirname, './fixtures')], + }, }, environments: { worker: { @@ -109,4 +113,13 @@ describe('running module runner inside a worker and using the ModuleRunnerTransp expect(output.result).toBe('baz.txt') expect(output.error).toBeUndefined() }) + + it('server.fs check is applied to the custom transport by default', async () => { + handleInvoke = (data: any) => + server.environments.worker.hot.handleInvoke(data) + + const output = await run('./fixture-outside.js') + expect(output).toHaveProperty('error') + expect(output.error).toContain('Failed to load url') + }) })
packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts+45 −0 modified@@ -1,4 +1,5 @@ import { BroadcastChannel, Worker } from 'node:worker_threads' +import path from 'node:path' import { describe, expect, it, onTestFinished } from 'vitest' import type { HotChannel, HotChannelListener, HotPayload } from 'vite' import { DevEnvironment } from '../../..' @@ -112,4 +113,48 @@ describe('running module runner inside a worker', () => { channel.postMessage({ id: './fixtures/default-string.ts' }) }) }) + + it('server.fs check is applied to the custom transport by default', async () => { + const worker = new Worker( + new URL('./fixtures/worker.mjs', import.meta.url), + { stdout: true }, + ) + await new Promise<void>((resolve, reject) => { + worker.on('message', () => resolve()) + worker.on('error', reject) + }) + const server = await createServer({ + root: import.meta.dirname, + logLevel: 'error', + server: { + middlewareMode: true, + watch: null, + hmr: { + port: 9609, + }, + fs: { + allow: [path.resolve(import.meta.dirname, './fixtures')], + }, + }, + environments: { + worker: { + dev: { + createEnvironment: (name, config) => { + return new DevEnvironment(name, config, { + hot: false, + transport: createWorkerTransport(worker), + }) + }, + }, + }, + }, + }) + onTestFinished(async () => { + await Promise.allSettled([server.close(), worker.terminate()]) + }) + + await expect( + server.environments.worker.transformRequest('./fixture-outside.js'), + ).rejects.toThrow('Failed to load url') + }) })
packages/vite/src/node/ssr/__tests__/fixtures/basic/file.js+1 −0 added@@ -0,0 +1 @@ +export default 'ok'
packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts+23 −0 modified@@ -406,3 +406,26 @@ test('buildStart before transform', async () => { ] `) }) + +test('server.fs check is not applied to ssrLoadModule', async () => { + const server = await createServer({ + configFile: false, + root, + logLevel: 'silent', + optimizeDeps: { + noDiscovery: true, + }, + server: { + fs: { + allow: [ + path.resolve(import.meta.dirname, './fixtures/named-overwrite-all'), + ], + }, + }, + }) + onTestFinished(() => server.close()) + await server.environments.ssr.pluginContainer.buildStart({}) + + const mod = await server.ssrLoadModule('/fixtures/basic/file.js') + expect(mod.default).toBe('ok') +})
playground/fs-serve/__tests__/commonTests.ts+69 −0 modified@@ -1,4 +1,7 @@ import http from 'node:http' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { setTimeout } from 'node:timers/promises' import { afterEach, beforeAll, @@ -459,6 +462,72 @@ describe('cross origin', () => { }) }) +describe.runIf(isServe)('fetchModule via WebSocket', () => { + const root = path.resolve( + import.meta.dirname.replace('playground', 'playground-temp'), + '..', + ) + + const fetchModuleViaWebSocket = async (filePath: string) => { + const resolvedPath = path.resolve(root, filePath) + const token = viteServer.config.webSocketToken + const wsUrl = viteTestUrl.replace('http', 'ws') + const ws = new WebSocket(`${wsUrl}?token=${token}`, ['vite-hmr']) + + try { + return await Promise.race([ + new Promise<any>((resolve, reject) => { + ws.on('open', () => { + ws.send( + JSON.stringify({ + type: 'custom', + event: 'vite:invoke', + data: { + name: 'fetchModule', + id: 'send:1', + data: [pathToFileURL(resolvedPath).href], + }, + }), + ) + }) + + ws.on('message', (raw: Buffer) => { + const parsed = JSON.parse(raw.toString()) + if ( + parsed.type === 'custom' && + parsed.event === 'vite:invoke' && + parsed.data?.id === 'response:1' + ) { + resolve(parsed.data.data) + } + }) + + ws.on('error', (err) => { + reject(err) + }) + }), + setTimeout(10_000).then(() => + Promise.reject(new Error('WebSocket response timed out')), + ), + ]) + } finally { + ws.close() + } + } + + test('should not read files inside allowed directories as fetchModule is disabled', async () => { + const result = await fetchModuleViaWebSocket('root/src/safe.txt?raw') + expect(result.result).toBeUndefined() + expect(result.error).toBeTruthy() + }) + + test('should not read files outside allowed directories', async () => { + const result = await fetchModuleViaWebSocket('root/unsafe.txt?raw') + expect(result.result).toBeUndefined() + expect(result.error).toBeTruthy() + }) +}) + describe.runIf(!isServe)('preview HTML', () => { test('unsafe HTML fetch', async () => { await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('')
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
8- github.com/vitejs/vite/security/advisories/GHSA-p9ff-h696-f583nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-p9ff-h696-f583ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39363ghsaADVISORY
- github.com/vitejs/vite/commit/f02d9fde0b195afe3ea2944414186962fbbe41e0ghsaWEB
- github.com/vitejs/vite/pull/22159ghsaWEB
- github.com/vitejs/vite/releases/tag/v6.4.2ghsaWEB
- 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.