From 3f56dae54a760fac4094ee9fcebc47f1c7972162 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 10 Jul 2026 13:18:11 +0200 Subject: [PATCH] Template exec context, redone --- server/server.go | 8 +- server/server_template.go | 36 +++-- server/server_test.go | 39 ++++- template/gotext/README.md | 48 +++--- template/gotext/exec.go | 89 ++++++++--- .../gotext/patches/0001-exec-context.patch | 149 ++++++++++++++++++ .../gotext/patches/0001-exec-deadline.patch | 113 ------------- template/gotext/template.go | 17 +- 8 files changed, 310 insertions(+), 189 deletions(-) create mode 100644 template/gotext/patches/0001-exec-context.patch delete mode 100644 template/gotext/patches/0001-exec-deadline.patch diff --git a/server/server.go b/server/server.go index 284d6d48..47418001 100644 --- a/server/server.go +++ b/server/server.go @@ -151,6 +151,11 @@ var ( templatesDir = "templates" templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) + + // templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard + // (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never + // mutated in production. + templateMaxExecutionTime = 100 * time.Millisecond ) const ( @@ -164,7 +169,6 @@ const ( unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part messagesHistoryMax = 10 // Number of message count values to keep in memory - templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks templateFileExtension = ".yml" // Template files must end with this extension ) @@ -1245,7 +1249,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message } else if m.Attachment != nil && m.Attachment.Name != "" { return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } else if template.Enabled() { - return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5 + return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5 } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { return s.handleBodyAsTextMessage(m, body) // Case 6 } diff --git a/server/server_template.go b/server/server_template.go index b9f73b7d..bed6e9f1 100644 --- a/server/server_template.go +++ b/server/server_template.go @@ -2,13 +2,13 @@ package server import ( "bytes" + "context" "encoding/json" "errors" "os" "path/filepath" "strings" "text/template/parse" - "time" "gopkg.in/yaml.v2" "heckel.io/ntfy/v2/model" @@ -17,7 +17,7 @@ import ( "heckel.io/ntfy/v2/util/sprig" ) -func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { +func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) if err != nil { return err @@ -26,11 +26,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem } peekedBody := strings.TrimSpace(string(body.PeekedBytes)) if template.FileMode() { - if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { + if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil { return err } } else { - if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil { + if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil { return err } } @@ -42,7 +42,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem // renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. // The template file must be in the templates directory, or in the configured template directory. -func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error { +func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error { if !templateNameRegex.MatchString(templateName) { return errHTTPBadRequestTemplateFileNotFound } @@ -61,17 +61,17 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo } var err error if tpl.Message != nil { - if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil { return err } } if tpl.Title != nil { - if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil { return err } } if tpl.Priority != nil { - renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody) + renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody) if err != nil { return err } @@ -84,16 +84,16 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo // renderTemplateFromParams transforms the JSON message body according to the inline template in the // message, title, and priority parameters. -func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error { +func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error { var err error - if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { + if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil { return err } - if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil { + if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil { return err } if priorityStr != "" { - renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody) + renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody) if err != nil { return err } @@ -105,7 +105,7 @@ func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, p } // renderTemplate renders a template with the given JSON source data. -func (s *Server) renderTemplate(name, tpl, source string) (string, error) { +func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) { var data any if err := json.Unmarshal([]byte(source), &data); err != nil { return "", errHTTPBadRequestTemplateMessageNotJSON @@ -117,11 +117,15 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) { if templateUsesDisallowedFeatures(t) { return "", errHTTPBadRequestTemplateDisallowedFunctionCalls } - t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp) + // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body + // has already been read, so a slow upload is not counted against it. Deriving from the request + // context means a client disconnect aborts the render too. + execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime) + defer cancel() var buf bytes.Buffer limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes)) - if err := t.Execute(limitWriter, data); err != nil { - if errors.Is(err, gotext.ErrExecutionInterrupted) { + if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil { + if errors.Is(err, context.DeadlineExceeded) { return "", errHTTPBadRequestTemplateExecutionTimeout } return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) diff --git a/server/server_test.go b/server/server_test.go index 6697ad4d..856f7fc6 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3764,9 +3764,8 @@ func (b *slowBody) Close() error { return nil } func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) { s := newTestServer(t, newTestConfig(t, "")) start := time.Now() - // The loop makes the template execute enough nodes (>256) to actually hit the deadline check, - // so this test distinguishes correct behavior from a deadline that includes upload time -- yet - // it runs in ~1ms, far under the deadline, so on correct code it renders fine. + // The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the + // point is that the deadline starts at execution, not when the (slow) upload began. response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{ "Template": "yes", "X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`, @@ -3780,6 +3779,40 @@ func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing. require.Equal(t, "hello bar", m.Message) } +// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request +// context (e.g. the client disconnecting) aborts an in-progress template render. The execution +// deadline is raised well above the cancel delay for this test so that cancellation -- not the +// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort +// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045), +// not the timeout code (40055). +// +// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel +// tests run in their own phase (parallel tests are paused), so the override is race-free. +func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) { + origDeadline := templateMaxExecutionTime + templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render + defer func() { templateMaxExecutionTime = origDeadline }() + + s := newTestServer(t, newTestConfig(t, "")) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(500 * time.Millisecond) + cancel() + }() + start := time.Now() + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`, + "X-Template": "1", + }, func(r *http.Request) { + *r = *r.WithContext(ctx) + }) + elapsed := time.Since(start) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055") + require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed) + require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed) +} + func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() diff --git a/template/gotext/README.md b/template/gotext/README.md index 4d8f3dc6..7f462cb2 100644 --- a/template/gotext/README.md +++ b/template/gotext/README.md @@ -1,8 +1,8 @@ -# `template/gotext/` -- vendored `text/template` with an execution deadline +# `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 a wall-clock execution deadline. 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)) +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` @@ -14,7 +14,8 @@ from burning CPU. 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) was declined). So a crafted template +([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). @@ -22,13 +23,17 @@ writes no output) can run for tens of seconds on a single request. That is a CPU 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 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`) 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. +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 @@ -36,7 +41,7 @@ config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not |------|--------| | `*.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) | +| `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) @@ -49,19 +54,26 @@ plain import. ## The patch `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`: +`0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`: -- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a - `SetExecutionDeadline(time.Time)` method on `Template` -- adds the amortized deadline check at the top of `state.walk` -- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`) +- 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. +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 (deadline logic only, on two stable files) is deliberate: it makes re-basing +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) diff --git a/template/gotext/exec.go b/template/gotext/exec.go index cfb92515..b4ef0ca3 100644 --- a/template/gotext/exec.go +++ b/template/gotext/exec.go @@ -5,14 +5,15 @@ package gotext import ( + "context" "errors" "fmt" "io" "reflect" "runtime" "strings" + "sync/atomic" "text/template/parse" - "time" "heckel.io/ntfy/v2/template/gotext/fmtsort" ) @@ -34,13 +35,13 @@ func initMaxExecDepth() int { // template so that multiple executions of the same template // can execute in parallel. type state struct { - tmpl *Template - 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. - deadline time.Time // ntfy: wall-clock bail-out; zero means no limit - steps int64 // ntfy: node counter for amortized deadline checks + 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 } // variable holds the dynamic value of a variable such as $, $x etc. @@ -135,10 +136,6 @@ func (e ExecError) Unwrap() error { return e.Err } -// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the -// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition) -var ErrExecutionInterrupted = errors.New("template execution interrupted") - // errorf records an ExecError and terminates processing. func (s *state) errorf(format string, args ...any) { name := doublePercent(s.tmpl.Name()) @@ -168,6 +165,14 @@ func (s *state) writeError(err error) { }) } +// cancelError is the wrapper type used internally when execution is aborted +// because the context is done. Like writeError, it is stripped in errRecover +// so the caller receives the original ctx.Err(). It is not an implementation +// of error, so it cannot escape from the package as an error value. +type cancelError struct { + Err error // Original context error. +} + // errRecover is the handler that turns panics into returns from the top // level of Parse. func errRecover(errp *error) { @@ -178,6 +183,8 @@ func errRecover(errp *error) { panic(e) case writeError: *errp = err.Err // Strip the wrapper. + case cancelError: + *errp = err.Err // Strip the wrapper; return the context error. case ExecError: *errp = err // Keep the wrapper. default: @@ -194,11 +201,19 @@ func errRecover(errp *error) { // A template may be executed safely in parallel, although if parallel // executions share a Writer the output may be interleaved. func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error { + return t.ExecuteTemplateContext(context.Background(), wr, name, data) +} + +// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and +// returns ctx.Err() if ctx is canceled or its deadline is exceeded before +// execution completes. See [Template.ExecuteContext] for the cancellation +// semantics. +func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error { tmpl := t.Lookup(name) if tmpl == nil { return fmt.Errorf("template: no template %q associated with template %q", name, t.name) } - return tmpl.Execute(wr, data) + return tmpl.ExecuteContext(ctx, wr, data) } // Execute applies a parsed template to the specified data object, @@ -212,20 +227,47 @@ func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error { // If data is a [reflect.Value], the template applies to the concrete // value that the reflect.Value holds, as in [fmt.Print]. func (t *Template) Execute(wr io.Writer, data any) error { - return t.execute(wr, data) + return t.executeContext(context.Background(), wr, data) } -func (t *Template) execute(wr io.Writer, data any) (err error) { +// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err() +// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with +// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution +// completes. +// +// Cancellation is observed between node evaluations as the template is walked, +// so long-running renders -- including tight or nested {{range}} loops that +// write no output -- are aborted promptly. A template blocked inside a single +// function call is not interrupted until that call returns. Partial results may +// already have been written to wr. +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) +} + +func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) { defer errRecover(&err) value, ok := data.(reflect.Value) if !ok { value = reflect.ValueOf(data) } state := &state{ - tmpl: t, - wr: wr, - vars: []variable{{"$", value}}, - deadline: t.deadline, // ntfy: wall-clock execution bail-out + tmpl: t, + ctx: ctx, + wr: wr, + vars: []variable{{"$", value}}, + } + // 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. + // Contexts that can never be canceled (Background, TODO) have a nil Done + // channel, so the default Execute path installs nothing and pays nothing. + if ctx.Done() != nil { + state.cancelled = new(atomic.Bool) + stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) }) + defer stop() } if t.Tree == nil || t.Root == nil { state.errorf("%q is an incomplete or empty template", t.Name()) @@ -269,10 +311,11 @@ var ( // generating output as they go. func (s *state) walk(dot reflect.Value, node parse.Node) { s.at(node) - // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates - // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp. - if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) { - s.errorf("execution interrupted: %w", ErrExecutionInterrupted) + // Abort if the context has been canceled or its deadline has passed. The + // flag is set by the watcher installed in executeContext; observing it here + // interrupts any template shape, including loops that write no output. + if s.cancelled != nil && s.cancelled.Load() { + panic(cancelError{s.ctx.Err()}) } switch node := node.(type) { case *parse.ActionNode: diff --git a/template/gotext/patches/0001-exec-context.patch b/template/gotext/patches/0001-exec-context.patch new file mode 100644 index 00000000..2256edd1 --- /dev/null +++ b/template/gotext/patches/0001-exec-context.patch @@ -0,0 +1,149 @@ +--- a/exec.go 2026-07-10 01:31:35.188129862 +0200 ++++ b/exec.go 2026-07-10 01:31:35.189129894 +0200 +@@ -5,14 +5,17 @@ + package gotext + + import ( ++ "context" + "errors" + "fmt" +- "heckel.io/ntfy/v2/template/gotext/fmtsort" + "io" + "reflect" + "runtime" + "strings" ++ "sync/atomic" + "text/template/parse" ++ ++ "heckel.io/ntfy/v2/template/gotext/fmtsort" + ) + + // maxExecDepth specifies the maximum stack depth of templates within +@@ -32,11 +35,13 @@ + // template so that multiple executions of the same template + // can execute in parallel. + type state struct { +- tmpl *Template +- 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. ++ 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 + } + + // variable holds the dynamic value of a variable such as $, $x etc. +@@ -160,6 +165,14 @@ + }) + } + ++// cancelError is the wrapper type used internally when execution is aborted ++// because the context is done. Like writeError, it is stripped in errRecover ++// so the caller receives the original ctx.Err(). It is not an implementation ++// of error, so it cannot escape from the package as an error value. ++type cancelError struct { ++ Err error // Original context error. ++} ++ + // errRecover is the handler that turns panics into returns from the top + // level of Parse. + func errRecover(errp *error) { +@@ -170,6 +183,8 @@ + panic(e) + case writeError: + *errp = err.Err // Strip the wrapper. ++ case cancelError: ++ *errp = err.Err // Strip the wrapper; return the context error. + case ExecError: + *errp = err // Keep the wrapper. + default: +@@ -186,11 +201,19 @@ + // A template may be executed safely in parallel, although if parallel + // executions share a Writer the output may be interleaved. + func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error { ++ return t.ExecuteTemplateContext(context.Background(), wr, name, data) ++} ++ ++// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and ++// returns ctx.Err() if ctx is canceled or its deadline is exceeded before ++// execution completes. See [Template.ExecuteContext] for the cancellation ++// semantics. ++func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error { + tmpl := t.Lookup(name) + if tmpl == nil { + return fmt.Errorf("template: no template %q associated with template %q", name, t.name) + } +- return tmpl.Execute(wr, data) ++ return tmpl.ExecuteContext(ctx, wr, data) + } + + // Execute applies a parsed template to the specified data object, +@@ -204,10 +227,27 @@ + // If data is a [reflect.Value], the template applies to the concrete + // value that the reflect.Value holds, as in [fmt.Print]. + func (t *Template) Execute(wr io.Writer, data any) error { +- return t.execute(wr, data) ++ return t.executeContext(context.Background(), wr, data) + } + +-func (t *Template) execute(wr io.Writer, data any) (err error) { ++// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err() ++// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with ++// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution ++// completes. ++// ++// Cancellation is observed between node evaluations as the template is walked, ++// so long-running renders -- including tight or nested {{range}} loops that ++// write no output -- are aborted promptly. A template blocked inside a single ++// function call is not interrupted until that call returns. Partial results may ++// already have been written to wr. ++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) ++} ++ ++func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) { + defer errRecover(&err) + value, ok := data.(reflect.Value) + if !ok { +@@ -215,9 +255,20 @@ + } + state := &state{ + tmpl: t, ++ ctx: ctx, + wr: wr, + vars: []variable{{"$", value}}, + } ++ // 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. ++ // Contexts that can never be canceled (Background, TODO) have a nil Done ++ // channel, so the default Execute path installs nothing and pays nothing. ++ if ctx.Done() != nil { ++ state.cancelled = new(atomic.Bool) ++ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) }) ++ defer stop() ++ } + if t.Tree == nil || t.Root == nil { + state.errorf("%q is an incomplete or empty template", t.Name()) + } +@@ -260,6 +311,12 @@ + // generating output as they go. + 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. The ++ // flag is set by the watcher installed in executeContext; observing it here ++ // interrupts any template shape, including loops that write no output. ++ if s.cancelled != nil && s.cancelled.Load() { ++ panic(cancelError{s.ctx.Err()}) ++ } + switch node := node.(type) { + case *parse.ActionNode: + // Do not pop variables so they persist until next end. diff --git a/template/gotext/patches/0001-exec-deadline.patch b/template/gotext/patches/0001-exec-deadline.patch deleted file mode 100644 index 07000d1e..00000000 --- a/template/gotext/patches/0001-exec-deadline.patch +++ /dev/null @@ -1,113 +0,0 @@ -diff -ruN a/exec.go b/exec.go ---- a/exec.go 2026-07-08 21:46:30.952555712 +0200 -+++ b/exec.go 2026-07-08 21:46:30.953912265 +0200 -@@ -7,12 +7,14 @@ - import ( - "errors" - "fmt" -- "heckel.io/ntfy/v2/template/gotext/fmtsort" - "io" - "reflect" - "runtime" - "strings" - "text/template/parse" -+ "time" -+ -+ "heckel.io/ntfy/v2/template/gotext/fmtsort" - ) - - // maxExecDepth specifies the maximum stack depth of templates within -@@ -32,11 +34,13 @@ - // template so that multiple executions of the same template - // can execute in parallel. - type state struct { -- tmpl *Template -- 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. -+ tmpl *Template -+ 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. -+ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit -+ steps int64 // ntfy: node counter for amortized deadline checks - } - - // variable holds the dynamic value of a variable such as $, $x etc. -@@ -131,6 +135,10 @@ - return e.Err - } - -+// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the -+// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition) -+var ErrExecutionInterrupted = errors.New("template execution interrupted") -+ - // errorf records an ExecError and terminates processing. - func (s *state) errorf(format string, args ...any) { - name := doublePercent(s.tmpl.Name()) -@@ -214,9 +222,10 @@ - value = reflect.ValueOf(data) - } - state := &state{ -- tmpl: t, -- wr: wr, -- vars: []variable{{"$", value}}, -+ tmpl: t, -+ wr: wr, -+ vars: []variable{{"$", value}}, -+ deadline: t.deadline, // ntfy: wall-clock execution bail-out - } - if t.Tree == nil || t.Root == nil { - state.errorf("%q is an incomplete or empty template", t.Name()) -@@ -260,6 +269,11 @@ - // generating output as they go. - func (s *state) walk(dot reflect.Value, node parse.Node) { - s.at(node) -+ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates -+ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp. -+ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) { -+ s.errorf("execution interrupted: %w", ErrExecutionInterrupted) -+ } - switch node := node.(type) { - case *parse.ActionNode: - // Do not pop variables so they persist until next end. -diff -ruN a/template.go b/template.go ---- a/template.go 2026-07-08 21:46:30.952848382 +0200 -+++ b/template.go 2026-07-08 21:46:30.953952891 +0200 -@@ -9,13 +9,15 @@ - "reflect" - "sync" - "text/template/parse" -+ "time" - ) - - // common holds the information shared by related templates. - type common struct { -- tmpl map[string]*Template // Map from name to defined templates. -- muTmpl sync.RWMutex // protects tmpl -- option option -+ tmpl map[string]*Template // Map from name to defined templates. -+ muTmpl sync.RWMutex // protects tmpl -+ option option -+ deadline time.Time // ntfy: wall-clock execution deadline (zero = none) - // We use two maps, one for parsing and one for execution. - // This separation makes the API cleaner since it doesn't - // expose reflection to the client. -@@ -49,6 +51,15 @@ - return t.name - } - -+// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping -+// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates -+// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.) -+func (t *Template) SetExecutionDeadline(deadline time.Time) *Template { -+ t.init() -+ t.deadline = deadline -+ return t -+} -+ - // New allocates a new, undefined template associated with the given one and with the same - // delimiters. The association, which is transitive, allows one template to - // invoke another with a {{template}} action. diff --git a/template/gotext/template.go b/template/gotext/template.go index cbadd1e0..f0e70efa 100644 --- a/template/gotext/template.go +++ b/template/gotext/template.go @@ -9,15 +9,13 @@ import ( "reflect" "sync" "text/template/parse" - "time" ) // common holds the information shared by related templates. type common struct { - tmpl map[string]*Template // Map from name to defined templates. - muTmpl sync.RWMutex // protects tmpl - option option - deadline time.Time // ntfy: wall-clock execution deadline (zero = none) + tmpl map[string]*Template // Map from name to defined templates. + muTmpl sync.RWMutex // protects tmpl + option option // We use two maps, one for parsing and one for execution. // This separation makes the API cleaner since it doesn't // expose reflection to the client. @@ -51,15 +49,6 @@ func (t *Template) Name() string { return t.name } -// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping -// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates -// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.) -func (t *Template) SetExecutionDeadline(deadline time.Time) *Template { - t.init() - t.deadline = deadline - return t -} - // New allocates a new, undefined template associated with the given one and with the same // delimiters. The association, which is transitive, allows one template to // invoke another with a {{template}} action.