Template execution rework

This commit is contained in:
binwiederhier
2026-07-08 21:08:07 +02:00
parent 7b54850c16
commit a001ac5195
20 changed files with 2795 additions and 48 deletions
+1
View File
@@ -138,6 +138,7 @@ var (
errHTTPBadRequestTemplateInvalid = &errHTTP{40043, http.StatusBadRequest, "invalid request: could not parse template", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateDisallowedFunctionCalls = &errHTTP{40044, http.StatusBadRequest, "invalid request: template contains disallowed function calls, e.g. template, call, or define", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecuteFailed = &errHTTP{40045, http.StatusBadRequest, "invalid request: template execution failed", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateExecutionTimeout = &errHTTP{40055, http.StatusBadRequest, "invalid request: template execution timed out", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
+9 -3
View File
@@ -3,13 +3,15 @@ package server
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"text/template"
"time"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/template/gotext"
"heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/util/sprig"
)
@@ -110,13 +112,17 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) {
if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON
}
t, err := template.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp)
var buf bytes.Buffer
limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes))
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil {
if errors.Is(err, gotext.ErrExecutionInterrupted) {
return "", errHTTPBadRequestTemplateExecutionTimeout
}
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
}
return strings.TrimSpace(strings.ReplaceAll(buf.String(), "\\n", "\n")), nil // replace any remaining "\n" (those outside of template curly braces) with newlines
+78
View File
@@ -3635,6 +3635,84 @@ func TestServer_MessageTemplate_Range(t *testing.T) {
})
}
func TestServer_MessageTemplate_ExecutionTimeout(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Nested range over a 1000-element JSON field with a no-output body: no Write ever happens,
// so the write-triggered TimeoutWriter never fires. Must be bounded by the executor's
// wall-clock deadline instead (GHSA-rhwf-xgc9-m9fp).
elems := make([]string, 1000)
for i := range elems {
elems[i] = "0"
}
jsonBody := `{"a":[` + strings.Join(elems, ",") + `]}`
msg := `{{range .a}}{{range $.a}}` + strings.Repeat(`{{$x := .}}`, 100) + `{{end}}{{end}}done`
start := time.Now()
response := request(t, s, "POST", "/mytopic", jsonBody, map[string]string{
"X-Message": msg,
"X-Template": "1",
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40055, toHTTPError(t, response.Body.String()).Code)
require.Less(t, elapsed, 500*time.Millisecond, "template must be interrupted by the deadline, not run to completion (took %s)", elapsed)
})
}
func TestServer_MessageTemplate_GenuineError_NotTimeout(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// A real runtime error (len of an int) must map to execute-failed, not the timeout code.
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{ len 5 }}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
})
}
// slowBody delivers its data after a delay, simulating a slow client upload of the request body.
type slowBody struct {
data []byte
delay time.Duration
done bool
}
func (b *slowBody) Read(p []byte) (int, error) {
if b.done {
return 0, io.EOF
}
time.Sleep(b.delay)
n := copy(p, b.data)
b.done = true
return n, nil
}
func (b *slowBody) Close() error { return nil }
// TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline verifies that a slow request-body
// upload does not consume the template execution deadline: the body is fully read (util.Peek)
// before the deadline starts, so a trivial template still renders even when the upload alone took
// longer than the deadline (GHSA-rhwf-xgc9-m9fp).
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
"Template": "yes",
"X-Message": "hello {{.foo}}",
}, func(r *http.Request) {
r.Body = &slowBody{data: []byte(`{"foo":"bar"}`), delay: 3 * templateMaxExecutionTime}
})
elapsed := time.Since(start)
require.Greater(t, elapsed, templateMaxExecutionTime, "the slow upload must outlast the exec deadline for this test to be meaningful")
require.Equal(t, 200, response.Code) // Would be 40055 if upload time counted against the deadline
m := toMessage(t, response.Body.String())
require.Equal(t, "hello bar", m.Message)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()