Files
ntfy/template/gotext
2026-07-10 13:18:11 +02:00
..
2026-07-08 21:08:07 +02:00
2026-07-10 13:18:11 +02:00
2026-07-10 13:18:11 +02:00
2026-07-08 21:08:07 +02:00
2026-07-08 21:08:07 +02:00
2026-07-08 21:08:07 +02:00
2026-07-10 13:18:11 +02:00
2026-07-10 13:18:11 +02:00

template/gotext/ -- vendored text/template with context cancellation

This directory is a verbatim copy of Go's standard-library text/template package, plus one small patch that adds context-aware execution (ExecuteContext). It exists for exactly one reason: to stop user-supplied message templates (Template: yes, see the templating docs) from burning CPU.

  • Source: Go stdlib text/template (+ internal/fmtsort), $(go env GOROOT)/src
  • Version: pinned in the repo-root .go-version; recorded in GENERATED_FROM
  • Local modifications: patches/ (see The patch)
  • Update mechanism: Manual -- make update-template, then commit (never autorolled; see Updating)

Why this exists

ntfy lets users send a Go template that is rendered against a JSON body. Go's text/template cannot be interrupted mid-execution -- there is no context, no deadline, no cancellation (golang/go#31107 proposed ExecuteContext but was declined, over a bundled context-values feature, not cancellation itself). So a crafted template with a tight or nested {{range}} (e.g. ranging over a large JSON array with a big loop body that writes no output) can run for tens of seconds on a single request. That is a CPU denial of service (GHSA-rhwf-xgc9-m9fp).

There is no way to add an interrupt from the outside -- the executor's per-node walk loop is unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add the cancellation half of #31107 as a patch: ExecuteContext(ctx, ...) that aborts with ctx.Err() when ctx is canceled or its deadline passes. The check is a single poll inside walk of an atomic flag that a context.AfterFunc watcher flips -- so it bounds CPU for any template shape (cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and the call site keeps compiling unchanged.

The one user-facing execution site (server/server_template.go renderTemplate) wraps execution in context.WithTimeout and calls ExecuteContext, mapping context.DeadlineExceeded to a 400. Trusted templates (operator config: Twilio, cmd/serve.go) keep using the standard library -- they are not user-supplied.

What's here

File Origin
*.go (exec.go, funcs.go, template.go, option.go, helper.go, doc.go) verbatim from $(go env GOROOT)/src/text/template/, enumerated with go list so files added/removed upstream are picked up automatically
fmtsort/sort.go verbatim from $(go env GOROOT)/src/internal/fmtsort/ -- exec.go needs it, and internal/... packages can't be imported from outside GOROOT, so it comes along
patches/0001-exec-context.patch our only real change (see below)
GENERATED_FROM the exact Go version make update-template last regenerated this copy from; provenance, written by that target

The Go toolchain version this copy is pinned to lives in the repo-root .go-version file (the single source of truth, also consumed by CI and the make targets below). GENERATED_FROM must equal it -- make check fails otherwise (see below).

We do not vendor text/template/parse -- it's a normal importable stdlib package and stays a plain import.

The patch

patches/ is a quilt-style ordered series (apply 0001-*, then 0002-*, ...). Today there is just 0001-exec-context.patch -- small, purely additive, and touching only exec.go:

  • adds ctx context.Context and a shared cancelled *atomic.Bool to the executor state
  • adds ExecuteContext / ExecuteTemplateContext; Execute / ExecuteTemplate become context.Background() wrappers, so their behavior and cost are unchanged
  • when ctx.Done() != nil, arms one context.AfterFunc watcher that flips the flag; walk polls it per node and aborts via a cancelError that errRecover strips to the bare ctx.Err() (errors.Is(err, context.DeadlineExceeded))

The flag is a *atomic.Bool (not a value) because walkTemplate copies state for nested {{template}} invocations; a shared pointer keeps one flag across all copies and avoids go vet copylocks. template.go is unchanged -- the context is per-call, not stored on the Template.

Two mechanical transforms are applied by make update-template with sed, not the patch -- renaming the package to gotext, and rewriting the internal/fmtsort import to heckel.io/ntfy/v2/template/gotext/fmtsort. Keeping them out of the patch means they apply to whatever files go list returns, so they survive upstream files being added or removed. (These two transforms are also the only difference between our patch and the upstream text/template diff.)

Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing onto a new Go release cheap.

Updating (when bumping the Go toolchain)

The copy is pinned to the Go version in the root .go-version, so it's not frozen -- re-syncing on a Go bump pulls in all upstream fixes for free. .go-version is authoritative and hand-edited; to bump the toolchain: edit .go-version, install that toolchain (go install golang.org/dl/<version>@latest && <version> download), then re-sync:

make update-template   # copies the files from your GOROOT and re-applies patches/*.patch

make update-template errors unless your local Go matches .go-version -- it validates against the pin, it never writes it. If the patch hunks no longer apply against the new release, refresh the patch as part of the bump.

make template-check (wired into make check) has two layers:

  1. Marker check (ungated, runs on any toolchain): fails if GENERATED_FROM != .go-version, i.e. someone bumped the pin but forgot make update-template (or vice versa). This catches the common mistake locally, on any developer's Go.
  2. Content check (gated to the pinned Go): re-derives the copy from GOROOT + patches and diffs it against what's committed, catching hand-edits and patch problems. It no-ops on a non-pinned toolchain so it never fails spuriously.

CI installs exactly .go-version (go-version-file), so both layers run there. make release additionally refuses to run off the pinned Go, so the content check is never skipped for a release.

License

These files are copyright The Go Authors, under the BSD-3-Clause license (headers preserved in each file). That is compatible with ntfy's Apache-2.0 / GPLv2 licensing.