Template exec context, redone

This commit is contained in:
binwiederhier
2026-07-10 13:18:11 +02:00
parent 1e4e3b6e36
commit 3f56dae54a
8 changed files with 310 additions and 189 deletions
+6 -2
View File
@@ -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
}
+20 -16
View File
@@ -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())
+36 -3
View File
@@ -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()