ntfy 源码解析:vendor 标准库 text/template 并补丁出 ExecuteContext,阻断模板渲染 CPU DoS
【免费下载链接】ntfySend push notifications to your phone or desktop using PUT/POST项目地址: https://gitcode.com/GitHub_Trending/nt/ntfy
ntfy 允许用户通过X-Template: yes把message/title/priority写成 Go 模板,服务端用 JSON 请求体作为数据渲染后推送。由于 Go 标准库text/template一旦开始执行便无法从外部中断,精心构造的模板(例如对超大数组做不产生输出的深层{{range}}循环)能让单个请求烧掉数十秒 CPU。本文以仓库 template/gotext/README.md 为骨架,完整讲解 ntfy 如何vendor 一份 Go 标准库text/template,并以最小补丁加入上下文感知执行(ExecuteContext),从而精确、零开销地把每次模板渲染限制在 100ms 内——读完本文,你将理解这条防护链路的每一个环节,以及如何随 Go 工具链升级同步这份拷贝。
背景:为什么必须 vendor 一份标准库
ntfy 的发布接口支持“消息模板”功能(详见 发布文档 中的消息模板章节):设置X-Template: yes(别名Template、tpl,或查询参数?template=yes)后,请求体中的message、title、priority会被当作 Go 模板,针对请求体的 JSON 数据进行渲染。
问题在于渲染的输入是用户提交的任意模板,而 Go 标准库text/template的执行器(executor)无法在执行中途被打断:
- 它没有 context、没有 deadline、没有 cancellation——社区提案 golang/go#31107 曾提出
ExecuteContext,但最终被否决(否决理由是当时捆绑的 context-values 特性,而非取消机制本身); - 执行器的逐节点
walk循环是未导出的,外部无法注入检查点。
于是,一个包含紧密或嵌套{{range}}的模板(比如遍历一个很大的 JSON 数组、循环体庞大但不产生输出)可以在单次请求中运行数十秒。这就是一次典型的CPU 拒绝服务(DoS),对应安全通告 GHSA-rhwf-xgc9-m9fp。
既然外部无法打断,唯一的稳健修复方案就是直接给执行器本身打补丁。ntfy 没有采用脆弱的外部启发式(猜测迭代次数、包装每一个函数等),而是选择把整个包 vendor 进仓库,然后加入 #31107 的“取消”那一半功能作为补丁。这份拷贝的定位非常明确,template/gotext/README.md 第一句就写得很清楚:它是 Go 标准库text/template的逐字节拷贝,外加一个实现上下文感知执行的小补丁,存在的唯一理由就是阻止用户提供的模板烧 CPU。
目录里有什么:逐字节拷贝 + 一个补丁
template/gotext/下的文件全部来自 Go 标准库,逐一对号入座:
| 文件 | 来源 |
|---|---|
*.go(exec.go、funcs.go、template.go、option.go、helper.go、doc.go) | 原样拷贝自$(go env GOROOT)/src/text/template/,且是用go list枚举文件列表,因此上游新增/删除文件时能自动同步 |
fmtsort/sort.go | 原样拷贝自$(go env GOROOT)/src/internal/fmtsort/——exec.go依赖它,而internal/...包无法被 GOROOT 之外的代码导入,所以必须一起搬进来 |
patches/0001-exec-context.patch | 唯一真正的改动(即补丁本体) |
GENERATED_FROM | 记录make update-template上次基于哪个 Go 版本生成的这份拷贝,是溯源凭据(当前内容为go1.26.5) |
拷贝所固定的 Go 工具链版本,由仓库根目录的 .go-version 文件(当前为1.26.5)声明。它是唯一权威来源,CI 和下面的make目标都读取它;且GENERATED_FROM必须等于.go-version,否则make check会直接失败。
值得注意的是:ntfy没有vendortext/template/parse——它是一个普通可导入的标准库包,保持普通 import 即可(见 exec.go 中的"text/template/parse"导入)。
补丁原理:ExecuteContext是如何实现“可中断”的
patches/采用 quilt 风格的有序补丁系列(依次应用0001-*、0002-*……)。目前只有0001-exec-context.patch一个,小而纯粹、只做加法,且只动了exec.go一个文件。核心改动分四步:
1.state增加 context 与共享取消标志
执行器内部的state结构体新增两个字段(见 补丁):
type state struct { tmpl *Template ctx context.Context // ctx-ex: execution context; Execute uses context.Background. wr io.Writer node parse.Node // current node, for errors vars []variable // push-down stack of variable values. depth int // the height of the stack of executing templates. cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled }关键设计点是cancelled是一个*atomic.Bool(指针而非值):因为walkTemplate在处理嵌套{{template}}调用时会复制state,共享指针能保证所有副本共用同一个标志位,同时避免go vet的 copylocks 告警。而template.go完全未改动——context 是每次调用传入的,不会存储在Template对象上,因此同一个模板仍可安全并行执行。
2. 新增 API,旧 API 变成 Background 包装
补丁新增ExecuteContext/ExecuteTemplateContext,并把原有的Execute/ExecuteTemplate改为context.Background()的薄包装(见 exec.go 中的实现):
func (t *Template) Execute(wr io.Writer, data any) error { return t.executeContext(context.Background(), wr, data) } func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error { if err := ctx.Err(); err != nil { return err } return t.executeContext(ctx, wr, data) }ExecuteContext的语义(直接来自补丁注释,也是需要牢记的使用契约):
- 如果
ctx被取消或超过 deadline,执行在模板节点求值之间被观察并中止,因此长时间运行的渲染——包括不产生输出的紧密/嵌套{{range}}循环——会被及时中止; - 若模板阻塞在单个函数调用内部,则要等该调用返回才能中断;
- 中止前可能已经向
wr写入了部分结果。
Execute/ExecuteTemplate走context.Background(),行为与开销完全不变,因此标准用法零成本。
3. AfterFunc watcher 翻转标志位,walk 每节点轮询
这是整个补丁的性能核心。在executeContext中:
// If the context can be canceled, watch it with a single context.AfterFunc // callback that flips an atomic flag; walk polls that flag per node (a cheap // monomorphic atomic load) instead of calling ctx.Err() every node. if ctx.Done() != nil { state.cancelled = new(atomic.Bool) stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) }) defer stop() }而在walk每进入一个节点时:
func (s *state) walk(dot reflect.Value, node parse.Node) { s.at(node) // Abort if the context has been canceled or its deadline has passed... if s.cancelled != nil && s.cancelled.Load() { panic(cancelError{s.ctx.Err()}) } switch node := node.(type) { // ... }也就是说:在节点之间只做一次廉价的单态原子加载(atomic load),而不是每节点调用ctx.Err();取消信号由单个context.AfterFunc回调把标志位翻转为 true。context.AfterFunc在 context 被取消(或超时)时会调度执行注册的回调——标志位机制避免了对 context 内部状态锁的竞争。defer stop()确保执行结束后注销 watcher。
对于Background/TODO这类永远不可取消的 context(Done()为 nil),什么都不安装、什么都不付出——所以默认的Execute路径零开销。
4. cancelError 包装 + errRecover 剥离
walk中检测到取消时panic(cancelError{s.ctx.Err()})。cancelError是一个内部包装类型,注释明确说明:它不是error接口的实现,因此永远不会作为 error 值逃出包外。而执行器顶层的errRecover负责把 panic 转回返回值,新增的分支与既有的writeError分支对齐:
case cancelError: *errp = err.Err // Strip the wrapper; return the context error.调用方通过errors.Is(err, context.DeadlineExceeded)即可识别超时。补丁刻意保持这么小(只做取消、只动一个稳定文件),是为了让日后 rebase 到新 Go 版本的代价足够低。
两个 sed 机械转换(不属于补丁)
make update-template还会用sed做两处机械转换(而不是放进补丁):
- 把包名从
package template改为package gotext; - 把
"internal/fmtsort"导入重写为"heckel.io/ntfy/v2/template/gotext/fmtsort"(见补丁 diff 中 import 块的调整)。
把它们放在补丁之外,意味着这两个转换对go list返回的任何文件都生效,即使上游新增或删除了文件也能幸存。这两处转换,也正是这份拷贝与上游text/template的 diff 中仅有的差别。
唯一调用点:server_template.go 里的 100ms 防护墙
这份 vendored 包在仓库里只有一个面向用户的执行点——server/server_template.go 中的renderTemplate。完整渲染链路是理解整条防护的关键:
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) { if len(tpl) > templateMaxTemplateBytes { // 1) 模板本身限长 32KB return "", errHTTPBadRequestTemplateTooLarge } var data any if err := json.Unmarshal([]byte(source), &data); err != nil { // 2) 请求体必须是合法 JSON return "", errHTTPBadRequestTemplateMessageNotJSON } t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl) if err != nil { return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } if templateUsesDisallowedFeatures(t) { // 3) 禁用 {{define}}/{{block}}/{{template}}/{{call}} return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime) // 4) 100ms 硬性超时 defer cancel() var buf bytes.Buffer limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes)) // 5) 输出限长 1MB if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil { // 6) 可中断执行 if errors.Is(err, context.DeadlineExceeded) { return "", errHTTPBadRequestTemplateExecutionTimeout } return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) } return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil }注意其中的防护参数(server_template.go 中的常量定义):
templateMaxExecutionTime = 100 * time.Millisecond——单次渲染的墙上时钟 deadline,这是 GHSA-rhwf-xgc9-m9fp 的直接对策。它被声明为var而非const,仅仅是为了让测试能临时调大,生产环境从不修改;templateMaxOutputBytes = 1MB——输出上限,防内存放大;templateMaxTemplateBytes = 32KB——模板本身的大小上限;templateUsesDisallowedFeatures会遍历解析树,禁止{{define}}/{{block}}/{{template}}子模板调用和{{call}}内建——遍历解析树(而非正则匹配字符串)能捕获所有语法形态,例如{{if call .x}}或{{$y := call .x}}。
templatePrintf则是另一道针对性防护:fmt允许每个动词的宽度/精度高达 1e6,一个小模板如{{printf "%999999d%999999d..." ...}}能在单个fmt调用内部分配数 GB 内存——而取消 context 只在模板节点之间检查、不会进入单个函数调用内部。因此宽度/精度 ≥1000 以及*(从参数取值)形式都会被拒绝(正则定义中的templatePrintfLargeSizeRegex)。
两条与 deadline 语义相关的重要事实(README 与源码注释双重确认):
- deadline 从 body 读完才开始计时:
renderTemplate的注释明确写道“deadline starts here, after the body has already been read, so a slow upload is not counted against it”。测试 TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline 用比 deadline 慢 3 倍的slowBody上传验证了这一点——上传再慢,模板照样渲染成功(200,而非超时码)。 - 从请求 context 派生意味着客户端断开也会中止渲染:
context.WithTimeout(ctx, ...)直接继承请求 ctx,所以客户端断连同样会翻转取消标志。测试 TestServer_MessageTemplate_ClientDisconnect_CancelsRender 把 deadline 临时抬到 30s,然后在 500ms 处取消 ctx,验证失控模板被取消中止后返回的是“执行失败”码40045,而不是超时码40055(两个错误码定义见 server/errors.go)。
另外,README 特别强调:受信模板不经过这条路径。运维配置里的模板(Twilio、cmd/serve.go)继续使用标准库text/template——因为它们不是用户提交的,不需要防护。
升级与校验:make update-template 与两层 template-check
这份拷贝是固定到根目录.go-version所声明的 Go 版本的,因此它并没有冻结——在 Go 升级时重新同步,就能免费获得上游所有text/template修复。升级流程(Makefile 中update-template目标的实现)为:
- 编辑
.go-version,安装对应工具链:go install golang.org/dl/<version>@latest && <version> download; - 运行
make update-template——从本机 GOROOT 拷贝文件、做两处 sed 转换、依次应用patches/*.patch、并把go env GOVERSION写入GENERATED_FROM; - 若补丁 hunk 无法在新版本上应用,则作为升级的一部分刷新补丁。
make update-template有一个强约束:除非本地 Go 与.go-version一致,否则直接报错——它校验 pin、绝不改写 pin。而make template-check(已接入make check)分两层:
- 版本标记检查(不限定工具链,任何 Go 上都会跑):
GENERATED_FROM!=.go-version即失败——有人只改了 pin 却忘了make update-template(或反过来)时,这个常见错误在任何开发者的本地 Go 上都能立刻暴露; - 内容检查(限定固定版本才会真正执行):从
GOROOT + patches重新推导出拷贝,再与仓库已提交内容逐文件 diff,抓出手工编辑和补丁问题;在非 pin 工具链上则 no-op,避免误报。
CI 通过go-version-file精确安装.go-version声明的版本,因此两层检查在 CI 都会执行。make release更进一步拒绝在非 pin Go 上执行(Makefile 中release-checks目标),确保发布时内容检查绝不被跳过。此外还有一个只告警不失败的目标go-check:当上游 Go 落后于最新版本时提示该重新同步,避免这份拷贝悄悄落后于上游修复。
许可证
template/gotext/下的文件版权归 The Go Authors 所有,遵循BSD-3-Clause许可证(每个文件头部都保留版权声明)。这与 ntfy 的 Apache-2.0 / GPLv2 许可协议兼容,这也是可以安全 vendor 进仓库的前提。
小结
这条防护链路的全貌可以概括为:用户模板不可信 → 标准库执行器无法外部打断 → vendor 拷贝并打最小补丁(ExecuteContext+ 原子标志位轮询)→ 唯一调用点用 100ms 超时包裹 → 超时/断连统一映射为明确的 400 错误码 → Makefile 两层校验保证拷贝与 Go 版本严格同步。它同时满足了三个苛刻要求:对任何模板形态(廉价循环和昂贵函数皆然)都能限定 CPU、精度精确到节点级、且对正常渲染无可测开销。若上游 #31107 的取消功能有一天合入标准库,这份 fork 可以直接删除,而调用点 server_template.go 中的ExecuteContext无需任何改动即可继续编译。
【免费下载链接】ntfySend push notifications to your phone or desktop using PUT/POST项目地址: https://gitcode.com/GitHub_Trending/nt/ntfy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考