diff --git a/docs/config/index.md b/docs/config/index.md index 79ec6c3a8c75c1..f234e82930b587 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -430,6 +430,17 @@ export default async ({ command, mode }) => { File system watcher options to pass on to [chokidar](https://github.com/paulmillr/chokidar#api). +### server.fsServe.root + +- **Type:** `string` + + Restrict files that could be served via `/@fs/`. Accessing files outside this directory 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). + - contains `workspaces` field in `package.json` + - contains one of the following file + - `pnpm-workspace.yaml` + ## Build Options ### build.target diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 135dca6f6b75e1..6199988279e2c4 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -12,6 +12,7 @@ export type { ViteDevServer, ServerOptions, CorsOptions, + FileSystemServeOptions, CorsOrigin, ServerHook } from './server' diff --git a/packages/vite/src/node/server/__tests__/fixtures/none/nested/package.json b/packages/vite/src/node/server/__tests__/fixtures/none/nested/package.json new file mode 100644 index 00000000000000..352055cdf83423 --- /dev/null +++ b/packages/vite/src/node/server/__tests__/fixtures/none/nested/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/packages/vite/src/node/server/__tests__/fixtures/pnpm/nested/package.json b/packages/vite/src/node/server/__tests__/fixtures/pnpm/nested/package.json new file mode 100644 index 00000000000000..352055cdf83423 --- /dev/null +++ b/packages/vite/src/node/server/__tests__/fixtures/pnpm/nested/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/packages/vite/src/node/server/__tests__/fixtures/pnpm/package.json b/packages/vite/src/node/server/__tests__/fixtures/pnpm/package.json new file mode 100644 index 00000000000000..352055cdf83423 --- /dev/null +++ b/packages/vite/src/node/server/__tests__/fixtures/pnpm/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/packages/vite/src/node/server/__tests__/fixtures/pnpm/pnpm-workspace.yaml b/packages/vite/src/node/server/__tests__/fixtures/pnpm/pnpm-workspace.yaml new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/server/__tests__/fixtures/yarn/nested/package.json b/packages/vite/src/node/server/__tests__/fixtures/yarn/nested/package.json new file mode 100644 index 00000000000000..352055cdf83423 --- /dev/null +++ b/packages/vite/src/node/server/__tests__/fixtures/yarn/nested/package.json @@ -0,0 +1,3 @@ +{ + "private": true +} diff --git a/packages/vite/src/node/server/__tests__/fixtures/yarn/package.json b/packages/vite/src/node/server/__tests__/fixtures/yarn/package.json new file mode 100644 index 00000000000000..ac514d81ca831e --- /dev/null +++ b/packages/vite/src/node/server/__tests__/fixtures/yarn/package.json @@ -0,0 +1,6 @@ +{ + "private": true, + "workspaces": [ + "nested" + ] +} diff --git a/packages/vite/src/node/server/__tests__/search-root.spec.ts b/packages/vite/src/node/server/__tests__/search-root.spec.ts new file mode 100644 index 00000000000000..dd64ac00c43ab8 --- /dev/null +++ b/packages/vite/src/node/server/__tests__/search-root.spec.ts @@ -0,0 +1,31 @@ +import { searchForWorkspaceRoot } from '../searchRoot' +import { resolve } from 'path' + +describe('searchForWorkspaceRoot', () => { + test('pnpm', () => { + const resolved = searchForWorkspaceRoot( + resolve(__dirname, 'fixtures/pnpm/nested') + ) + expect(resolved).toBe(resolve(__dirname, 'fixtures/pnpm')) + }) + + test('yarn', () => { + const resolved = searchForWorkspaceRoot( + resolve(__dirname, 'fixtures/yarn/nested') + ) + expect(resolved).toBe(resolve(__dirname, 'fixtures/yarn')) + }) + + test('yarn at root', () => { + const resolved = searchForWorkspaceRoot(resolve(__dirname, 'fixtures/yarn')) + expect(resolved).toBe(resolve(__dirname, 'fixtures/yarn')) + }) + + test('none', () => { + const resolved = searchForWorkspaceRoot( + resolve(__dirname, 'fixtures/none/nested') + ) + // resolved to vite repo's root + expect(resolved).toBe(resolve(__dirname, '../../../../../..')) + }) +}) diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index e782211f4269f8..3970d78d9f7e17 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -120,6 +120,20 @@ export interface ServerOptions { * Should start and end with the `/` character */ base?: string + /** + * Options for files served via '/\@fs/'. + */ + fsServe?: FileSystemServeOptions +} + +export interface FileSystemServeOptions { + /** + * Restrict accessing files outside this directory will result in a 403. + * + * Accepts absolute path or a path relative to project root. + * Will try to search up for workspace root by default. + */ + root?: string } /** @@ -443,7 +457,7 @@ export async function createServer( middlewares.use(transformMiddleware(server)) // serve static files - middlewares.use(serveRawFsMiddleware()) + middlewares.use(serveRawFsMiddleware(config)) middlewares.use(serveStaticMiddleware(root, config)) // spa fallback diff --git a/packages/vite/src/node/server/middlewares/static.ts b/packages/vite/src/node/server/middlewares/static.ts index d47de58adaab52..f6d47fa71e7ad0 100644 --- a/packages/vite/src/node/server/middlewares/static.ts +++ b/packages/vite/src/node/server/middlewares/static.ts @@ -4,7 +4,8 @@ import sirv, { Options } from 'sirv' import { Connect } from 'types/connect' import { ResolvedConfig } from '../..' import { FS_PREFIX } from '../../constants' -import { cleanUrl, isImportRequest } from '../../utils' +import { cleanUrl, fsPathFromId, isImportRequest } from '../../utils' +import { searchForWorkspaceRoot } from '../searchRoot' const sirvOptions: Options = { dev: true, @@ -74,9 +75,15 @@ export function serveStaticMiddleware( } } -export function serveRawFsMiddleware(): Connect.NextHandleFunction { +export function serveRawFsMiddleware( + config: ResolvedConfig +): Connect.NextHandleFunction { const isWin = os.platform() === 'win32' const serveFromRoot = sirv('/', sirvOptions) + const serveRoot = path.resolve( + config.root, + config.server?.fsServe?.root || searchForWorkspaceRoot(config.root) + ) // Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...` return function viteServeRawFsMiddleware(req, res, next) { @@ -86,6 +93,14 @@ export function serveRawFsMiddleware(): Connect.NextHandleFunction { // the paths are rewritten to `/@fs/` prefixed paths and must be served by // searching based from fs root. if (url.startsWith(FS_PREFIX)) { + // restrict files outside of `fsServe.root` + if (!path.resolve(fsPathFromId(url)).startsWith(serveRoot + path.sep)) { + res.statusCode = 403 + res.write(renderFsRestrictedHTML(serveRoot)) + res.end() + return + } + url = url.slice(FS_PREFIX.length) if (isWin) url = url.replace(/^[A-Z]:/i, '') @@ -96,3 +111,29 @@ export function serveRawFsMiddleware(): Connect.NextHandleFunction { } } } + +function renderFsRestrictedHTML(root: string) { + // to have syntax highlighting and autocompletion in IDE + const html = String.raw + return html` + +

403 Restricted

+

+ For security concerns, accessing files outside of workspace root + (${root}) is restricted since Vite v2.3.x +

+

+ Refer to docs + + https://vitejs.dev/config/#server-fsserveroot + + for configurations and more details. +

+ + + ` +} diff --git a/packages/vite/src/node/server/searchRoot.ts b/packages/vite/src/node/server/searchRoot.ts new file mode 100644 index 00000000000000..ea868c661dc3cd --- /dev/null +++ b/packages/vite/src/node/server/searchRoot.ts @@ -0,0 +1,49 @@ +import fs from 'fs' +import { dirname } from 'path' +import { join } from 'path' + +// https://github.com/vitejs/vite/issues/2820#issuecomment-812495079 +const ROOT_FILES = [ + // '.git', + + // https://pnpm.js.org/workspaces/ + 'pnpm-workspace.yaml' + + // https://rushjs.io/pages/advanced/config_files/ + // 'rush.json', + + // https://nx.dev/latest/react/getting-started/nx-setup + // 'workspace.json', + // 'nx.json' +] + +// npm: https://docs.npmjs.com/cli/v7/using-npm/workspaces#installing-workspaces +// yarn: https://classic.yarnpkg.com/en/docs/workspaces/#toc-how-to-use-it +function hasWorkspacePackageJSON(root: string): boolean { + const path = join(root, 'package.json') + try { + fs.accessSync(path, fs.constants.R_OK) + } catch { + return false + } + const content = JSON.parse(fs.readFileSync(path, 'utf-8')) || {} + return !!content.workspaces +} + +function hasRootFile(root: string): boolean { + return ROOT_FILES.some((file) => fs.existsSync(join(root, file))) +} + +export function searchForWorkspaceRoot( + current: string, + root = current +): string { + if (hasRootFile(current)) return current + if (hasWorkspacePackageJSON(current)) return current + + const dir = dirname(current) + // reach the fs root + if (!dir || dir === current) return root + + return searchForWorkspaceRoot(dir, root) +}