ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

ntfy 源码解析:vendor 标准库 text/template 并补丁出 ExecuteContext,阻断模板渲染 CPU DoS

2026/9/10 2:42:27 拓冰建站 浏览量
ntfy 源码解析:vendor 标准库 text/template 并补丁出 ExecuteContext,阻断模板渲染 CPU DoS ntfy 源码解析vendor 标准库 text/template 并补丁出 ExecuteContext阻断模板渲染 CPU DoS【免费下载链接】ntfySend push notifications to your phone or desktop using PUT/POST项目地址: https://gitcode.com/GitHub_Trending/nt/ntfyntfy 允许用户通过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或查询参数?templateyes后请求体中的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 标准库逐一对号入座文件来源*.goexec.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这类永远不可取消的 contextDone()为 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/versionlatest 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),仅供参考