mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
Merge branch 'main' into cluster2
This commit is contained in:
@@ -148,6 +148,7 @@ var (
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
||||
+3
-27
@@ -153,17 +153,6 @@ var (
|
||||
//go:embed docs
|
||||
docsStaticFs embed.FS
|
||||
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
|
||||
|
||||
//go:embed templates
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
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 (
|
||||
@@ -177,8 +166,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
|
||||
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
|
||||
)
|
||||
|
||||
// WebSocket constants
|
||||
@@ -938,18 +925,7 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery to
|
||||
// local subscribers and the cross-node broadcast (which always happen).
|
||||
type dispatchOpts struct {
|
||||
firebase bool // Send to Firebase (if configured)
|
||||
email string // Send an email to this address (if a mailer is configured)
|
||||
call string // Call this phone number (if Twilio is configured)
|
||||
upstream bool // Forward a poll request to the upstream server (if configured)
|
||||
webPush bool // Publish to web push endpoints (if configured)
|
||||
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
|
||||
}
|
||||
|
||||
// dispatch delivers m to local subscribers, relays it to peer cluster nodes, and fires the
|
||||
// dispatch delivers m to local subscribers, forwards it to peer cluster nodes, and fires the
|
||||
// requested side-effect targets. It is the single choke point through which every published
|
||||
// message must pass; t may be nil when the topic has no local subscribers (delayed sender).
|
||||
//
|
||||
@@ -969,9 +945,9 @@ func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchO
|
||||
return err
|
||||
}
|
||||
}
|
||||
// ForwardMessage to peer cluster nodes, whose subscribers do not show up in this node's topics map
|
||||
// Forward to peer cluster nodes, whose subscribers do not show up in this node's topics map
|
||||
if err := s.cluster.ForwardMessage(m); err != nil {
|
||||
logvm(v, m).Err(err).Warn("Cluster: unable to relay message to peer nodes")
|
||||
logvm(v, m).Err(err).Warn("Cluster: unable to forward message to peer nodes")
|
||||
}
|
||||
// Fire the requested side-effect targets
|
||||
if s.firebaseClient != nil && opts.firebase {
|
||||
|
||||
@@ -3,12 +3,16 @@ package server
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template/parse"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
@@ -17,6 +21,32 @@ import (
|
||||
"heckel.io/ntfy/v2/util/sprig"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates
|
||||
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
|
||||
templatesDir = "templates"
|
||||
|
||||
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
|
||||
|
||||
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
|
||||
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
|
||||
// scans the flag/width/precision characters after a % without requiring a well-formed
|
||||
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
|
||||
// unrecognized must still be caught.
|
||||
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
|
||||
|
||||
// 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 (
|
||||
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
|
||||
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
|
||||
templateFileExtension = ".yml" // Template files must end with this extension
|
||||
)
|
||||
|
||||
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 {
|
||||
@@ -106,11 +136,14 @@ func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message,
|
||||
|
||||
// renderTemplate renders a template with the given JSON source data.
|
||||
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
|
||||
if len(tpl) > templateMaxTemplateBytes {
|
||||
return "", errHTTPBadRequestTemplateTooLarge
|
||||
}
|
||||
var data any
|
||||
if err := json.Unmarshal([]byte(source), &data); err != nil {
|
||||
return "", errHTTPBadRequestTemplateMessageNotJSON
|
||||
}
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl)
|
||||
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
|
||||
if err != nil {
|
||||
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
|
||||
}
|
||||
@@ -168,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
|
||||
case *parse.TemplateNode: // {{template}} or {{block}} invocation
|
||||
return true
|
||||
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
|
||||
return treeContainsDisallowedNode(n.Node)
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return false
|
||||
@@ -188,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
|
||||
// allows widths and precisions up to 1e6 per verb, so a small template like
|
||||
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
|
||||
// executor's cancellation context is only checked between template nodes, never inside one.
|
||||
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
|
||||
// takes the width from an argument. Combined with the template size limit, this bounds a single
|
||||
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
|
||||
// at call time so a format string assembled during execution is covered too.
|
||||
func templatePrintf(format string, args ...any) (string, error) {
|
||||
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
|
||||
return "", errors.New("printf width or precision too large")
|
||||
}
|
||||
return fmt.Sprintf(format, args...), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
|
||||
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
|
||||
// the executor's context is never checked; the printf guard must reject the call before
|
||||
// fmt runs, not after the limit writer sees the output
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// The format string is assembled at execution time, so the guard must inspect the actual
|
||||
// argument, not the template source
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// Star widths take the width from an argument; sprig's math functions (int64 results)
|
||||
// make large integer arguments reachable from a template
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
|
||||
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Contains(t, response.Body.String(), "printf width or precision too large")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
|
||||
"X-Message": `{{printf "%05d" 7}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_templatePrintf(t *testing.T) {
|
||||
tests := []struct {
|
||||
format string
|
||||
args []any
|
||||
want string // Empty means the call must be rejected
|
||||
}{
|
||||
{"%d", []any{5}, "5"},
|
||||
{"%05d", []any{5}, "00005"},
|
||||
{"%-8.3f|", []any{1.5}, "1.500 |"},
|
||||
{"%1000d", []any{1}, ""}, // Rejected: four digits
|
||||
{"%.1000s", []any{"x"}, ""},
|
||||
{"%*d", []any{500, 1}, ""}, // Rejected: star width
|
||||
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
|
||||
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
|
||||
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
|
||||
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
|
||||
}
|
||||
for _, test := range tests {
|
||||
out, err := templatePrintf(test.format, test.args...)
|
||||
if test.want == "" {
|
||||
require.Error(t, err, "format %q must be rejected", test.format)
|
||||
require.Contains(t, err.Error(), "too large")
|
||||
} else {
|
||||
require.Nil(t, err, "format %q", test.format)
|
||||
require.Equal(t, test.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The largest allowed width still produces bounded output
|
||||
out, err := templatePrintf("%999d", 1)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, out, 999)
|
||||
}
|
||||
|
||||
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
|
||||
// function value, so this cannot be exploited today, but the ban must catch every
|
||||
// syntactic form rather than relying on the call failing at runtime.
|
||||
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
|
||||
"X-Message": `{{(call .fn).x}}`,
|
||||
"X-Template": "1",
|
||||
})
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,17 @@ type publishMessage struct {
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
|
||||
// to local subscribers and the cross-node forward, which always happen (see Server.dispatch)
|
||||
type dispatchOpts struct {
|
||||
firebase bool // Send to Firebase (if configured)
|
||||
email string // Send an email to this address (if a mailer is configured)
|
||||
call string // Call this phone number (if Twilio is configured)
|
||||
upstream bool // Forward a poll request to the upstream server (if configured)
|
||||
webPush bool // Publish to web push endpoints (if configured)
|
||||
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *model.Message) (string, error)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user