mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Merge pull request #1830 from binwiederhier/template-exec-context
Template exec context, redone
This commit is contained in:
+6
-2
@@ -151,6 +151,11 @@ var (
|
|||||||
templatesDir = "templates"
|
templatesDir = "templates"
|
||||||
|
|
||||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
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 (
|
const (
|
||||||
@@ -164,7 +169,6 @@ const (
|
|||||||
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
|
||||||
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
|
||||||
messagesHistoryMax = 10 // Number of message count values to keep in memory
|
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
|
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
|
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 != "" {
|
} else if m.Attachment != nil && m.Attachment.Name != "" {
|
||||||
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
|
||||||
} else if template.Enabled() {
|
} 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) {
|
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
|
||||||
return s.handleBodyAsTextMessage(m, body) // Case 6
|
return s.handleBodyAsTextMessage(m, body) // Case 6
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-16
@@ -2,13 +2,13 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template/parse"
|
"text/template/parse"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
"heckel.io/ntfy/v2/model"
|
"heckel.io/ntfy/v2/model"
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"heckel.io/ntfy/v2/util/sprig"
|
"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))
|
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -26,11 +26,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
|
|||||||
}
|
}
|
||||||
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
peekedBody := strings.TrimSpace(string(body.PeekedBytes))
|
||||||
if template.FileMode() {
|
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
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil {
|
if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
|
||||||
return err
|
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.
|
// 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.
|
// 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) {
|
if !templateNameRegex.MatchString(templateName) {
|
||||||
return errHTTPBadRequestTemplateFileNotFound
|
return errHTTPBadRequestTemplateFileNotFound
|
||||||
}
|
}
|
||||||
@@ -61,17 +61,17 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
|
|||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
if tpl.Message != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tpl.Title != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if tpl.Priority != nil {
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// renderTemplateFromParams transforms the JSON message body according to the inline template in the
|
||||||
// message, title, and priority parameters.
|
// 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
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
if priorityStr != "" {
|
if priorityStr != "" {
|
||||||
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody)
|
renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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.
|
// 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
|
var data any
|
||||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||||
@@ -117,11 +117,15 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
|
|||||||
if templateUsesDisallowedFeatures(t) {
|
if templateUsesDisallowedFeatures(t) {
|
||||||
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
|
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
|
var buf bytes.Buffer
|
||||||
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
|
||||||
if err := t.Execute(limitWriter, data); err != nil {
|
if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
|
||||||
if errors.Is(err, gotext.ErrExecutionInterrupted) {
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
return "", errHTTPBadRequestTemplateExecutionTimeout
|
return "", errHTTPBadRequestTemplateExecutionTimeout
|
||||||
}
|
}
|
||||||
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
|
||||||
|
|||||||
+36
-3
@@ -3764,9 +3764,8 @@ func (b *slowBody) Close() error { return nil }
|
|||||||
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
|
||||||
s := newTestServer(t, newTestConfig(t, ""))
|
s := newTestServer(t, newTestConfig(t, ""))
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
// The loop makes the template execute enough nodes (>256) to actually hit the deadline check,
|
// The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
|
||||||
// so this test distinguishes correct behavior from a deadline that includes upload time -- yet
|
// point is that the deadline starts at execution, not when the (slow) upload began.
|
||||||
// it runs in ~1ms, far under the deadline, so on correct code it renders fine.
|
|
||||||
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||||
"Template": "yes",
|
"Template": "yes",
|
||||||
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
|
"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)
|
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) {
|
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
|
||||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
+30
-18
@@ -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
|
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
|
small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
|
||||||
**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
|
||||||
from burning CPU.
|
from burning CPU.
|
||||||
|
|
||||||
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
|
- **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`
|
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
|
**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
|
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
|
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
|
||||||
(GHSA-rhwf-xgc9-m9fp).
|
(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
|
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
|
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
|
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 cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
|
||||||
the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap
|
when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
|
||||||
loops and expensive functions alike -- by construction.
|
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
|
The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
|
||||||
with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator
|
`context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
|
||||||
config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied.
|
Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
|
||||||
|
are not user-supplied.
|
||||||
|
|
||||||
## What's here
|
## 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 |
|
| `*.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 |
|
| `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 |
|
| `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)
|
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
|
## The patch
|
||||||
|
|
||||||
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
|
`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
|
- adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
|
||||||
`SetExecutionDeadline(time.Time)` method on `Template`
|
- adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
|
||||||
- adds the amortized deadline check at the top of `state.walk`
|
`context.Background()` wrappers, so their behavior and cost are unchanged
|
||||||
- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`)
|
- 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 --
|
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
|
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
|
`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.
|
onto a new Go release cheap.
|
||||||
|
|
||||||
## Updating (when bumping the Go toolchain)
|
## Updating (when bumping the Go toolchain)
|
||||||
|
|||||||
+58
-15
@@ -5,14 +5,15 @@
|
|||||||
package gotext
|
package gotext
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"text/template/parse"
|
"text/template/parse"
|
||||||
"time"
|
|
||||||
|
|
||||||
"heckel.io/ntfy/v2/template/gotext/fmtsort"
|
"heckel.io/ntfy/v2/template/gotext/fmtsort"
|
||||||
)
|
)
|
||||||
@@ -35,12 +36,12 @@ func initMaxExecDepth() int {
|
|||||||
// can execute in parallel.
|
// can execute in parallel.
|
||||||
type state struct {
|
type state struct {
|
||||||
tmpl *Template
|
tmpl *Template
|
||||||
|
ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
|
||||||
wr io.Writer
|
wr io.Writer
|
||||||
node parse.Node // current node, for errors
|
node parse.Node // current node, for errors
|
||||||
vars []variable // push-down stack of variable values.
|
vars []variable // push-down stack of variable values.
|
||||||
depth int // the height of the stack of executing templates.
|
depth int // the height of the stack of executing templates.
|
||||||
deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
|
cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
|
||||||
steps int64 // ntfy: node counter for amortized deadline checks
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// variable holds the dynamic value of a variable such as $, $x etc.
|
// variable holds the dynamic value of a variable such as $, $x etc.
|
||||||
@@ -135,10 +136,6 @@ func (e ExecError) Unwrap() error {
|
|||||||
return e.Err
|
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.
|
// errorf records an ExecError and terminates processing.
|
||||||
func (s *state) errorf(format string, args ...any) {
|
func (s *state) errorf(format string, args ...any) {
|
||||||
name := doublePercent(s.tmpl.Name())
|
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
|
// errRecover is the handler that turns panics into returns from the top
|
||||||
// level of Parse.
|
// level of Parse.
|
||||||
func errRecover(errp *error) {
|
func errRecover(errp *error) {
|
||||||
@@ -178,6 +183,8 @@ func errRecover(errp *error) {
|
|||||||
panic(e)
|
panic(e)
|
||||||
case writeError:
|
case writeError:
|
||||||
*errp = err.Err // Strip the wrapper.
|
*errp = err.Err // Strip the wrapper.
|
||||||
|
case cancelError:
|
||||||
|
*errp = err.Err // Strip the wrapper; return the context error.
|
||||||
case ExecError:
|
case ExecError:
|
||||||
*errp = err // Keep the wrapper.
|
*errp = err // Keep the wrapper.
|
||||||
default:
|
default:
|
||||||
@@ -194,11 +201,19 @@ func errRecover(errp *error) {
|
|||||||
// A template may be executed safely in parallel, although if parallel
|
// A template may be executed safely in parallel, although if parallel
|
||||||
// executions share a Writer the output may be interleaved.
|
// executions share a Writer the output may be interleaved.
|
||||||
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
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)
|
tmpl := t.Lookup(name)
|
||||||
if tmpl == nil {
|
if tmpl == nil {
|
||||||
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
|
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,
|
// Execute applies a parsed template to the specified data object,
|
||||||
@@ -212,10 +227,27 @@ func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
|
|||||||
// If data is a [reflect.Value], the template applies to the concrete
|
// If data is a [reflect.Value], the template applies to the concrete
|
||||||
// value that the reflect.Value holds, as in [fmt.Print].
|
// value that the reflect.Value holds, as in [fmt.Print].
|
||||||
func (t *Template) Execute(wr io.Writer, data any) error {
|
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)
|
defer errRecover(&err)
|
||||||
value, ok := data.(reflect.Value)
|
value, ok := data.(reflect.Value)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -223,9 +255,19 @@ func (t *Template) execute(wr io.Writer, data any) (err error) {
|
|||||||
}
|
}
|
||||||
state := &state{
|
state := &state{
|
||||||
tmpl: t,
|
tmpl: t,
|
||||||
|
ctx: ctx,
|
||||||
wr: wr,
|
wr: wr,
|
||||||
vars: []variable{{"$", value}},
|
vars: []variable{{"$", value}},
|
||||||
deadline: t.deadline, // ntfy: wall-clock execution bail-out
|
}
|
||||||
|
// 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 {
|
if t.Tree == nil || t.Root == nil {
|
||||||
state.errorf("%q is an incomplete or empty template", t.Name())
|
state.errorf("%q is an incomplete or empty template", t.Name())
|
||||||
@@ -269,10 +311,11 @@ var (
|
|||||||
// generating output as they go.
|
// generating output as they go.
|
||||||
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
func (s *state) walk(dot reflect.Value, node parse.Node) {
|
||||||
s.at(node)
|
s.at(node)
|
||||||
// ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
|
// Abort if the context has been canceled or its deadline has passed. The
|
||||||
// (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
|
// flag is set by the watcher installed in executeContext; observing it here
|
||||||
if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
|
// interrupts any template shape, including loops that write no output.
|
||||||
s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
|
if s.cancelled != nil && s.cancelled.Load() {
|
||||||
|
panic(cancelError{s.ctx.Err()})
|
||||||
}
|
}
|
||||||
switch node := node.(type) {
|
switch node := node.(type) {
|
||||||
case *parse.ActionNode:
|
case *parse.ActionNode:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"text/template/parse"
|
"text/template/parse"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// common holds the information shared by related templates.
|
// common holds the information shared by related templates.
|
||||||
@@ -17,7 +16,6 @@ type common struct {
|
|||||||
tmpl map[string]*Template // Map from name to defined templates.
|
tmpl map[string]*Template // Map from name to defined templates.
|
||||||
muTmpl sync.RWMutex // protects tmpl
|
muTmpl sync.RWMutex // protects tmpl
|
||||||
option option
|
option option
|
||||||
deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
|
|
||||||
// We use two maps, one for parsing and one for execution.
|
// We use two maps, one for parsing and one for execution.
|
||||||
// This separation makes the API cleaner since it doesn't
|
// This separation makes the API cleaner since it doesn't
|
||||||
// expose reflection to the client.
|
// expose reflection to the client.
|
||||||
@@ -51,15 +49,6 @@ func (t *Template) Name() string {
|
|||||||
return t.name
|
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
|
// 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
|
// delimiters. The association, which is transitive, allows one template to
|
||||||
// invoke another with a {{template}} action.
|
// invoke another with a {{template}} action.
|
||||||
|
|||||||
Reference in New Issue
Block a user