template/gotext/README.md
template/gotext/ -- vendored text/template with an execution deadlineThis directory is a verbatim copy of Go's standard-library text/template package, plus one
small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop
user-supplied message templates (Template: yes, see the templating docs)
from burning CPU.
text/template (+ internal/fmtsort), $(go env GOROOT)/src.go-version; recorded in GENERATED_FROMpatches/ (see The patch)make update-template, then commit (never autorolled; see Updating)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 was declined). 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
a single check inside walk: every ~256 nodes it checks a wall-clock deadline and aborts (via
the normal ExecError path) if it has passed. This bounds CPU for any template shape -- cheap
loops and expensive functions alike -- by construction.
The one user-facing execution site (server/server_template.go renderTemplate) sets the deadline
with SetExecutionDeadline and maps the resulting error to a 400. Trusted templates (operator
config: Twilio, cmd/serve.go) keep using the standard library -- they are not user-supplied.
| 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-deadline.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.
patches/ is a quilt-style ordered series (apply 0001-*, then 0002-*, ...). Today there is just
0001-exec-deadline.patch -- small, purely additive, and touching only exec.go/template.go:
deadline/steps fields to the executor state and a deadline field + a
SetExecutionDeadline(time.Time) method on Templatestate.walkErrExecutionInterrupted (detect with errors.Is)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.
Keeping the patch tiny (deadline logic only, on two stable files) is deliberate: it makes re-basing onto a new Go release cheap.
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:
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.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.
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.