From 633a1ff1ab38d1d6b1612d07d9be07f96d714231 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:28:17 +0900 Subject: [PATCH 1/4] fix: apply `server.fs` check to env transport --- packages/vite/src/node/server/environment.ts | 22 +++--- packages/vite/src/node/server/hmr.ts | 6 ++ .../src/node/server/middlewares/transform.ts | 14 ++-- .../vite/src/node/server/transformRequest.ts | 17 +++-- .../node/ssr/__tests__/fixtures/basic/file.js | 1 + .../node/ssr/__tests__/ssrLoadModule.spec.ts | 23 +++++++ .../ssr/runtime/__tests__/fixture-outside.js | 1 + .../runtime/__tests__/server-runtime.spec.ts | 17 ++++- .../server-worker-runner.invoke.spec.ts | 13 ++++ .../__tests__/server-worker-runner.spec.ts | 45 ++++++++++++ playground/fs-serve/__tests__/commonTests.ts | 69 +++++++++++++++++++ 11 files changed, 202 insertions(+), 26 deletions(-) create mode 100644 packages/vite/src/node/ssr/__tests__/fixtures/basic/file.js create mode 100644 packages/vite/src/node/ssr/runtime/__tests__/fixture-outside.js diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index de0e62f45bd49e..b83f829fe17968 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -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 { @@ -61,6 +58,10 @@ export class DevEnvironment extends BaseEnvironment { * @internal */ _remoteRunnerOptions: DevEnvironmentContext['remoteRunner'] + /** + * @internal + */ + _skipFsCheck: boolean get pluginContainer(): EnvironmentPluginContainer { if (!this._pluginContainer) @@ -128,6 +129,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 @@ -233,12 +239,8 @@ export class DevEnvironment extends BaseEnvironment { } } - transformRequest( - url: string, - /** @internal */ - options?: TransformOptionsInternal, - ): Promise { - return transformRequest(this, url, options) + transformRequest(url: string): Promise { + return transformRequest(this, url, { skipFsCheck: this._skipFsCheck }) } async warmupRequest(url: string): Promise { diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index 2803c911138cdc..2298047efaf23a 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -89,6 +89,11 @@ export type HotChannelListener = ( ) => void export interface HotChannel { + /** + * 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) }, diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts index e6c7e3fdb24246..345771162b6bcc 100644 --- a/packages/vite/src/node/server/middlewares/transform.ts +++ b/packages/vite/src/node/server/middlewares/transform.ts @@ -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' diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts index 397b63fd6e049c..9f097460414f78 100644 --- a/packages/vite/src/node/server/transformRequest.ts +++ b/packages/vite/src/node/server/transformRequest.ts @@ -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 { 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 { diff --git a/packages/vite/src/node/ssr/__tests__/fixtures/basic/file.js b/packages/vite/src/node/ssr/__tests__/fixtures/basic/file.js new file mode 100644 index 00000000000000..60c71f346d9a3e --- /dev/null +++ b/packages/vite/src/node/ssr/__tests__/fixtures/basic/file.js @@ -0,0 +1 @@ +export default 'ok' diff --git a/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts index 7d144fad15ac14..423f1d539ec617 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrLoadModule.spec.ts @@ -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') +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixture-outside.js b/packages/vite/src/node/ssr/runtime/__tests__/fixture-outside.js new file mode 100644 index 00000000000000..b0c2b6333f05d9 --- /dev/null +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixture-outside.js @@ -0,0 +1 @@ +export default 'error' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 8eb4276c54e38f..292868d608bb71 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -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') + }) +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.invoke.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.invoke.spec.ts index e156faca3797ed..8ba892f9c5539a 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.invoke.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.invoke.spec.ts @@ -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') + }) }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts index 25c388034ccd46..49759b955c57c4 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-worker-runner.spec.ts @@ -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((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') + }) }) diff --git a/playground/fs-serve/__tests__/commonTests.ts b/playground/fs-serve/__tests__/commonTests.ts index b9ab4fae96d388..4eeb8951ad0e17 100644 --- a/playground/fs-serve/__tests__/commonTests.ts +++ b/playground/fs-serve/__tests__/commonTests.ts @@ -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((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 read files inside allowed directories', async () => { + const result = await fetchModuleViaWebSocket('root/src/safe.txt?raw') + expect(result.result).toBeTruthy() + expect(result.error).toBeFalsy() + }) + + 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('') From 88a72d0118aaa6033c78b72ec8081a57df9f6963 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:27:46 +0900 Subject: [PATCH 2/4] docs: add docs --- docs/guide/api-environment-runtimes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/guide/api-environment-runtimes.md b/docs/guide/api-environment-runtimes.md index feee6d16c74471..eefac39205a55a 100644 --- a/docs/guide/api-environment-runtimes.md +++ b/docs/guide/api-environment-runtimes.md @@ -156,6 +156,8 @@ function createWorkerdDevEnvironment( } ``` +By default, `HotChannel` transports (non-WebSocket) 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 From d120702e3cc456a9a0ef0dab803dc314c7a20df8 Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:46:28 +0900 Subject: [PATCH 3/4] feat: disable fetchModule in the default client environment --- packages/vite/src/node/config.ts | 1 + packages/vite/src/node/server/environment.ts | 7 ++++++- playground/fs-serve/__tests__/commonTests.ts | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 9e31a35bd7f4e5..cbbeefab65a698 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -254,6 +254,7 @@ function defaultCreateClientDevEnvironment( return new DevEnvironment(name, config, { hot: true, transport: context.ws, + disableFetchModule: true, }) } diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index b83f829fe17968..133205856a28c3 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -45,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 } @@ -143,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 () => { @@ -245,7 +250,7 @@ export class DevEnvironment extends BaseEnvironment { async warmupRequest(url: string): Promise { try { - await this.transformRequest(url) + await transformRequest(this, url, { skipFsCheck: true }) } catch (e) { if ( e?.code === ERR_OUTDATED_OPTIMIZED_DEP || diff --git a/playground/fs-serve/__tests__/commonTests.ts b/playground/fs-serve/__tests__/commonTests.ts index 4eeb8951ad0e17..cbc8d84925c256 100644 --- a/playground/fs-serve/__tests__/commonTests.ts +++ b/playground/fs-serve/__tests__/commonTests.ts @@ -515,10 +515,10 @@ describe.runIf(isServe)('fetchModule via WebSocket', () => { } } - test('should read files inside allowed directories', async () => { + 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).toBeTruthy() - expect(result.error).toBeFalsy() + expect(result.result).toBeUndefined() + expect(result.error).toBeTruthy() }) test('should not read files outside allowed directories', async () => { From a1a518e25bb09daa7bc2a50edf9d261bf46b6f05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0?= Date: Thu, 19 Mar 2026 18:28:23 +0900 Subject: [PATCH 4/4] docs: fix wording --- docs/guide/api-environment-runtimes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/api-environment-runtimes.md b/docs/guide/api-environment-runtimes.md index eefac39205a55a..4432d70de29945 100644 --- a/docs/guide/api-environment-runtimes.md +++ b/docs/guide/api-environment-runtimes.md @@ -156,7 +156,7 @@ function createWorkerdDevEnvironment( } ``` -By default, `HotChannel` transports (non-WebSocket) 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. +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.