人们在生产服务器的安全性上投入了大量的时间和精力。然而,运行在开发者机器上的本地服务器的安全性往往被忽视。事实上,在今年年初,我调查、报告并修复了几个前端开发工具中的漏洞。本文基于这些经验和研究,解释了具体的攻击方法以及防范这些攻击的对策。如果您对漏洞调查和响应过程感兴趣,请同时阅读上一篇文章。
背景知识
首先,让我们了解理解后面描述的攻击方法所必需的背景知识。如果您已经熟悉某些主题,请随意跳过。
URL 及其组成
在本文中,URL 的组成部分按照 URL 标准 进行如下定义。
“协议(Scheme)”
- 协议的名称
- 可以通过
new URL(inputUrl).protocol获取 - 示例:
http:、https:
“主机名(Hostname)”
- 域名字符串或 IP 地址
- 可以通过
new URL(inputUrl).hostname获取 - 示例:
www.example.com、127.0.0.1、[::1]
“端口(Port)”
- 端口号
- 可选
- 可以通过
new URL(inputUrl).port获取 - 示例:
8080
“主机(Host)”
- “主机名” 和 “端口” 的组合
- 可以通过
new URL(inputUrl).host获取 - 示例:
www.example.com:8080、192.168.0.1
“源(Origin)”
- “协议”、“主机” 和 “端口” 的组合
- 可以通过
new URL(inputUrl).origin获取 - 示例:
http://www.example.com:8080、https://192.168.0.1
DNS(域名系统)
这是一个将域名字符串转换为 IP 地址的系统。
Host 头 / :Authority 伪头
Host 头指示请求试图访问的站点。它在 HTTP/1.x 中使用。在 HTTP/2 及更高版本中,使用 :Authority 伪头代替。这些头指定 URL 的 “主机” 部分。
仅通过使用 DNS 从 URL 的 “主机名” 获取 IP 地址就可以向服务器发送请求。当同一 IP 地址上托管多个站点时,这些头的存在是为了指定要访问的站点。
Origin 头
Origin 头指示请求是从哪个站点发送的。此头指定 URL 的 “源”。它由浏览器控制,不能被站点上的 JavaScript 操作。
例如,如果以下 HTML 从 http://example1.sapphi.red 提供,那么对 http://example2.sapphi.red/foo.js 的请求的 Origin 头将是 http://example1.sapphi.red,而不是 http://example2.sapphi.red。
<html>
<head>
<script src="http://example2.sapphi.red/foo.js"></script>
</head>
</html>同源策略(Same-Origin Policy, SOP)
同源策略(Same-Origin Policy, SOP)是一种浏览器安全机制,限制从一个 “源” 加载的文档或脚本如何与来自另一个 “源” 的资源交互。
例如,从 http://example.com 到 http://example.sapphi.red 的 fetch 请求默认会被浏览器阻止,因为 “源” 不同。
这种机制防止来自其他站点的意外请求,阻止它们在未经许可的情况下读取您的信息或通过 POST 请求写入数据。
攻击向量
现在,让我们解释我发现的每个漏洞的攻击方法。所有这些攻击都旨在从外部站点向本地服务器发送请求,让本地服务器接受它,并接收有用的响应。对于前端开发服务器,“有用的响应”是指揭示私有服务器端源代码或预发布功能的响应。此外,虽然这是不好的做法,但私有源代码可能在预期在打包过程中被剥离的地方包含敏感信息,例如注释。
本地服务器面临的主要攻击向量包括:
- 过于宽松的 CORS 设置
- 使用 XSSI 和原型污染
- 使用 CSWSH
- 使用 DNS 重绑定
1. 过于宽松的 CORS 设置
来自外部站点的请求通常会被浏览器的 SOP 阻止,要么阻止请求本身,要么阻止读取响应。然而,如果设置了 Access-Control-Allow-Origin 头来明确允许跨域请求,情况就不是这样了。以这种方式利用来自不同”源”的资源被称为跨域资源共享(Cross-Origin Resource Sharing, CORS)。许多开发服务器配置了 Access-Control-Allow-Origin: *,这允许来自所有”源”的请求。
**具体攻击步骤如下:
- 攻击者提供恶意站点(例如,
http://malicious.example.com) - 用户访问执行由恶意站点提供的 JavaScript 的站点。这不仅限于顶级导航;还包括通过 iframe 或外部站点上的脚本进行嵌入
- 提供的 JavaScript 执行
fetch('http://127.0.0.1:3000/main.js'),并将响应内容发送给攻击者 - 攻击者接收到
http://127.0.0.1:3000/main.js的内容
在开发服务器的情况下,main.js 是一个转译文件,但通常源代码存在于该文件中或在 main.js.map 中,允许检索原始源代码。
相关漏洞:
- Vite:GHSA-vg6x-rcgg-rjx6 / CVE-2025-24010([1]:宽松的默认 CORS 设置)
- parcel:修复 PR
- esbuild:GHSA-67mh-4wv8-2f99
- Next.js:GHSA-3h52-269p-cp9r / CVE-2025-48068(允许使用
Access-Control-Allow-Origin: *的源映射,尽管描述中未提及) - Nuxt:GHSA-2452-6xj8-jh47 / CVE-2025-24360
2. 使用 XSSI 和修改原型
浏览器中有两种类型的脚本。一种是模块脚本,可以使用 import 和 export 语句,通过 <script> 标签加载时需要 type=module 属性。另一种是经典脚本,已经使用了很长时间。
由于历史原因,通过 <script> 标签加载经典脚本时不适用 SOP。这意味着即使没有 CORS 配置,也可以从外部站点加载脚本。利用这一点的攻击被称为跨站脚本包含(Cross-Site Script Inclusion, XSSI)。需要明确的是,如前所述,直接使用 fetch 获取脚本内容默认会被 SOP 阻止。
对于以经典脚本形式输出包并且格式允许检索模块列表的打包器来说,这成为一个问题。例如,在 Webpack 中,设置 output.iife: false 可以在全局变量(如 window.webpackChunkSomething)中暴露模块列表。这允许使用如下代码检索所有模块名称及其转换后的代码:
const script = document.createElement('script')
script.src = 'http://localhost:3000/path/to/the/entrypoint.js'
script.addEventListener('load', () => {
for (const page in window.webpackChunkSomething) {
const moduleList = window.webpackChunkSomething[page][1]
for (const key in moduleList) {
console.log('moduleName', key)
console.log('moduleCode', moduleList[key].toString()) // Function::toString
}
}
})
document.head.appendChild(script)即使没有这样的设置,如果模块列表在内部保存,有时也可以通过修改原型来检索。例如,使用 Webpack 的默认设置,可以通过覆盖 Array.prototype.forEach 来获取模块列表。Webpack 的输出包含这样的函数:
function __webpack_require__(moduleId) {
var cachedModule = __webpack_module_cache__[moduleId]
if (cachedModule !== undefined) {
return cachedModule.exports
}
var module = (__webpack_module_cache__[moduleId] = { exports: {} })
var execOptions = {
id: moduleId,
module: module,
factory: __webpack_modules__[moduleId],
require: __webpack_require__,
}
// 这个调用很重要!
__webpack_require__.i.forEach(function (handler) {
handler(execOptions)
})
module = execOptions.module
execOptions.factory.call(
module.exports,
module,
module.exports,
execOptions.require,
)
return module.exports
}通过包装 __webpack_require__.i.forEach 的回调,您可以获取 execOptions,然后从 execOptions.require.m 检索模块列表。具体来说,您可以使用如下代码获取:
let moduleList
const onHandlerSet = (handler) => {
moduleList = handler.require.m
}
const originalArrayForEach = Array.prototype.forEach
Array.prototype.forEach = function forEach(callback, thisArg) {
callback((handler) => {
onHandlerSet(handler)
})
originalArrayForEach.call(this, callback, thisArg)
Array.prototype.forEach = originalArrayForEach
}
// 之后,执行前面提到的脚本插入代码此外,如果由于 devtool: 'eval' 等设置而在模块中嵌入了源映射,则可以检索原始文件的内容。
具体攻击步骤如下:
- 攻击者提供恶意站点(例如,
http://malicious.example.com) - 用户访问该站点。这包括顶级导航以及通过 iframe 嵌入
- 提供的 JavaScript 执行如上所示的脚本,并将结果发送给攻击者
- 攻击者接收到模块列表的内容
相关漏洞:
- webpack-dev-server:GHSA-4v9v-hfq4-rm2v / CVE-2025-30359
- Next.js:GHSA-3h52-269p-cp9r / CVE-2025-48068(
allowedDevOrigins必要性的原因之一) - Nuxt:GHSA-4gf7-ff8x-hq99 / CVE-2025-24361
3. 使用 CSWSH
与经典脚本类似,WebSocket 连接不受 SOP 限制。因此,默认情况下,来自其他站点的连接是可能的。利用这一点的攻击被称为跨站 WebSocket 劫持(Cross-Site WebSocket Hijacking, CSWSH)。
大多数打包器不会直接通过 WebSocket 发送源代码,因此仅此漏洞无法在不与其他漏洞结合的情况下检索有用信息。然而,由于 Turbopack 确实通过 WebSocket 发送源代码,仅此漏洞就可以检索源代码。
具体攻击步骤如下:
- 攻击者提供恶意站点(例如,
http://malicious.example.com) - 用户访问执行来自恶意站点的 JavaScript 的站点。这包括顶级导航以及通过 iframe 或外部站点上的脚本进行嵌入
- 提供的 JavaScript 建立 WebSocket 连接
- 用户编辑文件,打包器通过 WebSocket 发送源代码
- 提供的 JavaScript 将该源代码发送给攻击者
- 攻击者接收到源代码
相关漏洞:
- Vite:GHSA-vg6x-rcgg-rjx6 / CVE-2025-24010([2]:WebSocket 连接缺乏对 Origin 头的验证)
- Vitest:GHSA-9crc-q9x8-hgqq / CVE-2025-24964
- parcel:修复 PR
- webpack-dev-server:GHSA-9jgg-88mc-972h / CVE-2025-30360
- Next.js:GHSA-3h52-269p-cp9r / CVE-2025-48068(
allowedDevOrigins必要性的原因之一)
4. 使用 DNS 重绑定
绕过 SOP 的一种方法是被称为 DNS 重绑定(DNS Rebinding)的攻击。它通过更改域名指向的 IP 地址来工作,允许在保持相同”源”的同时向不同的 IP 地址发送请求。如果服务器容易受到此攻击,即使 CORS 配置正确,也可以从另一个站点发送请求并接收响应。
具体攻击步骤如下:
- 攻击者通过 HTTP 提供恶意站点(例如,
http://malicious.example.com) - 用户访问该站点。这包括顶级导航以及通过 iframe 嵌入
- 攻击者更改域名的 DNS 记录以指向
127.0.0.1(或另一个私有 IP 地址) - 站点的 JavaScript 使用
fetch('http://malicious.example.com/main.js')发送请求。此请求是对相同”源”的,但由于域名现在解析为127.0.0.1,它接收到与对http://127.0.0.1/main.js的请求相同的内容 - 站点的 JavaScript 将
fetch的结果发送给攻击者 - 攻击者接收到
main.js的内容
请注意,此攻击在 HTTPS 站点上不起作用,因为域名的证书验证会失败。因此,今天,此攻击主要针对本地服务器可行。
相关漏洞:
- Vite:GHSA-vg6x-rcgg-rjx6 / CVE-2025-24010([3]:HTTP 请求缺乏对 Host 头的验证)
- esbuild:GHSA-67mh-4wv8-2f99
- parcel:修复 PR
本地服务器最佳实践
这些漏洞可能源于本地使用的服务器无法从外部访问的假设。然而,实际上,浏览器不会阻止来自任意站点对本地或私有 IP 的请求。虽然正在开发一个名为私有网络访问的规范作为浏览器端的对策,但由于兼容性问题,其在浏览器中的实现需要相当长的时间。目前,每个服务器都必须实现自己的对策。
即使对于不打算供浏览器访问的服务器,这种保护也很重要。私有网络访问的动机提到了一个杀毒软件因未阻止来自浏览器的访问而允许任意命令执行的漏洞,表明这种观点很容易被忽视。
正确检查请求来源
攻击方法 1-3 都利用了对请求来源检查不足的问题。
对于 CORS 设置:
- 关键是要认识到允许的”源”可以读取响应
- 不应该随意设置
Access-Control-Allow-Origin头 - 应该仅为受信任的”源”正确配置它
对于 XSSI 攻击:
- 可以使用
Cross-Origin-Resource-Policy头 - 指定此头可以限制来自其他”源”的响应读取
- 请注意请求本身仍会到达服务器
- 如果涉及具有副作用(如写入数据)的操作,应该检查
Sec-Fetch-Site头不是cross-site
对于 CSWSH 攻击:
- 可以通过验证
Origin头来防止 - 关键点是不要统一允许所有 IP 地址
- 虽然对于”DNS 重绑定”允许所有 IP 地址是可以接受的,但对于 CSWSH,允许所有 IP 将无法防止来自可以从 IP 地址提供服务的站点的攻击(GHSA-9jgg-88mc-972h / CVE-2025-30360)
正确检查请求目标
“4. 使用 DNS 重绑定”利用了对请求目的地检查不足的问题。
防护措施:
- 使用 HTTPS 是理想的,但对开发服务器的用户来说可能是一个负担
- 应该验证
Host头属于您的站点 - 可以始终允许 IP 地址的
Host头(因为对于 IP 地址主机不会查询 DNS,因此不会发生 DNS 重绑定)
实用工具:
- 如果可以使用与 Node.js 的 Connect 兼容的中间件,可以使用 host-validation-middleware
- 这是从 Vite 中提取的验证
Host头的库
仅绑定到回环接口
问题描述:
- 在 Node.js 的
server.listen中,如果未指定host参数,它会接受指向机器的所有 IP 地址的请求 - 除非被防火墙阻止,否则可以从同一网络内访问
- 例如,连接到同一 Wi-Fi 的手机可以通过指定 PC 的 Wi-Fi IP 地址(如
192.168.0.5)来访问在 PC 上运行的服务器
安全建议:
- 如果不必要,最好在
host参数中指定回环接口(如127.0.0.1或::1) - 这可以防止从机器本身外部访问
- 自 Vite v2.3 以来,这已成为默认设置,因此在 Vite 中不需要配置
结论
本地开发服务器面临着多种安全威胁,包括:
- 过于宽松的 CORS 设置
- XSSI 攻击和原型污染
- CSWSH 攻击
- DNS 重绑定攻击
通过实施适当的安全措施,如正确验证请求来源和目标、仅绑定到回环接口,开发者可以显著提高本地服务器的安全性。
虽然本文专注于前端开发工具,但在 MCP 服务器中也发现了类似的漏洞(MCP: May Cause Pwnage - Backdoors in Disguise)。由于这些漏洞很可能被忽视,当使用运行本地服务器的工具时,最好确认这些攻击向量已得到适当解决。
随着前端开发工具的不断发展,安全性应该始终是一个重要考虑因素。开发者和工具维护者都应该意识到这些潜在的安全风险,并采取相应的防护措施。