Просмотр исходного кода

Add push service allowlist and topic limit

nimbleghost 2 лет назад
Родитель
Сommit
46f34ca1e3
3 измененных файлов с 74 добавлено и 21 удалено
  1. 2 0
      server/errors.go
  2. 27 0
      server/server_web_push.go
  3. 45 21
      server/server_web_push_test.go

+ 2 - 0
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}

+ 27 - 0
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...)

+ 45 - 21
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) {