mirror of
				https://github.com/binwiederhier/ntfy.git
				synced 2025-10-31 13:02:24 +01:00 
			
		
		
		
	Add push service allowlist and topic limit
This commit is contained in:
		
							parent
							
								
									0f0074cbab
								
							
						
					
					
						commit
						46f34ca1e3
					
				
					 3 changed files with 74 additions and 21 deletions
				
			
		|  | @ -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} | ||||
|  |  | |||
|  | @ -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...) | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 nimbleghost
						nimbleghost