From 147534286fb1947b1a864a64c9c1cd8f67868098 Mon Sep 17 00:00:00 2001 From: lone-cloud Date: Tue, 17 Feb 2026 21:20:56 -0800 Subject: [PATCH] adding basic webpush request validation --- service/integration/webpush/handlers.go | 45 ++++++++++-- service/integration/webpush/validation.go | 89 +++++++++++++++++++++++ service/server/handlers_subscriptions.go | 4 +- 3 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 service/integration/webpush/validation.go diff --git a/service/integration/webpush/handlers.go b/service/integration/webpush/handlers.go index 755c90f..5897444 100644 --- a/service/integration/webpush/handlers.go +++ b/service/integration/webpush/handlers.go @@ -6,7 +6,6 @@ import ( "encoding/json" "log/slog" "net/http" - "net/url" "prism/service/notification" "prism/service/util" @@ -54,8 +53,23 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { return } - if _, err := url.Parse(req.PushEndpoint); err != nil { - util.JSONError(w, "Invalid pushEndpoint URL", http.StatusBadRequest) + encryptedFieldCount := 0 + if req.P256dh != nil { + encryptedFieldCount++ + } + if req.Auth != nil { + encryptedFieldCount++ + } + if req.VapidPrivateKey != nil { + encryptedFieldCount++ + } + if encryptedFieldCount > 0 && encryptedFieldCount < 3 { + util.JSONError(w, "p256dh, auth, and vapidPrivateKey must all be provided together", http.StatusBadRequest) + return + } + + if err := validatePushEndpoint(req.PushEndpoint, encryptedFieldCount == 3); err != nil { + util.JSONError(w, err.Error(), http.StatusBadRequest) return } @@ -67,11 +81,29 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { var webPush *notification.WebPushSubscription if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil { + normalizedP256dh, err := normalizeP256DH(*req.P256dh) + if err != nil { + util.JSONError(w, err.Error(), http.StatusBadRequest) + return + } + + normalizedAuth, err := normalizeAuthSecret(*req.Auth) + if err != nil { + util.JSONError(w, err.Error(), http.StatusBadRequest) + return + } + + normalizedKey, err := normalizeVAPIDPrivateKey(*req.VapidPrivateKey) + if err != nil { + util.JSONError(w, err.Error(), http.StatusBadRequest) + return + } + webPush = ¬ification.WebPushSubscription{ Endpoint: req.PushEndpoint, - P256dh: *req.P256dh, - Auth: *req.Auth, - VapidPrivateKey: *req.VapidPrivateKey, + P256dh: normalizedP256dh, + Auth: normalizedAuth, + VapidPrivateKey: normalizedKey, } } else { webPush = ¬ification.WebPushSubscription{ @@ -87,6 +119,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { } if err := h.store.AddSubscription(sub); err != nil { + h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err) util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err) return } diff --git a/service/integration/webpush/validation.go b/service/integration/webpush/validation.go new file mode 100644 index 0000000..31c0a29 --- /dev/null +++ b/service/integration/webpush/validation.go @@ -0,0 +1,89 @@ +package webpush + +import ( + "crypto/ecdh" + "crypto/elliptic" + "encoding/base64" + "fmt" + "math/big" + "net/url" + "strings" +) + +func validatePushEndpoint(raw string, requireHTTPS bool) error { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u == nil || u.Scheme == "" || u.Host == "" { + return fmt.Errorf("invalid pushEndpoint URL") + } + + if u.Scheme != "https" && u.Scheme != "http" { + return fmt.Errorf("pushEndpoint must use http or https") + } + + if requireHTTPS && u.Scheme != "https" { + return fmt.Errorf("encrypted webpush endpoint must use https") + } + + return nil +} + +func normalizeVAPIDPrivateKey(raw string) (string, error) { + decoded, err := decodeBase64URL(raw) + if err != nil { + return "", fmt.Errorf("invalid VAPID private key encoding") + } + + if len(decoded) != 32 { + return "", fmt.Errorf("invalid VAPID private key length: expected 32 bytes, got %d", len(decoded)) + } + + n := elliptic.P256().Params().N + d := new(big.Int).SetBytes(decoded) + if d.Sign() <= 0 || d.Cmp(n) >= 0 { + return "", fmt.Errorf("invalid VAPID private key scalar") + } + + return base64.RawURLEncoding.EncodeToString(decoded), nil +} + +func normalizeP256DH(raw string) (string, error) { + decoded, err := decodeBase64URL(raw) + if err != nil { + return "", fmt.Errorf("invalid p256dh encoding") + } + + if len(decoded) != 65 || decoded[0] != 0x04 { + return "", fmt.Errorf("invalid p256dh key format") + } + + if _, err := ecdh.P256().NewPublicKey(decoded); err != nil { + return "", fmt.Errorf("invalid p256dh point") + } + + return base64.RawURLEncoding.EncodeToString(decoded), nil +} + +func normalizeAuthSecret(raw string) (string, error) { + decoded, err := decodeBase64URL(raw) + if err != nil { + return "", fmt.Errorf("invalid auth encoding") + } + + if len(decoded) != 16 { + return "", fmt.Errorf("invalid auth length: expected 16 bytes, got %d", len(decoded)) + } + + return base64.RawURLEncoding.EncodeToString(decoded), nil +} + +func decodeBase64URL(raw string) ([]byte, error) { + key := strings.TrimSpace(raw) + if decoded, err := base64.RawURLEncoding.DecodeString(key); err == nil { + return decoded, nil + } + decoded, err := base64.URLEncoding.DecodeString(key) + if err == nil { + return decoded, nil + } + return nil, err +} diff --git a/service/server/handlers_subscriptions.go b/service/server/handlers_subscriptions.go index e3878ea..4c98e35 100644 --- a/service/server/handlers_subscriptions.go +++ b/service/server/handlers_subscriptions.go @@ -159,9 +159,9 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request return } - message := fmt.Sprintf("%s disabled", sub.Channel.Label()) + message := fmt.Sprintf("%s channel disabled", sub.Channel.Label()) if sub.Channel == notification.ChannelWebPush { - message = fmt.Sprintf("%s deleted", sub.Channel.Label()) + message = fmt.Sprintf("%s channel deleted", sub.Channel.Label()) } util.SetToast(w, message, "success") s.handleFragmentApps(w, r)