# `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](https://ntfy.sh/docs/publish/#message-templating)) from burning CPU. - **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src` - **Version:** pinned in the repo-root [`.go-version`](../../.go-version); recorded in `GENERATED_FROM` - **Local modifications:** `patches/` (see [The patch](#the-patch)) - **Update mechanism:** Manual -- `make update-template`, then commit (never autorolled; see [Updating](#updating-when-bumping-the-go-toolchain)) ## 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](https://github.com/golang/go/issues/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`](../../.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/@latest && 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.