一个由非法 header 引发的事故
周六晚上,我刚准备开始在《戴森球计划》中体验一把快乐的曲速飞行,一个突如其来的 incident 打断了我的旅程。它来自于 BFF 的监控告警——503 频率高到吓人。

我熟练地登上 Datadog 平台的 oncall 页面,点了 acknowledge 按钮后开始调查。奇怪的是,我没发现任何可用的 trace。转头确认 pod 状态,发现 pod 数量保持稳定(意味着它们没挂),不过 pod 的 CPU 曲线却不断波动。
后端同事很快从日志中捕捉到了关键线索:
TypeError: Headers.has: "ot-baggage-";print(md5(31337));$a" is an invalid header name.
at webidl.errors.exception (node:internal/deps/undici/undici:3610:14)
at webidl.errors.invalidArgument (node:internal/deps/undici/undici:3621:28)
at _Headers.has (node:internal/deps/undici/undici:8907:31)
at FetchPlugin.bindStart (/app/node_modules/.pnpm/[email protected]/node_modules/dd-trace/packages/datadog-plugin-fetch/src/index.js:21:24)
at /app/node_modules/.pnpm/[email protected]/node_modules/dd-trace/packages/dd-trace/src/plugins/tracing.js:81:59
at StoreBinding._transform (/app/node_modules/.pnpm/[email protected]/node_modules/dd-trace/packages/dd-trace/src/plugins/plugin.js:38:11)
at node:diagnostics_channel:86:17
at Channel.runStores (node:diagnostics_channel:171:12)
at TracingChannel.tracePromise (node:diagnostics_channel:366:18)
at /app/node_modules/.pnpm/[email protected]/node_modules/dd-trace/packages/datadog-instrumentations/src/helpers/fetch.js:23:19一眼看过去:print(md5(31337))。机智如我(以及我那刻在 DNA 里的安全基因)立刻意识到:这不就是渗透测试里常用的探测 payload 吗?虽然它看起来像是面向 PHP/Perl,对 Node.js 没啥实效性,但它却阴差阳错地把我们的 worker 线程搞崩了。
回想了一下,当初 BFF 的 owner 启用了 cluster 模式,即一个主线程管理若干个 worker 线程。每个请求只会搞挂一个 worker,只要攻击频率没有高到瞬间击垮所有 worker,BFF 就能持续提供部分服务。这也解释了为何 pod 没挂,但 503 告警却频繁触发。至于 pod 的 CPU 波动,也跟 worker 重启的记录对上了。
要命的是,崩溃发生在 dd-trace(Datadog 提供的全链路追踪工具)的逻辑中,这意味着 Datadog 平台上的 trace 模块不可能记录下导致自身崩溃的请求 trace。而更上层的网关只记录了 URL,不记录 header 和 body。至此,所有可用的信息,只剩下 pod 内部那几行冰冷的 TypeError 日志了。
果断但无法验证的修复
根据过往的安全经验,我很容易就发现这个攻击对 Node.js 项目没有任何安全威胁,只是触发了 SDK 的一个 bug,并且幸运的是,攻击没过一会儿就停了,我们不必太过紧急,而是可以集中精力解决这个让 worker 挂掉的问题。
报错清楚地指出了问题:Headers.has: "ot-baggage-";print(md5(31337));$a" is an invalid header name。一个请求的 header key 是非法的 ot-baggage-";print(md5(31337));$a,Node.js 在执行 header.has 时,主动抛出了 TypeError。
看了一下报错堆栈,我能控制的最上层代码是 datadog-plugin-fetch 库里面的 FetchPlugin.bindStart:
for (const name in options.headers) {
if (!req.headers.has(name)) {
req.headers.set(name, options.headers[name])
}
}于是我跟同事兵分两路:同事负责给 Datadog 提 issue,而我则负责在官方修复之前,临时 patch 这个库:
diff --git a/packages/datadog-plugin-fetch/src/index.js b/packages/datadog-plugin-fetch/src/index.js
index 943a1908ddb2235f3172d3ab8c9498400f37c257..26b9fd240966a0e2e9fd5511080fdd6feaaa654d 100644
--- a/packages/datadog-plugin-fetch/src/index.js
+++ b/packages/datadog-plugin-fetch/src/index.js
@@ -18,8 +18,13 @@ class FetchPlugin extends HttpClientPlugin {
const store = super.bindStart(ctx)
for (const name in options.headers) {
- if (!req.headers.has(name)) {
- req.headers.set(name, options.headers[name])
+ // prevent potential crash caused by `headers.has` in node.js
+ try {
+ if (!req.headers.has(name)) {
+ req.headers.set(name, options.headers[name])
+ }
+ } catch {
+ // ignore
}
}为了验证修复是否有效,我要先在修复前的环境中复现问题,再在修复后的环境验证效果。只要前者会 503,后者不会 503,就可以认为修复有效。
然而,事情并不顺利。简单的工具似乎都派不上用场:
- 用 cURL 构造请求:没发现任何问题;
- 用 Postman 构造请求:提示
Error: Header name must be a valid HTTP token [""ot-baggage-";print(md5(31337));$a""]; - 用 BurpSuite 拦截并修改请求:会直接返回 BurpSuite 自己的错误页面;
- 用 Node.js 写了段简单的 PoC:发现居然直接报错 400,原因是 Node.js 会在构造 request 时校验 header。

“老子当年好歹也是搞安全的,居然沦落到如此地步。”我心一横,直接掏出 python,想用最原始的 TCP+SSL 强行把非法数据塞进服务器。
import socket
import ssl
host = "xxx.xxx.xxx"
port = 443
path = "/path/to/endpoint"
raw_http = f"""GET {path} HTTP/1.1
Host: {host}
User-Agent: test by rex
ot-baggage-";print(md5(31337));$a: test-value
""".replace("\n", "\r\n")
context = ssl.create_default_context()
with socket.create_connection((host, port)) as sock:
with context.wrap_socket(sock, server_hostname=host) as ssock:
ssock.sendall(raw_http.encode("utf-8"))
response = ssock.recv(4096)
print(response.decode("utf-8", errors="replace"))然并卵……请求直接 400 了,BFF 内部甚至没有打印出任何相关日志。
考虑到这个 patch 是防御性的,不会引入新问题,且大概率能阻止 worker 挂掉,我将 incident 状态改为 stable,决定周一上班再继续深入。
艰难的复现旅程
周一上班后,我跟 BFF owner 大眼瞪小眼一个小时,依旧没能复现问题。
我们最先怀疑是不是被最近刚部署的 WAF 给拦截了。但跟 SRE 同事确认后发现并不是——即使把 WAF 下掉,请求依然直接返回了 400。甚至我在本地启动了 BFF 服务,试图访问,也直接返回 400。我们尝试在 middlewares 中加断点,没有任何一个断点被触发。但当我把那个 ot-baggage 相关的非法 header 去掉,一切又恢复了正常,断点也被触发了。
我们的 BFF 基于 hono 框架,底层用的就是 Node.js 的 createHttpServer。这不禁让人产生一个猜想:Node.js 自带的 http server 会拦截非法 header!为了验证,我让 GPT 快速生成了一个 createHttpServer 的 demo,确实如此。
那就神奇了,既然 Node.js 自己就能拦住非法 header,这个 ot-baggage 又是如何绕过并进入到 BFF 内部,最终导致 SDK 崩溃呢?我又该如何复现呢?
我转变思路,既然错误是发生在 datadog-plugin-fetch 包,其作用是包装 fetch 函数,那么直接在代码中调用 fetch 并传入这个 header 试试。请求如预期那样报错了:
ClientInfo2BusinessInfoMiddleware error: TypeError: Headers.append: "ot-baggage-";print(md5(31337));$a" is an invalid header name.
at webidl.errors.exception (node:internal/deps/undici/undici:3384:14)
at webidl.errors.invalidArgument (node:internal/deps/undici/undici:3395:28)
at appendHeader (node:internal/deps/undici/undici:8319:29)
at fill (node:internal/deps/undici/undici:8305:11)
at new Request (node:internal/deps/undici/undici:9483:13)
at /app/node_modules/.pnpm/[email protected]/node_modules/dd-trace/packages/datadog-instrumentations/src/helpers/fetch.js:20:21意料之外的是,这个报错堆栈跟线上相比,不能说有点差异,只能说完全不同。堆栈的最上层变成了 datadog-instrumentations 包,且 BFF 并没有任何 worker 挂掉。想想也合理,router 和 middleware 中抛出的异常,hono 肯定会帮忙 catch 住。但这样一来,worker 线程就更不应该挂掉了。
我打开这个报错对应的代码,是这样的:
const req = new Request(input, init) // <- 本地报错在这里
const ctx = { req }
return ch.tracePromise(() => fetch.call(this, req), ctx) // <- 线上报错在这里如何构造这个 init,让上面的 new Request 不报错,但下面的 tracePromise 报错呢?这条路似乎已经走到了尽头,我想不到更多的可能性了。
此时同事被叫去开会了,留我一个人继续调查。
从结果反推
如果正向推不出结论,那就反向来!ot-baggage-";print(md5(31337));$a 明显就是 ot-baggage- 前缀接了一个用户输入,那我直接在 node_modules 中全局搜索这个前缀,果不其然,在 dd-trace/src/opentracing/propagation/text_map.js 中找到了线索:
const baggagePrefix = 'ot-baggage-'
class TextMapPropagator {
_injectBaggageItems (spanContext, carrier) {
if (this._config.legacyBaggageEnabled) {
spanContext._baggageItems && Object.keys(spanContext._baggageItems).forEach(key => {
carrier[baggagePrefix + key] = String(spanContext._baggageItems[key])
})
}
// ...
}
}这里的 carrier 应该就是 headers 了。接着往上找,spanContext._baggageItems 是什么呢?在同一个 class 中:
class TextMapPropagator {
_extractBaggageItems (carrier, spanContext) {
// ...
const baggages = carrier.baggage.split(',')
for (const keyValue of baggages) {
// ...
let [key, value] = keyValue.split('=')
key = this._decodeOtelBaggageKey(key.trim())
value = decodeURIComponent(value.trim())
// ...
spanContext._baggageItems[key] = value
}
}
}原来是从一个叫 baggage 的 header 中解出来的!看解析逻辑,这个 header 应该长得像 baggage: key1=value1, key2=value2, ...。它是用来做什么的呢?
通过搜索 Datadog 文档,我发现 Datadog 用它来做 Trace Context Propagation,也就是全链路追踪。Datadog 会用它来传递 trace ID、span ID、采样策略等内容。用户也可以往里面加入自己的内容。
通过这份文档,我还发现它是个 W3C 规范中用来在分布式场景下存放 context 的地方(文档)。W3C 定义了一种标准格式,用于在分布式系统或微服务架构中,携带一些“应用程序定义”的属性(key-value 对),并随请求在服务间传递。它可用于传递诸如用户 ID、referer、环境标记、A/B 测试标记等对业务或监控有意义的信息,有点像 Golang 中的 context。一个示例 baggage header 如下:
baggage: userId=alice, serverNode=DF%2028, isProduction=false除 Datadog 以外,其它的主流实现如 OpenTelemetry 也支持了这个 header。巧了,OpenTelemetry 的简称不就是 ot 吗?
构造请求!
结合上述信息,我大概知道非法请求是如何绕过 Node.js 自带的防护了——请求本身并没有用非法字符作为 header 的 key,而是将其作为了 value:
baggage: ";print(md5(31337));$a=test-value经过 Datadog SDK 处理后,它被转换成了:
ot-baggage-";print(md5(31337));$a: test-value于是我改进了我的 PoC,发现不会 400 了,但——也不会报错了,就像一个正常请求一样。
那怎么行!我开始在 node_modules 中单步调试,发现在刚刚的 _extractBaggageItems 函数中,有一句 spanContext === null,直接提前 return 了……
继续往上追,发现了 spanContext 的来源:
const traceKey = 'x-datadog-trace-id'
const spanKey = 'x-datadog-parent-id'
class TextMapPropagator {
_extractGenericContext (carrier, traceKey, spanKey, radix) {
if (carrier && carrier[traceKey] && carrier[spanKey]) {
return new DatadogSpanContext({ ... })
}
}
}行,那我最后再改进一次 PoC,加上这两个 header,在本地运行,成功了:
import socket
host = "localhost"
port = 8080
path = "/path/to/endpoint"
raw_http = f"""GET {path} HTTP/1.1
Host: {host}:{port}
x-datadog-trace-id: 1
x-datadog-parent-id: 2
baggage: ";print(md5(31337));$a=test-value
""".replace("\n", "\r\n")
with socket.create_connection((host, port)) as sock:
sock.sendall(raw_http.encode("utf-8"))
response = sock.recv(4096)
print(response.decode("utf-8", errors="replace"))

最后再顺着刚刚的 _injectBaggageItems 往上追,发现这个 _injectBaggageItems 的调用路径是在 FetchPlugin.bindStart 中,执行了父类的同名方法 HttpClientPlugin.bindStart,在后者通过 this.tracer.inject 调用的:
class HttpClientPlugin extends ClientPlugin {
bindStart (message) {
if (this.shouldInjectTraceHeaders(options, uri)) {
// ...
this.tracer.inject(span, HTTP_HEADERS, options.headers)
}
// ...
}
}这完美解释了所有的疑问:
new Request不报错,是因为初始化时 headers 对象中并没有非法 Key。tracePromise报错,是因为它后续调用的bindStart中,Datadog SDK 生成了非法 key 并注入到了 header 中。- Worker 线程挂掉,是因为
tracePromise返回了一个 Promise,脱离了 hono router 的异常捕获范围。由于没有全局捕获,异常就导致了 worker 崩溃。
完美结局:多重防御
最终,同事作为 BFF owner,给 worker 线程添加了 process.on('error'),彻底解决了此问题。经验证,无论是在我 patch 后的版本,还是添加全局捕获的版本,我的 PoC 都无法再触发 503 了。
Datadog 的 issue 也被 maintainer 添加了 bug 的标签,后续应该会排期修复吧。

这次 incident 以其反直觉的绕过方式,再次告诫我们:在复杂的微服务架构中,任何一个微小的细节都可能是导致生产事故的薄弱环节。同时,我们这次的 WAF 规则优化也已经在路上了,毕竟,多重防御才是王道。
在排查的过程中,不光了解了 hono 的底层实现、Node.js 会拦截非法 header 的做法,以及了解到了 W3C 还有个专门用于分布式场景下的 baggage 规范。所以,只要找对方向,偶尔排查一下生产问题也挺有意思的……
……只要不是在我想打《戴森球计划》的时候发生问题就行。