From dde07adbdc9c020a6c163a420f68121688d970a0 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 19 Jul 2025 16:46:53 +0200 Subject: [PATCH] Add some limits --- docs/publish.md | 15 ++++++++++----- server/errors.go | 1 - server/server.go | 6 ++++-- server/server_test.go | 36 ++++++++++++++++++++++++++++++++++++ util/sprig/defaults.go | 2 +- util/sprig/functions.go | 7 ++++++- util/sprig/numeric.go | 10 +++++++++- util/sprig/strings.go | 9 +++++++++ util/timeout_writer.go | 4 ++-- 9 files changed, 77 insertions(+), 13 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 745c99c4..dc124dbc 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -969,15 +969,20 @@ To learn the basics of Go's templating language, please see [template syntax](#t ### Pre-defined templates When `X-Template: ` (aliases: `Template: `, `Tpl: `) or `?template=` is set, ntfy will transform the -message and/or title based on one of the built-in pre-defined templates +message and/or title based on one of the built-in pre-defined templates. The following **pre-defined templates** are available: -* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment) -* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts) -* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts) +* `github`: Formats a subset of [GitHub webhook](https://docs.github.com/en/webhooks/about-webhooks) payloads (PRs, issues, new star, new watcher, new comment). See [github.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/github.yml). +* `grafana`: Formats [Grafana webhook](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) payloads (firing/resolved alerts). See [grafana.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/grafana.yml). +* `alertmanager`: Formats [Alertmanager webhook](https://prometheus.io/docs/alerting/latest/configuration/#webhook_config) payloads (firing/resolved alerts). See [alertmanager.yml](https://github.com/binwiederhier/ntfy/blob/main/server/templates/alertmanager.yml). -Here's an example of how to use the pre-defined `github` template: First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`. +To override the pre-defined templates, you can place a file with the same name in the template directory (defaults to `/etc/ntfy/templates`, +can be overridden with `template-dir`). See [custom templates](#custom-templates) for more details. + +Here's an example of how to use the **pre-defined `github` template**: + +First, configure the webhook in GitHub to send a webhook to your ntfy topic, e.g. `https://ntfy.sh/mytopic?template=github`.
![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 }
GitHub webhook configuration
diff --git a/server/errors.go b/server/errors.go index fa504410..c6745779 100644 --- a/server/errors.go +++ b/server/errors.go @@ -123,7 +123,6 @@ var ( 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} errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil} - errHTTPBadRequestTemplateDirectoryNotConfigured = &errHTTP{40046, http.StatusBadRequest, "invalid request: template directory not configured", "https://ntfy.sh/docs/publish/#message-templating", 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} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} diff --git a/server/server.go b/server/server.go index 7bad3fde..0d69e068 100644 --- a/server/server.go +++ b/server/server.go @@ -145,6 +145,7 @@ const ( 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 ) @@ -1127,7 +1128,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM return err } } - if len(m.Message) > s.config.MessageSizeLimit { + if len(m.Title) > s.config.MessageSizeLimit || len(m.Message) > s.config.MessageSizeLimit { return errHTTPBadRequestTemplateMessageTooLarge } return nil @@ -1188,7 +1189,8 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) { return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) } var buf bytes.Buffer - if err := t.Execute(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), data); err != nil { + limitWriter := util.NewLimitWriter(util.NewTimeoutWriter(&buf, templateMaxExecutionTime), util.NewFixedLimiter(templateMaxOutputBytes)) + if err := t.Execute(limitWriter, data); err != nil { return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error()) } return strings.TrimSpace(buf.String()), nil diff --git a/server/server_test.go b/server/server_test.go index ad2bb8fd..36bbae3f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3143,6 +3143,42 @@ message: | require.Equal(t, "Custom message 1391", m.Message) } +func TestServer_MessageTemplate_Repeat9999_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 9999 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40041, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "message or title is too large after replacing template") +} + +func TestServer_MessageTemplate_Repeat10001_TooLarge(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ repeat 10001 "mystring" }}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "repeat count 10001 exceeds limit of 10000") +} + +func TestServer_MessageTemplate_Until100_000(t *testing.T) { + t.Parallel() + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{ + "X-Message": `{{ range $i, $e := until 100_000 }}{{end}}`, + "X-Template": "1", + }) + require.Equal(t, 400, response.Code) + require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code) + require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations") +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/util/sprig/defaults.go b/util/sprig/defaults.go index 7dcf7450..71c3e61b 100644 --- a/util/sprig/defaults.go +++ b/util/sprig/defaults.go @@ -132,7 +132,7 @@ func toRawJSON(v any) string { if err != nil { panic(err) } - return string(output) + return output } // mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. diff --git a/util/sprig/functions.go b/util/sprig/functions.go index 10ededd6..c9b9f86b 100644 --- a/util/sprig/functions.go +++ b/util/sprig/functions.go @@ -14,6 +14,11 @@ import ( "time" ) +const ( + loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long + stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues +) + // TxtFuncMap produces the function map. // // Use this to pass the functions into the template engine: @@ -58,7 +63,7 @@ var genericMap = map[string]any{ }, "substr": substring, // Switch order so that "foo" | repeat 5 - "repeat": func(count int, str string) string { return strings.Repeat(str, count) }, + "repeat": repeat, "trimAll": func(a, b string) string { return strings.Trim(b, a) }, "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, diff --git a/util/sprig/numeric.go b/util/sprig/numeric.go index e41f61f5..901fe3f3 100644 --- a/util/sprig/numeric.go +++ b/util/sprig/numeric.go @@ -127,7 +127,15 @@ func until(count int) []int { } func untilStep(start, stop, step int) []int { - v := []int{} + var v []int + if step == 0 { + return v + } + + iterations := math.Abs(float64(stop)-float64(start)) / float64(step) + if iterations > loopExecutionLimit { + panic(fmt.Sprintf("too many iterations in untilStep; max allowed is %d, got %f", loopExecutionLimit, iterations)) + } if stop < start { if step >= 0 { diff --git a/util/sprig/strings.go b/util/sprig/strings.go index 911aa6f4..11459a4b 100644 --- a/util/sprig/strings.go +++ b/util/sprig/strings.go @@ -187,3 +187,12 @@ func substring(start, end int, s string) string { } return s[start:end] } + +func repeat(count int, str string) string { + if count > loopExecutionLimit { + panic(fmt.Sprintf("repeat count %d exceeds limit of %d", count, loopExecutionLimit)) + } else if count*len(str) >= stringLengthLimit { + panic(fmt.Sprintf("repeat count %d with string length %d exceeds limit of %d", count, len(str), stringLengthLimit)) + } + return strings.Repeat(str, count) +} diff --git a/util/timeout_writer.go b/util/timeout_writer.go index 370068c4..d531916d 100644 --- a/util/timeout_writer.go +++ b/util/timeout_writer.go @@ -7,7 +7,7 @@ import ( ) // ErrWriteTimeout is returned when a write timed out -var ErrWriteTimeout = errors.New("write operation failed due to timeout since creation") +var ErrWriteTimeout = errors.New("write operation failed due to timeout") // TimeoutWriter wraps an io.Writer that will time out after the given timeout type TimeoutWriter struct { @@ -28,7 +28,7 @@ func NewTimeoutWriter(w io.Writer, timeout time.Duration) *TimeoutWriter { // Write implements the io.Writer interface, failing if called after the timeout period from creation. func (tw *TimeoutWriter) Write(p []byte) (n int, err error) { if time.Since(tw.start) > tw.timeout { - return 0, errors.New("write operation failed due to timeout since creation") + return 0, ErrWriteTimeout } return tw.writer.Write(p) }