一次 WebSocket 握手为何能终止进程?Undici CVE-2026-19534 的异常边界
背景与时间线
项目公告于 2026-09-04 披露,GitHub 已审核数据库于 9 月 29 日 收录 CVE-2026-19534。本次属于新收录,不是 9 月 29 日新发生的攻击。
已确认事实是:WebSocket 服务端返回客户端没有请求过的子协议时,受影响客户端可能在微任务回调中抛出未捕获的 TypeError,导致 Node.js 进程退出。必要场景是应用主动连接恶意或被入侵服务端,或明文连接遭到中间人干扰。不能把它写成任何人向普通 HTTP 服务发一个请求就能触发。
技术原理:失败路径也是协议实现
WebSocket 握手不仅验证状态码,也需要检查协商结果与客户端请求是否一致。子协议是双方协商的结果,服务端不能单方面选择一个客户端未请求的值。RFC 6455 第 4.1 节要求遇到这种情况使连接失败。
**工程分析:**协议错误首先属于外部输入错误,不应自动升级为进程级故障。异步调度会让这一点更难保证:包围构造函数的同步 try/catch,不一定覆盖稍后微任务中的异常。仅验证"能识别异常响应",仍可能漏掉"识别后错误被谁接住"。
可将测试目标拆成三层:协商结果被拒绝、连接资源释放、其他连接和业务任务继续工作。测试只看到第一层失败,就不能宣称完成拒绝服务防护。
影响范围与修复
| 受影响分支 | 修复版本 |
|---|---|
| 6.7.0 至小于 6.28.1 | 6.28.1 |
| 7.0.0 至小于 7.29.1 | 7.29.1 |
| 8.0.0 至小于 8.10.2 | 8.10.2 |
版本范围来自官方公告及数据库;7.29.1 发布记录提供对应升级入口。官方没有给出完整绕过措施,要求升级。
应区分项目安装的 Undici 与运行时内置实现:修改 package.json 不必然更新 Node.js 自带组件。实际使用哪条调用路径,要从依赖导入和运行时版本确认。本文不提供未经核验的 Node.js 修复版本映射。
无网络安全示例
下例是协议状态模型,不创建套接字,也不是官方补丁。
python
def negotiate(requested, selected):
if selected is not None and selected not in requested:
return {'state': 'closed', 'error': 'unsupported protocol'}
return {'state': 'open', 'error': None}
jobs = [([], 'unknown'), (['chat'], 'chat'), ([], None)]
results = [negotiate(*job) for job in jobs]
assert results[0]['state'] == 'closed'
assert results[1]['state'] == 'open'
assert results[2]['state'] == 'open'
assert len(results) == 3
print('connection isolation model passed')
这里的关键断言不是错误文本,而是第三个任务仍得到处理。在真实项目中,应把握手异常、错误事件、关闭事件和子进程退出码一起纳入测试。将回归运行在隔离子进程中,能够让测试框架识别进程退出,而不把整套测试一起带走。
研发与安全团队行动清单
P0:确认暴露路径并升级
梳理主动连接外部 WebSocket 的服务,确认目标地址是否可受用户影响,升级对应 Undici 分支并重建部署制品。TLS 可以减少明文中间人风险,但无法保护应用免受一个本身恶意的合法 TLS 服务端影响,也不能替代升级。
P1:测试错误的传播范围
为握手增加正常、不支持的协商结果、缺失字段和连接中断用例。观察连接是否关闭、任务是否挂起、重试是否有退避。自动重启只改善恢复能力,如果每次重连立即再次失败,仍可能形成循环故障。
P2:设立运行边界
对不可信外部服务连接设置并发限制、重连预算与错误指标。日志记录目标分类和错误阶段即可,不应默认保存完整敏感握手信息。不要用全局吞掉未捕获异常的方法来假装修复;进程可能已处于无法保证一致性的状态。
总结
安全的协议实现要让非法输入停留在连接边界内。版本扫描发现候选风险,异步失败测试则验证风险是否真的被隔离。两者缺一不可。