From 46f34ca1e3e844f31ca8ac8e78142651a3c3283b Mon Sep 17 00:00:00 2001 From: nimbleghost <132819643+nimbleghost@users.noreply.github.com> Date: Fri, 2 Jun 2023 16:52:35 +0200 Subject: [PATCH] Add push service allowlist and topic limit --- server/errors.go | 2 ++ server/server_web_push.go | 27 ++++++++++++++ server/server_web_push_test.go | 66 +++++++++++++++++++++++----------- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/server/errors.go b/server/errors.go index d13e2969..c8d96edb 100644 --- a/server/errors.go +++ b/server/errors.go @@ -115,6 +115,8 @@ var ( errHTTPBadRequestPhoneNumberVerifyChannelInvalid = &errHTTP{40036, http.StatusBadRequest, "invalid request: verification channel must be 'sms' or 'call'", "https://ntfy.sh/docs/publish/#phone-calls", nil} errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil} errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", nil} + errHTTPBadRequestWebPushEndpointUnknown = &errHTTP{40039, http.StatusBadRequest, "invalid request: web push endpoint unknown", "", nil} + errHTTPBadRequestWebPushTopicCountTooHigh = &errHTTP{40040, http.StatusBadRequest, "invalid request: too many web push topic subscriptions", "", 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} diff --git a/server/server_web_push.go b/server/server_web_push.go index d7c28955..caccce92 100644 --- a/server/server_web_push.go +++ b/server/server_web_push.go @@ -4,18 +4,45 @@ import ( "encoding/json" "fmt" "net/http" + "regexp" "github.com/SherClockHolmes/webpush-go" "heckel.io/ntfy/log" "heckel.io/ntfy/user" ) +// test: https://regexr.com/7eqvl +// example urls: +// +// https://android.googleapis.com/XYZ +// https://fcm.googleapis.com/XYZ +// https://updates.push.services.mozilla.com/XYZ +// https://updates-autopush.stage.mozaws.net/XYZ +// https://updates-autopush.dev.mozaws.net/XYZ +// https://AAA.notify.windows.com/XYZ +// https://AAA.push.apple.com/XYZ +const ( + webPushEndpointAllowRegexStr = `^https:\/\/(android\.googleapis\.com|fcm\.googleapis\.com|updates\.push\.services\.mozilla\.com|updates-autopush\.stage\.mozaws\.net|updates-autopush\.dev\.mozaws\.net|.*\.notify\.windows\.com|.*\.push\.apple\.com)\/.*$` + webPushTopicSubscribeLimit = 50 +) + +var webPushEndpointAllowRegex = regexp.MustCompile(webPushEndpointAllowRegexStr) + func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { payload, err := readJSONWithLimit[webPushSubscriptionPayload](r.Body, jsonBodyBytesLimit, false) + if err != nil || payload.BrowserSubscription.Endpoint == "" || payload.BrowserSubscription.Keys.P256dh == "" || payload.BrowserSubscription.Keys.Auth == "" { return errHTTPBadRequestWebPushSubscriptionInvalid } + if !webPushEndpointAllowRegex.MatchString(payload.BrowserSubscription.Endpoint) { + return errHTTPBadRequestWebPushEndpointUnknown + } + + if len(payload.Topics) > webPushTopicSubscribeLimit { + return errHTTPBadRequestWebPushTopicCountTooHigh + } + u := v.User() topics, err := s.topicsFromIDs(payload.Topics...) diff --git a/server/server_web_push_test.go b/server/server_web_push_test.go index 0086b794..29d91f7e 100644 --- a/server/server_web_push_test.go +++ b/server/server_web_push_test.go @@ -16,10 +16,14 @@ import ( "heckel.io/ntfy/util" ) +const ( + defaultEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" +) + func TestServer_WebPush_TopicAdd(t *testing.T) { s := newTestServer(t, newTestConfigWithWebPush(t)) - response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil) + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}, defaultEndpoint), nil) require.Equal(t, 200, response.Code) require.Equal(t, `{"success":true}`+"\n", response.Body.String()) @@ -27,19 +31,40 @@ func TestServer_WebPush_TopicAdd(t *testing.T) { require.Nil(t, err) require.Len(t, subs, 1) - require.Equal(t, subs[0].BrowserSubscription.Endpoint, "https://example.com/webpush") + require.Equal(t, subs[0].BrowserSubscription.Endpoint, defaultEndpoint) require.Equal(t, subs[0].BrowserSubscription.Keys.P256dh, "p256dh-key") require.Equal(t, subs[0].BrowserSubscription.Keys.Auth, "auth-key") require.Equal(t, subs[0].UserID, "") } +func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String()) +} + +func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { + s := newTestServer(t, newTestConfigWithWebPush(t)) + + topicList := make([]string, 51) + for i := range topicList { + topicList[i] = util.RandomString(5) + } + + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, topicList, defaultEndpoint), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String()) +} + func TestServer_WebPush_TopicUnsubscribe(t *testing.T) { s := newTestServer(t, newTestConfigWithWebPush(t)) - addSubscription(t, s, "test-topic", "https://example.com/webpush") + addSubscription(t, s, "test-topic", defaultEndpoint) requireSubscriptionCount(t, s, "test-topic", 1) - response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{}), nil) + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{}, defaultEndpoint), nil) require.Equal(t, 200, response.Code) require.Equal(t, `{"success":true}`+"\n", response.Body.String()) @@ -54,7 +79,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) { require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) - response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{ + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}, defaultEndpoint), map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) require.Equal(t, 200, response.Code) @@ -71,7 +96,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) { config.AuthDefault = user.PermissionDenyAll s := newTestServer(t, config) - response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), nil) + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}, defaultEndpoint), nil) require.Equal(t, 403, response.Code) requireSubscriptionCount(t, s, "test-topic", 0) @@ -84,7 +109,7 @@ func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) { require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser)) require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) - response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}), map[string]string{ + response := request(t, s, "PUT", "/v1/account/web-push", payloadForTopics(t, []string{"test-topic"}, defaultEndpoint), map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) @@ -105,7 +130,7 @@ func TestServer_WebPush_Publish(t *testing.T) { var received atomic.Bool - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) require.Nil(t, err) require.Equal(t, "/push-receive", r.URL.Path) @@ -113,9 +138,9 @@ func TestServer_WebPush_Publish(t *testing.T) { require.Equal(t, "", r.Header.Get("Topic")) received.Store(true) })) - defer upstreamServer.Close() + defer pushService.Close() - addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") + addSubscription(t, s, "test-topic", pushService.URL+"/push-receive") request(t, s, "PUT", "/test-topic", "web push test", nil) @@ -129,18 +154,17 @@ func TestServer_WebPush_PublishExpire(t *testing.T) { var received atomic.Bool - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) require.Nil(t, err) // Gone w.WriteHeader(410) - w.Write([]byte(``)) received.Store(true) })) - defer upstreamServer.Close() + defer pushService.Close() - addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") - addSubscription(t, s, "test-topic-abc", upstreamServer.URL+"/push-receive") + addSubscription(t, s, "test-topic", pushService.URL+"/push-receive") + addSubscription(t, s, "test-topic-abc", pushService.URL+"/push-receive") requireSubscriptionCount(t, s, "test-topic", 1) requireSubscriptionCount(t, s, "test-topic-abc", 1) @@ -162,16 +186,16 @@ func TestServer_WebPush_Expiry(t *testing.T) { var received atomic.Bool - upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, err := io.ReadAll(r.Body) require.Nil(t, err) w.WriteHeader(200) w.Write([]byte(``)) received.Store(true) })) - defer upstreamServer.Close() + defer pushService.Close() - addSubscription(t, s, "test-topic", upstreamServer.URL+"/push-receive") + addSubscription(t, s, "test-topic", pushService.URL+"/push-receive") requireSubscriptionCount(t, s, "test-topic", 1) _, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')") @@ -191,20 +215,20 @@ func TestServer_WebPush_Expiry(t *testing.T) { requireSubscriptionCount(t, s, "test-topic", 0) } -func payloadForTopics(t *testing.T, topics []string) string { +func payloadForTopics(t *testing.T, topics []string, endpoint string) string { topicsJSON, err := json.Marshal(topics) require.Nil(t, err) return fmt.Sprintf(`{ "topics": %s, "browser_subscription":{ - "endpoint": "https://example.com/webpush", + "endpoint": "%s", "keys": { "p256dh": "p256dh-key", "auth": "auth-key" } } - }`, topicsJSON) + }`, topicsJSON, endpoint) } func addSubscription(t *testing.T, s *Server, topic string, url string) {