VYPR
High severity7.5NVD Advisory· Published Apr 7, 2026· Updated Apr 30, 2026

CVE-2026-39363

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.

PackageAffected versionsPatched versions
vitenpm
>= 8.0.0, < 8.0.58.0.5
vitenpm
>= 7.0.0, < 7.3.27.3.2
vitenpm
>= 6.0.0, < 6.4.26.4.2

Affected products

2
  • cpe:2.3:a:vitejs:vite:*:*:*:*:*:node.js:*:*
    Range: >=6.0.0,<=6.4.1
  • cpe:2.3:a:voidzero:vite\+:*:*:*:*:*:node.js:*:*
    Range: <=0.1.15

Patches

1
f02d9fde0b19

fix: apply server.fs check to env transport (#22159)

https://github.com/vitejs/viteApr 6, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.