Add some limits

This commit is contained in:
binwiederhier 2025-07-19 16:46:53 +02:00
parent 57df16dd62
commit dde07adbdc
9 changed files with 77 additions and 13 deletions

View file

@ -969,15 +969,20 @@ To learn the basics of Go's templating language, please see [template syntax](#t
### Pre-defined templates ### Pre-defined templates
When `X-Template: <name>` (aliases: `Template: <name>`, `Tpl: <name>`) or `?template=<name>` is set, ntfy will transform the When `X-Template: <name>` (aliases: `Template: <name>`, `Tpl: <name>`) or `?template=<name>` 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: 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) * `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) * `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) * `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`.
<figure markdown> <figure markdown>
![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 } ![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 }
<figcaption>GitHub webhook configuration</figcaption> <figcaption>GitHub webhook configuration</figcaption>

View file

@ -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} 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} 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} 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} 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} 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} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}

View file

@ -145,6 +145,7 @@ const (
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 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 templateFileExtension = ".yml" // Template files must end with this extension
) )
@ -1127,7 +1128,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *message, template templateM
return err 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 errHTTPBadRequestTemplateMessageTooLarge
} }
return nil return nil
@ -1188,7 +1189,8 @@ func (s *Server) replaceTemplate(tpl string, source string) (string, error) {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
} }
var buf bytes.Buffer 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 "", errHTTPBadRequestTemplateExecuteFailed.Wrap("%s", err.Error())
} }
return strings.TrimSpace(buf.String()), nil return strings.TrimSpace(buf.String()), nil

View file

@ -3143,6 +3143,42 @@ message: |
require.Equal(t, "Custom message 1391", m.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 { func newTestConfig(t *testing.T) *Config {
conf := NewConfig() conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345" conf.BaseURL = "http://127.0.0.1:12345"

View file

@ -132,7 +132,7 @@ func toRawJSON(v any) string {
if err != nil { if err != nil {
panic(err) panic(err)
} }
return string(output) return output
} }
// mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters. // mustToRawJSON encodes an item into a JSON string with no escaping of HTML characters.

View file

@ -14,6 +14,11 @@ import (
"time" "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. // TxtFuncMap produces the function map.
// //
// Use this to pass the functions into the template engine: // Use this to pass the functions into the template engine:
@ -58,7 +63,7 @@ var genericMap = map[string]any{
}, },
"substr": substring, "substr": substring,
// Switch order so that "foo" | repeat 5 // 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) }, "trimAll": func(a, b string) string { return strings.Trim(b, a) },
"trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) }, "trimSuffix": func(a, b string) string { return strings.TrimSuffix(b, a) },
"trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) }, "trimPrefix": func(a, b string) string { return strings.TrimPrefix(b, a) },

View file

@ -127,7 +127,15 @@ func until(count int) []int {
} }
func untilStep(start, stop, step 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 stop < start {
if step >= 0 { if step >= 0 {

View file

@ -187,3 +187,12 @@ func substring(start, end int, s string) string {
} }
return s[start:end] 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)
}

View file

@ -7,7 +7,7 @@ import (
) )
// ErrWriteTimeout is returned when a write timed out // 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 // TimeoutWriter wraps an io.Writer that will time out after the given timeout
type TimeoutWriter struct { 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. // 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) { func (tw *TimeoutWriter) Write(p []byte) (n int, err error) {
if time.Since(tw.start) > tw.timeout { 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) return tw.writer.Write(p)
} }