package aviso import ( "crypto/ecdh" "encoding/base64" "encoding/json" "errors" "io" "net/http" "strings" "amadan.net/rastrillo/rastrillo/csrf" "amadan.net/rastrillo/rastrillo/sessions" ) const maxSubscribeBody = 8192 // PublicKey answers GET with {"publicKey": ...}. no-cache so a rotated // key reaches browsers on their next load rather than after a cache // expiry nobody chose. func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", http.MethodGet) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-cache") _ = json.NewEncoder(w).Encode(map[string]string{"publicKey": s.pub}) } // gate is what both mutations require: POST, a session with a subject, // and a same-origin request. It writes the refusal and returns "" when // the caller must stop. func (s *Service) gate(w http.ResponseWriter, r *http.Request) string { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return "" } sess, ok := sessions.Current(r) if !ok || sess.Subject == "" { http.Error(w, "sign in first", http.StatusUnauthorized) return "" } if !csrf.SameOrigin(r, s.cfg.Origin) { http.Error(w, "cross-origin request refused", http.StatusForbidden) return "" } return sess.Subject } // decodeBody reads at most maxSubscribeBody bytes of JSON into into. // Unknown fields are tolerated on purpose: a browser's // PushSubscription.toJSON() carries expirationTime and whatever a // future spec adds, and refusing those would refuse every genuine // subscription. The byte cap and field validation are the defence. func decodeBody(w http.ResponseWriter, r *http.Request, into any) bool { body := http.MaxBytesReader(w, r.Body, maxSubscribeBody) dec := json.NewDecoder(body) tooLarge := func(err error) bool { var mbe *http.MaxBytesError return errors.As(err, &mbe) } if err := dec.Decode(into); err != nil { if tooLarge(err) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return false } http.Error(w, "bad request body", http.StatusBadRequest) return false } // Exactly one JSON value: a second Decode must hit EOF. Anything // else — trailing garbage, a second document — is refused rather // than silently dropped, so a mangled body cannot half-apply. if err := dec.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) { if tooLarge(err) { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return false } http.Error(w, "bad request body", http.StatusBadRequest) return false } return true } // canonicalKeys checks the two RFC 8291 inputs the way webpush-go will // before it encrypts — p256dh an uncompressed P-256 point, auth 16 // bytes, both base64url with or without padding — and returns them // re-encoded canonically. Stored canonical, not as sent: Go's decoder // forgives a stray CR/LF that webpush-go's padding arithmetic does // not, so a key that "validated" verbatim could still fail every // send. Refusing bad keys here keeps a mangled re-subscribe from // replacing working keys or deleting previousEndpoint. func canonicalKeys(p256dh, auth string) (string, string, error) { decode := func(s string) ([]byte, error) { if strings.ContainsAny(s, "\r\n \t") { return nil, errors.New("whitespace") } if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { return b, nil } return base64.URLEncoding.DecodeString(s) } point, err := decode(p256dh) if err != nil { return "", "", errors.New("p256dh is not base64url") } if _, err := ecdh.P256().NewPublicKey(point); err != nil { return "", "", errors.New("p256dh is not a P-256 point") } secret, err := decode(auth) if err != nil || len(secret) != 16 { return "", "", errors.New("auth is not 16 base64url bytes") } return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(secret), nil } type subscribeRequest struct { Subscription struct { Endpoint string `json:"endpoint"` Keys struct { P256dh string `json:"p256dh"` Auth string `json:"auth"` } `json:"keys"` } `json:"subscription"` PublicKey string `json:"publicKey"` PreviousEndpoint string `json:"previousEndpoint"` } // Subscribe stores the caller's subscription. 409 when the endpoint is // another subject's or the browser subscribed under a key that is not // ours — storing that row would be storing one nothing can sign for. func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request) { subject := s.gate(w, r) if subject == "" { return } var req subscribeRequest if !decodeBody(w, r, &req) { return } if req.PublicKey != s.pub { http.Error(w, "subscribed under a different application server key; re-enrol", http.StatusConflict) return } if err := validateEndpoint(req.Subscription.Endpoint); err != nil { http.Error(w, "endpoint refused", http.StatusBadRequest) return } p256dh, auth, err := canonicalKeys(req.Subscription.Keys.P256dh, req.Subscription.Keys.Auth) if err != nil { http.Error(w, "subscription keys refused: "+err.Error(), http.StatusBadRequest) return } if req.PreviousEndpoint != "" && validateEndpoint(req.PreviousEndpoint) != nil { http.Error(w, "previousEndpoint refused", http.StatusBadRequest) return } sub := Subscription{Endpoint: req.Subscription.Endpoint, P256dh: p256dh, Auth: auth} switch err := s.put(r.Context(), subject, sub, req.PreviousEndpoint); { case errors.Is(err, ErrOwnedElsewhere): http.Error(w, "endpoint enrolled by another account", http.StatusConflict) case err != nil: s.cfg.Logger.Error("aviso: subscribe", "err", err) http.Error(w, "could not store subscription", http.StatusInternalServerError) default: w.WriteHeader(http.StatusNoContent) } } // Unsubscribe removes the caller's own row for the endpoint. 204 // whether or not it existed: the endpoint's existence is not the // caller's to learn unless they own it. func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request) { subject := s.gate(w, r) if subject == "" { return } var req struct { Endpoint string `json:"endpoint"` } if !decodeBody(w, r, &req) { return } if req.Endpoint == "" || len(req.Endpoint) > maxEndpointLen { http.Error(w, "endpoint missing", http.StatusBadRequest) return } if err := s.deleteOwn(r.Context(), subject, req.Endpoint); err != nil { s.cfg.Logger.Error("aviso: unsubscribe", "err", err) http.Error(w, "could not remove subscription", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) }