diff --git a/docs/config/index.md b/docs/config/index.md
index 6c6370fb0c8dbf..be11392ea0f349 100644
--- a/docs/config/index.md
+++ b/docs/config/index.md
@@ -488,12 +488,12 @@ createServer()
Restrict serving files outside of workspace root.
-### server.fsServe.root
+### server.fsServe.allow
- **Experimental**
-- **Type:** `string`
+- **Type:** `string[]`
- Restrict files that could be served via `/@fs/`. When `server.fsServe.strict` is set to `true`, accessing files outside this directory will result in a 403.
+ Restrict files that could be served via `/@fs/`. When `server.fsServe.strict` is set to `true`, accessing files outside this directory list will result in a 403.
Vite will search for the root of the potential workspace and use it as default. A valid workspace met the following conditions, otherwise will fallback to the [project root](/guide/#index-html-and-project-root).
@@ -508,7 +508,9 @@ createServer()
server: {
fsServe: {
// Allow serving files from one level up to the project root
- root: '..'
+ allow: [
+ '..'
+ ]
}
}
}
diff --git a/packages/playground/fs-serve/__tests__/fs-serve.spec.ts b/packages/playground/fs-serve/__tests__/fs-serve.spec.ts
new file mode 100644
index 00000000000000..af09045be48b1a
--- /dev/null
+++ b/packages/playground/fs-serve/__tests__/fs-serve.spec.ts
@@ -0,0 +1,32 @@
+import { isBuild } from '../../testUtils'
+
+const json = require('../safe.json')
+const stringified = JSON.stringify(json)
+
+if (!isBuild) {
+ test('default import', async () => {
+ expect(await page.textContent('.full')).toBe(stringified)
+ })
+
+ test('named import', async () => {
+ expect(await page.textContent('.named')).toBe(json.msg)
+ })
+
+ test('safe fetch', async () => {
+ expect(await page.textContent('.safe-fetch')).toBe(stringified)
+ expect(await page.textContent('.safe-fetch-status')).toBe('200')
+ })
+
+ test('unsafe fetch', async () => {
+ expect(await page.textContent('.unsafe-fetch')).toBe('')
+ expect(await page.textContent('.unsafe-fetch-status')).toBe('403')
+ })
+
+ test('nested entry', async () => {
+ expect(await page.textContent('.nested-entry')).toBe('foobar')
+ })
+} else {
+ test('dummy test to make jest happy', async () => {
+ // Your test suite must contain at least one test.
+ })
+}
diff --git a/packages/playground/fs-serve/entry.js b/packages/playground/fs-serve/entry.js
new file mode 100644
index 00000000000000..b133236e632f16
--- /dev/null
+++ b/packages/playground/fs-serve/entry.js
@@ -0,0 +1,5 @@
+import { msg } from './nested/foo'
+
+export const fullmsg = msg + 'bar'
+
+document.querySelector('.nested-entry').textContent = fullmsg
diff --git a/packages/playground/fs-serve/nested/foo.js b/packages/playground/fs-serve/nested/foo.js
new file mode 100644
index 00000000000000..4eeb2ac0e1dbb4
--- /dev/null
+++ b/packages/playground/fs-serve/nested/foo.js
@@ -0,0 +1 @@
+export const msg = 'foo'
diff --git a/packages/playground/fs-serve/package.json b/packages/playground/fs-serve/package.json
new file mode 100644
index 00000000000000..7f517900a229be
--- /dev/null
+++ b/packages/playground/fs-serve/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "test-fs-serve",
+ "private": true,
+ "version": "0.0.0",
+ "scripts": {
+ "dev": "vite root",
+ "build": "vite build root",
+ "debug": "node --inspect-brk ../../vite/bin/vite",
+ "serve": "vite preview"
+ }
+}
diff --git a/packages/playground/fs-serve/root/index.html b/packages/playground/fs-serve/root/index.html
new file mode 100644
index 00000000000000..1f100557ba3e5b
--- /dev/null
+++ b/packages/playground/fs-serve/root/index.html
@@ -0,0 +1,49 @@
+
Normal Import
+
+
+
+Safe Fetch
+
+
+
+Unsafe Fetch
+
+
+
+Nested Entry
+
+
+
diff --git a/packages/playground/fs-serve/root/vite.config.js b/packages/playground/fs-serve/root/vite.config.js
new file mode 100644
index 00000000000000..70356a8d82b5fb
--- /dev/null
+++ b/packages/playground/fs-serve/root/vite.config.js
@@ -0,0 +1,19 @@
+const path = require('path')
+
+/**
+ * @type {import('vite').UserConfig}
+ */
+module.exports = {
+ server: {
+ fsServe: {
+ root: __dirname,
+ strict: true
+ },
+ hmr: {
+ overlay: false
+ }
+ },
+ define: {
+ ROOT: JSON.stringify(path.dirname(__dirname).replace(/\\/g, '/'))
+ }
+}
diff --git a/packages/playground/fs-serve/safe.json b/packages/playground/fs-serve/safe.json
new file mode 100644
index 00000000000000..84f96593c10bad
--- /dev/null
+++ b/packages/playground/fs-serve/safe.json
@@ -0,0 +1,3 @@
+{
+ "msg": "safe"
+}
diff --git a/packages/playground/fs-serve/unsafe.json b/packages/playground/fs-serve/unsafe.json
new file mode 100644
index 00000000000000..9b26fb7a8959fb
--- /dev/null
+++ b/packages/playground/fs-serve/unsafe.json
@@ -0,0 +1,3 @@
+{
+ "msg": "unsafe"
+}
diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts
index a1ad68c49104bd..7783882c6a30ff 100644
--- a/packages/vite/src/node/plugins/importAnalysis.ts
+++ b/packages/vite/src/node/plugins/importAnalysis.ts
@@ -361,6 +361,11 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
)
let url = normalizedUrl
+ // record as safe modules
+ server?.moduleGraph.safeModulesPath.add(
+ cleanUrl(url).slice(4 /* '/@fs'.length */)
+ )
+
// rewrite
if (url !== specifier) {
// for optimized cjs deps, support named imports by rewriting named
diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts
index 0e06d6bc52808a..b8fe8d2e6632a6 100644
--- a/packages/vite/src/node/server/index.ts
+++ b/packages/vite/src/node/server/index.ts
@@ -33,7 +33,7 @@ import {
import { timeMiddleware } from './middlewares/time'
import { ModuleGraph, ModuleNode } from './moduleGraph'
import { Connect } from 'types/connect'
-import { createDebugger, normalizePath } from '../utils'
+import { createDebugger, ensureLeadingSlash, normalizePath } from '../utils'
import { errorMiddleware, prepareError } from './middlewares/error'
import { handleHMRUpdate, HmrOptions, handleFileAddUnlink } from './hmr'
import { openBrowser } from './openBrowser'
@@ -53,6 +53,7 @@ import { createMissingImporterRegisterFn } from '../optimizer/registerMissing'
import { printServerUrls } from '../logger'
import { resolveHostname } from '../utils'
import { searchForWorkspaceRoot } from './searchRoot'
+import { CLIENT_DIR } from '../constants'
export interface ServerOptions {
host?: string | boolean
@@ -129,28 +130,37 @@ export interface ServerOptions {
}
export interface ResolvedServerOptions extends ServerOptions {
- fsServe: Required
+ fsServe: Required>
}
export interface FileSystemServeOptions {
/**
* Strictly restrict file accessing outside of allowing paths.
*
+ * Set to `false` to disable the warning
* Default to false at this moment, will enabled by default in the future versions.
+ *
+ * @expiremental
+ * @default undefined
+ */
+ strict?: boolean | undefined
+
+ /**
+ *
* @expiremental
- * @default false
+ * @deprecated use `fsServe.allow` instead
*/
- strict?: boolean
+ root?: string
/**
- * Restrict accessing files outside this directory will result in a 403.
+ * Restrict accessing files outside the allowed directories.
*
* Accepts absolute path or a path relative to project root.
* Will try to search up for workspace root by default.
*
* @expiremental
*/
- root?: string
+ allow?: string[]
}
/**
@@ -484,7 +494,7 @@ export async function createServer(
middlewares.use(transformMiddleware(server))
// serve static files
- middlewares.use(serveRawFsMiddleware(config))
+ middlewares.use(serveRawFsMiddleware(server))
middlewares.use(serveStaticMiddleware(root, config))
// spa fallback
@@ -695,19 +705,32 @@ function createServerCloseFn(server: http.Server | null) {
})
}
+function resolvedAllowDir(root: string, dir: string): string {
+ return ensureLeadingSlash(normalizePath(path.resolve(root, dir)))
+}
+
export function resolveServerOptions(
root: string,
raw?: ServerOptions
): ResolvedServerOptions {
const server = raw || {}
- const fsServeRoot = normalizePath(
- path.resolve(root, server.fsServe?.root || searchForWorkspaceRoot(root))
- )
- // TODO: make strict by default
- const fsServeStrict = server.fsServe?.strict ?? false
+ let allowDirs = server.fsServe?.allow
+
+ if (!allowDirs) {
+ allowDirs = [server.fsServe?.root || searchForWorkspaceRoot(root)]
+ }
+ allowDirs = allowDirs.map((i) => resolvedAllowDir(root, i))
+
+ // only push client dir when vite itself is outside-of-root
+ const resolvedClientDir = resolvedAllowDir(root, CLIENT_DIR)
+ if (!allowDirs.some((i) => resolvedClientDir.startsWith(i))) {
+ allowDirs.push(resolvedClientDir)
+ }
+
server.fsServe = {
- root: fsServeRoot,
- strict: fsServeStrict
+ // TODO: make strict by default
+ strict: server.fsServe?.strict,
+ allow: allowDirs
}
return server as ResolvedServerOptions
}
diff --git a/packages/vite/src/node/server/middlewares/error.ts b/packages/vite/src/node/server/middlewares/error.ts
index 3bc6eca98b2f71..5bb4e8ae7253bb 100644
--- a/packages/vite/src/node/server/middlewares/error.ts
+++ b/packages/vite/src/node/server/middlewares/error.ts
@@ -75,7 +75,7 @@ export function errorMiddleware(
}
export class AccessRestrictedError extends Error {
- constructor(msg: string, public url: string, public serveRoot: string) {
+ constructor(msg: string) {
super(msg)
}
}
diff --git a/packages/vite/src/node/server/middlewares/static.ts b/packages/vite/src/node/server/middlewares/static.ts
index 5c01c9101a25b0..f386edc581a15b 100644
--- a/packages/vite/src/node/server/middlewares/static.ts
+++ b/packages/vite/src/node/server/middlewares/static.ts
@@ -1,11 +1,16 @@
import path from 'path'
import sirv, { Options } from 'sirv'
import { Connect } from 'types/connect'
-import { FileSystemServeOptions } from '..'
-import { normalizePath, ResolvedConfig } from '../..'
+import { normalizePath, ResolvedConfig, ViteDevServer } from '../..'
import { FS_PREFIX } from '../../constants'
-import { Logger } from '../../logger'
-import { cleanUrl, fsPathFromId, isImportRequest, isWindows } from '../../utils'
+import {
+ cleanUrl,
+ ensureLeadingSlash,
+ fsPathFromId,
+ isImportRequest,
+ isWindows,
+ slash
+} from '../../utils'
import { AccessRestrictedError } from './error'
const sirvOptions: Options = {
@@ -77,7 +82,7 @@ export function serveStaticMiddleware(
}
export function serveRawFsMiddleware(
- config: ResolvedConfig
+ server: ViteDevServer
): Connect.NextHandleFunction {
const serveFromRoot = sirv('/', sirvOptions)
@@ -90,12 +95,7 @@ export function serveRawFsMiddleware(
// searching based from fs root.
if (url.startsWith(FS_PREFIX)) {
// restrict files outside of `fsServe.root`
- ensureServingAccess(
- path.resolve(fsPathFromId(url)),
- config.server.fsServe,
- config.logger
- )
-
+ ensureServingAccess(slash(path.resolve(fsPathFromId(url))), server)
url = url.slice(FS_PREFIX.length)
if (isWindows) url = url.replace(/^[A-Z]:/i, '')
@@ -107,40 +107,42 @@ export function serveRawFsMiddleware(
}
}
-export function isFileAccessAllowed(
+export function isFileServingAllowed(
url: string,
- { root, strict }: Required
+ server: ViteDevServer
): boolean {
- return !strict || normalizePath(url).startsWith(root + path.posix.sep)
+ // explicitly disabled
+ if (server.config.server.fsServe.strict === false) return true
+
+ const file = ensureLeadingSlash(normalizePath(cleanUrl(url)))
+
+ if (server.moduleGraph.safeModulesPath.has(file)) return true
+
+ if (server.config.server.fsServe.allow.some((i) => file.startsWith(i + '/')))
+ return true
+
+ if (!server.config.server.fsServe.strict) {
+ server.config.logger.warnOnce(`Unrestricted file system access to "${url}"`)
+ server.config.logger.warnOnce(
+ `For security concerns, accessing files outside of serving allow list will ` +
+ `be restricted by default in the future version of Vite. ` +
+ `Refer to https://vitejs.dev/config/#server-fsserve-allow for more details.`
+ )
+ return true
+ }
+
+ return false
}
-export function ensureServingAccess(
- url: string,
- serveOptions: Required,
- logger: Logger
-): void {
- const { strict, root } = serveOptions
- // TODO: early return, should remove once we polished the restriction logic
- if (!strict) return
-
- if (!isFileAccessAllowed(url, serveOptions)) {
- const normalizedUrl = normalizePath(url)
- if (strict) {
- throw new AccessRestrictedError(
- `The request url "${normalizedUrl}" is outside of vite dev server root "${root}".
- For security concerns, accessing files outside of workspace root is restricted since Vite v2.3.x.
- Refer to docs https://vitejs.dev/config/#server-fsserve-root for configurations and more details.`,
- normalizedUrl,
- root
- )
- } else {
- // TODO: warn for potential unrestricted access
- logger.warnOnce(
- `For security concerns, accessing files outside of workspace root will ` +
- `be restricted by default in the future version of Vite. ` +
- `Refer to [] for more`
- )
- logger.warnOnce(`Unrestricted file system access to "${normalizedUrl}"`)
- }
+export function ensureServingAccess(url: string, server: ViteDevServer): void {
+ if (!isFileServingAllowed(url, server)) {
+ const allow = server.config.server.fsServe.allow
+ throw new AccessRestrictedError(
+ `The request url "${url}" is outside of Vite serving allow list:
+
+${allow.map((i) => `- ${i}`).join('\n')}
+
+Refer to docs https://vitejs.dev/config/#server-fsserve-allow for configurations and more details.`
+ )
}
}
diff --git a/packages/vite/src/node/server/moduleGraph.ts b/packages/vite/src/node/server/moduleGraph.ts
index c5c94b749c9add..465fb4c93adeca 100644
--- a/packages/vite/src/node/server/moduleGraph.ts
+++ b/packages/vite/src/node/server/moduleGraph.ts
@@ -50,6 +50,7 @@ export class ModuleGraph {
idToModuleMap = new Map()
// a single file may corresponds to multiple modules with different queries
fileToModulesMap = new Map>()
+ safeModulesPath = new Set()
container: PluginContainer
constructor(container: PluginContainer) {
diff --git a/packages/vite/src/node/server/transformRequest.ts b/packages/vite/src/node/server/transformRequest.ts
index 5e506bd36299bb..b04f844200599e 100644
--- a/packages/vite/src/node/server/transformRequest.ts
+++ b/packages/vite/src/node/server/transformRequest.ts
@@ -16,7 +16,7 @@ import {
import { checkPublicFile } from '../plugins/asset'
import { ssrTransform } from '../ssr/ssrTransform'
import { injectSourcesContent } from './sourcemap'
-import { isFileAccessAllowed } from './middlewares/static'
+import { isFileServingAllowed } from './middlewares/static'
const debugLoad = createDebugger('vite:load')
const debugTransform = createDebugger('vite:transform')
@@ -37,9 +37,11 @@ export interface TransformOptions {
export async function transformRequest(
url: string,
- { config, pluginContainer, moduleGraph, watcher }: ViteDevServer,
+ server: ViteDevServer,
options: TransformOptions = {}
): Promise {
+ const { config, pluginContainer, moduleGraph, watcher } = server
+
url = removeTimestampQuery(url)
const { root, logger } = config
const prettyUrl = isDebug ? prettifyUrl(url, root) : ''
@@ -75,7 +77,7 @@ export async function transformRequest(
// as string
// only try the fallback if access is allowed, skip for out of root url
// like /service-worker.js or /api/users
- if (options.ssr || isFileAccessAllowed(file, config.server.fsServe)) {
+ if (options.ssr || isFileServingAllowed(file, server)) {
try {
code = await fs.readFile(file, 'utf-8')
isDebug && debugLoad(`${timeFrom(loadStart)} [fs] ${prettyUrl}`)
diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts
index 15c86b715a81d1..18ff1f1d51b88d 100644
--- a/packages/vite/src/node/utils.ts
+++ b/packages/vite/src/node/utils.ts
@@ -361,6 +361,10 @@ export function copyDir(srcDir: string, destDir: string): void {
}
}
+export function ensureLeadingSlash(path: string): string {
+ return !path.startsWith('/') ? '/' + path : path
+}
+
export function ensureWatchedFile(
watcher: FSWatcher,
file: string | null,
diff --git a/scripts/jestPerTestSetup.ts b/scripts/jestPerTestSetup.ts
index f240ac430e056d..601cd02a1e2f55 100644
--- a/scripts/jestPerTestSetup.ts
+++ b/scripts/jestPerTestSetup.ts
@@ -24,6 +24,7 @@ declare global {
let server: ViteDevServer | http.Server
let tempDir: string
+let rootDir: string
let err: Error
const logs = ((global as any).browserLogs = [])
@@ -60,16 +61,20 @@ beforeAll(async () => {
}
})
+ // when `root` dir is present, use it as vite's root
+ let testCustomRoot = resolve(tempDir, 'root')
+ rootDir = fs.existsSync(testCustomRoot) ? testCustomRoot : tempDir
+
const testCustomServe = resolve(dirname(testPath), 'serve.js')
if (fs.existsSync(testCustomServe)) {
// test has custom server configuration.
const { serve } = require(testCustomServe)
- server = await serve(tempDir, isBuildTest)
+ server = await serve(rootDir, isBuildTest)
return
}
const options: UserConfig = {
- root: tempDir,
+ root: rootDir,
logLevel: 'silent',
server: {
watch: {
@@ -128,7 +133,7 @@ afterAll(async () => {
function startStaticServer(): Promise {
// check if the test project has base config
- const configFile = resolve(tempDir, 'vite.config.js')
+ const configFile = resolve(rootDir, 'vite.config.js')
let config: UserConfig
try {
config = require(configFile)
@@ -142,7 +147,7 @@ function startStaticServer(): Promise {
}
// start static file server
- const serve = sirv(resolve(tempDir, 'dist'))
+ const serve = sirv(resolve(rootDir, 'dist'))
const httpServer = (server = http.createServer((req, res) => {
if (req.url === '/ping') {
res.statusCode = 200