Add push service allowlist and topic limit

pull/751/head
nimbleghost 2023-06-02 16:52:35 +02:00
parent 0f0074cbab
commit 46f34ca1e3
3 changed files with 74 additions and 21 deletions

View File

@ -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} 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} errHTTPBadRequestDelayNoCall = &errHTTP{40037, http.StatusBadRequest, "delayed call notifications are not supported", "", nil}
errHTTPBadRequestWebPushSubscriptionInvalid = &errHTTP{40038, http.StatusBadRequest, "invalid request: web push payload malformed", "", 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} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}

View File

@ -4,18 +4,45 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"github.com/SherClockHolmes/webpush-go" "github.com/SherClockHolmes/webpush-go"
"heckel.io/ntfy/log" "heckel.io/ntfy/log"
"heckel.io/ntfy/user" "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 { func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
payload, err := readJSONWithLimit[webPushSubscriptionPayload](r.Body, jsonBodyBytesLimit, false) payload, err := readJSONWithLimit[webPushSubscriptionPayload](r.Body, jsonBodyBytesLimit, false)
if err != nil || payload.BrowserSubscription.Endpoint == "" || payload.BrowserSubscription.Keys.P256dh == "" || payload.BrowserSubscription.Keys.Auth == "" { if err != nil || payload.BrowserSubscription.Endpoint == "" || payload.BrowserSubscription.Keys.P256dh == "" || payload.BrowserSubscription.Keys.Auth == "" {
return errHTTPBadRequestWebPushSubscriptionInvalid return errHTTPBadRequestWebPushSubscriptionInvalid
} }
if !webPushEndpointAllowRegex.MatchString(payload.BrowserSubscription.Endpoint) {
return errHTTPBadRequestWebPushEndpointUnknown
}
if len(payload.Topics) > webPushTopicSubscribeLimit {
return errHTTPBadRequestWebPushTopicCountTooHigh
}
u := v.User() u := v.User()
topics, err := s.topicsFromIDs(payload.Topics...) topics, err := s.topicsFromIDs(payload.Topics...)

View File

@ -16,10 +16,14 @@ import (
"heckel.io/ntfy/util" "heckel.io/ntfy/util"
) )
const (
defaultEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
)
func TestServer_WebPush_TopicAdd(t *testing.T) { func TestServer_WebPush_TopicAdd(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(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, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String()) 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.Nil(t, err)
require.Len(t, subs, 1) 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.P256dh, "p256dh-key")
require.Equal(t, subs[0].BrowserSubscription.Keys.Auth, "auth-key") require.Equal(t, subs[0].BrowserSubscription.Keys.Auth, "auth-key")
require.Equal(t, subs[0].UserID, "") 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) { func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
s := newTestServer(t, newTestConfigWithWebPush(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) 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, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String()) 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.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) 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"), "Authorization": util.BasicAuth("ben", "ben"),
}) })
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
@ -71,7 +96,7 @@ func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
config.AuthDefault = user.PermissionDenyAll config.AuthDefault = user.PermissionDenyAll
s := newTestServer(t, config) 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) require.Equal(t, 403, response.Code)
requireSubscriptionCount(t, s, "test-topic", 0) 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.AddUser("ben", "ben", user.RoleUser))
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite)) 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"), "Authorization": util.BasicAuth("ben", "ben"),
}) })
@ -105,7 +130,7 @@ func TestServer_WebPush_Publish(t *testing.T) {
var received atomic.Bool 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) _, err := io.ReadAll(r.Body)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "/push-receive", r.URL.Path) 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")) require.Equal(t, "", r.Header.Get("Topic"))
received.Store(true) 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) 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 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) _, err := io.ReadAll(r.Body)
require.Nil(t, err) require.Nil(t, err)
// Gone // Gone
w.WriteHeader(410) w.WriteHeader(410)
w.Write([]byte(``))
received.Store(true) 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")
addSubscription(t, s, "test-topic-abc", upstreamServer.URL+"/push-receive") addSubscription(t, s, "test-topic-abc", pushService.URL+"/push-receive")
requireSubscriptionCount(t, s, "test-topic", 1) requireSubscriptionCount(t, s, "test-topic", 1)
requireSubscriptionCount(t, s, "test-topic-abc", 1) requireSubscriptionCount(t, s, "test-topic-abc", 1)
@ -162,16 +186,16 @@ func TestServer_WebPush_Expiry(t *testing.T) {
var received atomic.Bool 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) _, err := io.ReadAll(r.Body)
require.Nil(t, err) require.Nil(t, err)
w.WriteHeader(200) w.WriteHeader(200)
w.Write([]byte(``)) w.Write([]byte(``))
received.Store(true) 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) requireSubscriptionCount(t, s, "test-topic", 1)
_, err := s.webPush.db.Exec("UPDATE subscriptions SET updated_at = datetime('now', '-7 days')") _, 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) 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) topicsJSON, err := json.Marshal(topics)
require.Nil(t, err) require.Nil(t, err)
return fmt.Sprintf(`{ return fmt.Sprintf(`{
"topics": %s, "topics": %s,
"browser_subscription":{ "browser_subscription":{
"endpoint": "https://example.com/webpush", "endpoint": "%s",
"keys": { "keys": {
"p256dh": "p256dh-key", "p256dh": "p256dh-key",
"auth": "auth-key" "auth": "auth-key"
} }
} }
}`, topicsJSON) }`, topicsJSON, endpoint)
} }
func addSubscription(t *testing.T, s *Server, topic string, url string) { func addSubscription(t *testing.T, s *Server, topic string, url string) {