为什么 Vite 的请求有时候是相对路径,有时候是 /@fs/ + 绝对路径?

在开发项目时,我发现有时候请求资源的路径是相对路径,有时候是 /@fs/ 开头的绝对路径,这是为什么呢?

Vite 的请求路径种类

  • 相对路径,相对于根目录的路径。如:http://localhost/src/main.ts
  • /@fs/ 开头 + 绝对路径,例如:http://localhost/@fs/app/vite/packages/vite/dist/client/env.mjs

其中 /app/vite/packages/vite/dist/client/env.mjs 为绝对路径,可以直接访问文件。

这两种不同路径种类的使用场景,其实很简单,就是看要访问的文件,是否在项目根目录中?

如果文件在 Vite root 根目录中,则直接使用相对路径

但如果在 Vite root 根目录外,相对路径就需要使用 ../ 这种,这种形式不能马上看出文件的位置,因此直接使用绝对路径更好,但是需要跟相对路径做区分,因此用 /@fs/ 开头 + 绝对路径的方式

这里一个两种请求种类都有的项目,在线运行地址

image-20230105200103080

该项目设置了 root 为 /root 文件夹,因此 public 文件夹就在 root 外了,因此访问 /public/vite.svg 就会用 /@fs/ + 绝对路径的方式访问了。

image-20230105200310383

在开发 monorepo 项目的时候,经过就会遇到模块是在 Vite root 目录外的。

源码解析

Vite 在转换一个文件时,会将它的 import 的模块的路径标准化,例如:

我们访问 http://localhost/src/main.ts 时,Vite 会转换 main.ts 的代码,转换前和转换后的结果如下:

// 转换前的源代码
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

// 转换后的代码
import { createApp } from "/node_modules/.vite/deps/vue.js?v=3386baa1";
import "/src/style.css";
import App from "/src/App.vue";
createApp(App).mount("#app");

可以看到 import 的模块路径被改变了,路径被标准化为基于根目录的相对路径(如果在 Vite 根目录外,则用 /@fs/

我们再来看看路径标准化的相关源码(有节选):

// 标准化 url,例如: ./App.vue -> /src/App.vue
const normalizeUrl = async (
    url: string,
    pos: number,
    forceSkipImportAnalysis: boolean = false,
): Promise<[string, string]> => {
   
    // 解析 url,resolved.id 就是当前文件的绝对路径
    const resolved = await this.resolve(url, importerFile)

    // 通过绝对路径判断
    // 如果路径在 Vite 根目录内,就用相对路径
    if (resolved.id.startsWith(root + '/')) {
        // 去掉 root 根目录的前缀,就是相对路径了
        url = resolved.id.slice(root.length)
    } else if (
        // 如果文件存在
        fs.existsSync(cleanUrl(resolved.id))
    ) {
        // 在绝对路径前,拼接 /@fs/
        url = path.posix.join('/@fs/', resolved.id)
    } else {
        // 文件不存在,这可能是一个 Vite 的虚拟模块
        // 例如:plugin-vue:export-helper,不是真实存在的模块,但在 Vue 插件中会被转换成代码
        // 这个可以不管,跟本文无关
        url = resolved.id
    }

    return [url, resolved.id]
}

从这里可以看出,相对路径和绝对路径的使用场景,就是根据文件是否在 root 目录中来决定的

到这里,其实已经解决了我们的问题了,但我们可以想得更深:

既然可以绝对路径访问文件,那输入另一个的路径,是不是就能访问到别的文件了? 这样有安全问题了啊

安全问题

支持绝对路径访问文件是有风险的,坏人可以通过输入其他路径,获取到整个机器的所有文件了(只要能知道路径),可能那些文件里面就有敏感信息,因此非常危险。

为了避免产生安全问题,Vite 限制了 Dev Server 的文件访问范围,让其只能访问到部分项目用到的文件,这就是 Vite 的文件安全访问策略。

如果访问了允许范围外的文件,Vite 就会返回以下错误页面。

image-20230105210032828

我们通过 localhost 访问的,别人用 localhost + 绝对路径也是访问它自己的机器,这应该没什么安全问题?

如果是本地开发,使用 localhost 访问,那的确没有什么安全问题。Vite 的 server.host 默认值是 localhost,因此 Dev Server 也只会绑定到 localhost,别人是没办法访问的。

但其实还有另一种开发模式 —— 远程开发。代码是写在服务器上的,然后 Vite 也是跑在服务器上的,然后通过网络去访问页面。这种情况下,就要远程访问 Dev Server,就会有安全问题,要防止别人通过绝对路径,访问到服务器上的其他数据了。

有关远程开发细节,可以查看我的文章《JetBrains 远程开发的使用和心得》

Vite 文件安全访问策略

我们直接从源码看看,Vite 是如何判断是否有允许访问的:

// 函数返回 true 就是允许访问
function ensureServingAccess(
  url: string,
  server: ViteDevServer,
  res: ServerResponse,
  next: Connect.NextFunction,
): boolean {
  // 判断是否允许访问
  if (isFileServingAllowed(url, server)) {
    return true
  }
  
  // 如果不允许访问,但文件又是存在的,就会返回 403 的页面
  if (isFileReadable(cleanUrl(url))) {
    const urlMessage = `The request url "${url}" is outside of Vite serving allow list.`
    
    // 当前允许访问的路径
    const hintMessage = `
${server.config.server.fs.allow.map((i) => `- ${i}`).join('\n')}

Refer to docs https://vitejs.dev/config/server-options.html#server-fs-allow for configurations and more details.`

    server.config.logger.error(urlMessage)
    server.config.logger.warnOnce(hintMessage + '\n')
    res.statusCode = 403
    
    // 响应请求,响应的是 403 页面。
    res.write(renderRestrictedErrorHTML(urlMessage + '\n' + hintMessage))
    res.end()
  } else {
    // 如果文件不存在,那就不管了,别的 server 中间件会返回 404 HTTP 状态码
    next()
  }
  return false
}

从上述代码中可以知道,我们上一小节看到的 Vite 403 错误页面,就是这里返回的

是否允许访问的核心判断逻辑在 isFileServingAllowed

export function isFileServingAllowed(
  url: string,
  server: ViteDevServer,
): boolean {
  // 如果不执行不严格的 fs 策略,就允许访问。
  if (!server.config.server.fs.strict) return true

  //  标准化为绝对路径
  const file = fsPathFromUrl(url)

  if (server._fsDenyGlob(file)) return false

  if (server.moduleGraph.safeModulesPath.has(file)) return true

  if (server.config.server.fs.allow.some((dir) => isParentDirectory(dir, file)))
    return true

  return false
}

主要有几个判断:

  1. 是否执行了严格的 fs 策略,对应的 Vite 配置是 server.fs.strict,默认是 true
  2. 是否命中 deny 拒绝名单,对应的配置是 server.fs.deny,默认为 ['.env', '.env.*', '*.{pem,crt}']
  3. 是否为项目中使用到的文件server.moduleGraph.safeModulesPath 是一个 Set<string>,它记录了所有项目中被 import 的文件的绝对路径。因此,如果项目中使用到了在 root 根目录外的文件,也是能被正常访问到的。但没有使用的文件就不行了。
  4. 是否命中 allow 名单。对应的配置是 server.fs.allow,如果不配置,Vite 将当前目录加入到 allow,如果是 monorepo 项目,还会将 workspaces 的目录加入到 allow

如果不被允许,Vite 就会返回 403 页面,从而保证了安全性

为什么不直接用 url 判断,而是要先将 url 标准化为绝对路径再判断?

因为需要确保安全性。假如通过 url 是否是 root 开头,来判断是否允许访问,是有问题的。

假如 Vite 的 root 为 /root,那坏人可以 /@fs/root/../other/password.txt,去绕过这个策略,这就会出现安全漏洞了。

总结

本文以一个开发中的一个小问题作为开头,提出疑问:为什么 Vite 的请求有时候是相对路径,有时候是 /@fs/ 开头 + 绝对路径?

然后逐步进行解答,最终得出结论:在 root 外的会用 /@fs/ 进行访问

问题虽然很简单,但还可以再一步深入,提出了潜在安全问题,并探索 Vite 是如何解决的,最终还从源码中了解到了 Vite 文件安全访问策略

如果这篇文章对您有所帮助,可以点赞加收藏👍,您的鼓励是我创作路上的最大的动力。也可以关注我的公众号订阅后续的文章:Candy 的修仙秘籍(点击可跳转)

关联阅读

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容