1
0
Fork 0
mirror of https://github.com/binwiederhier/ntfy.git synced 2025-09-25 21:10:18 +02:00

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
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:
* `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`.
<figure markdown>
![GitHub webhook config](static/img/screenshot-github-webhook-config.png){ width=600 }
<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}
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}

View file

@ -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

View file

@ -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"

View file

@ -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.

View file

@ -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) },

View file

@ -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 {

View file

@ -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)
}

View file

@ -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)
}