From c45a02f0279103be922a2d24b8cd05141e0ff165 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Sat, 26 Jun 2021 02:06:08 +0800 Subject: [PATCH] feat: fs-serve import graph awareness (#3784) Co-authored-by: Shinigami --- docs/config/index.md | 10 ++- .../fs-serve/__tests__/fs-serve.spec.ts | 32 +++++++ packages/playground/fs-serve/entry.js | 5 ++ packages/playground/fs-serve/nested/foo.js | 1 + packages/playground/fs-serve/package.json | 11 +++ packages/playground/fs-serve/root/index.html | 49 +++++++++++ .../playground/fs-serve/root/vite.config.js | 19 ++++ packages/playground/fs-serve/safe.json | 3 + packages/playground/fs-serve/unsafe.json | 3 + .../vite/src/node/plugins/importAnalysis.ts | 5 ++ packages/vite/src/node/server/index.ts | 51 ++++++++--- .../vite/src/node/server/middlewares/error.ts | 2 +- .../src/node/server/middlewares/static.ts | 86 ++++++++++--------- packages/vite/src/node/server/moduleGraph.ts | 1 + .../vite/src/node/server/transformRequest.ts | 8 +- packages/vite/src/node/utils.ts | 4 + scripts/jestPerTestSetup.ts | 13 ++- 17 files changed, 235 insertions(+), 68 deletions(-) create mode 100644 packages/playground/fs-serve/__tests__/fs-serve.spec.ts create mode 100644 packages/playground/fs-serve/entry.js create mode 100644 packages/playground/fs-serve/nested/foo.js create mode 100644 packages/playground/fs-serve/package.json create mode 100644 packages/playground/fs-serve/root/index.html create mode 100644 packages/playground/fs-serve/root/vite.config.js create mode 100644 packages/playground/fs-serve/safe.json create mode 100644 packages/playground/fs-serve/unsafe.json diff --git a/docs/config/index.md b/docs/config/index.md index 67365b2ec96504..92bfe50f650c48 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -496,12 +496,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). @@ -516,7 +516,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 4c5c66ce7c52b3..f3b8396dcbf338 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 8d10ac519a6f3d..ceefe975959f89 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[]
 }
 
 /**
@@ -487,7 +497,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
@@ -698,19 +708,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 1c0860b7fe4898..d29c31aae6bcc0 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 ceaff9799a38ad..bd6666081d0194 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 d578124341b7ad..620309694f7893 100644
--- a/scripts/jestPerTestSetup.ts
+++ b/scripts/jestPerTestSetup.ts
@@ -34,6 +34,7 @@ declare global {
 
 let server: ViteDevServer | http.Server
 let tempDir: string
+let rootDir: string
 let err: Error
 
 const logs = ((global as any).browserLogs = [])
@@ -70,16 +71,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: {
@@ -153,7 +158,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)
@@ -167,7 +172,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