rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1package aviso
2
3import (
4 "crypto/ecdh"
5 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "strings"
11
12 "amadan.net/rastrillo/rastrillo/csrf"
13 "amadan.net/rastrillo/rastrillo/sessions"
14)
15
16const maxSubscribeBody = 8192
17
18// PublicKey answers GET with {"publicKey": ...}. no-cache so a rotated
19// key reaches browsers on their next load rather than after a cache
20// expiry nobody chose.
21func (s *Service) PublicKey(w http.ResponseWriter, r *http.Request) {
22 if r.Method != http.MethodGet {
23 w.Header().Set("Allow", http.MethodGet)
24 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
25 return
26 }
27 w.Header().Set("Content-Type", "application/json")
28 w.Header().Set("Cache-Control", "no-cache")
29 _ = json.NewEncoder(w).Encode(map[string]string{"publicKey": s.pub})
30}
31
32// gate is what both mutations require: POST, a session with a subject,
33// and a same-origin request. It writes the refusal and returns "" when
34// the caller must stop.
35func (s *Service) gate(w http.ResponseWriter, r *http.Request) string {
36 if r.Method != http.MethodPost {
37 w.Header().Set("Allow", http.MethodPost)
38 http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
39 return ""
40 }
41 sess, ok := sessions.Current(r)
42 if !ok || sess.Subject == "" {
43 http.Error(w, "sign in first", http.StatusUnauthorized)
44 return ""
45 }
46 if !csrf.SameOrigin(r, s.cfg.Origin) {
47 http.Error(w, "cross-origin request refused", http.StatusForbidden)
48 return ""
49 }
50 return sess.Subject
51}
52
53// decodeBody reads at most maxSubscribeBody bytes of JSON into into.
54// Unknown fields are tolerated on purpose: a browser's
55// PushSubscription.toJSON() carries expirationTime and whatever a
56// future spec adds, and refusing those would refuse every genuine
57// subscription. The byte cap and field validation are the defence.
58func decodeBody(w http.ResponseWriter, r *http.Request, into any) bool {
59 body := http.MaxBytesReader(w, r.Body, maxSubscribeBody)
60 dec := json.NewDecoder(body)
61 tooLarge := func(err error) bool {
62 var mbe *http.MaxBytesError
63 return errors.As(err, &mbe)
64 }
65 if err := dec.Decode(into); err != nil {
66 if tooLarge(err) {
67 http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
68 return false
69 }
70 http.Error(w, "bad request body", http.StatusBadRequest)
71 return false
72 }
73 // Exactly one JSON value: a second Decode must hit EOF. Anything
74 // else — trailing garbage, a second document — is refused rather
75 // than silently dropped, so a mangled body cannot half-apply.
76 if err := dec.Decode(new(json.RawMessage)); !errors.Is(err, io.EOF) {
77 if tooLarge(err) {
78 http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
79 return false
80 }
81 http.Error(w, "bad request body", http.StatusBadRequest)
82 return false
83 }
84 return true
85}
86
87// canonicalKeys checks the two RFC 8291 inputs the way webpush-go will
88// before it encrypts — p256dh an uncompressed P-256 point, auth 16
89// bytes, both base64url with or without padding — and returns them
90// re-encoded canonically. Stored canonical, not as sent: Go's decoder
91// forgives a stray CR/LF that webpush-go's padding arithmetic does
92// not, so a key that "validated" verbatim could still fail every
93// send. Refusing bad keys here keeps a mangled re-subscribe from
94// replacing working keys or deleting previousEndpoint.
95func canonicalKeys(p256dh, auth string) (string, string, error) {
96 decode := func(s string) ([]byte, error) {
97 if strings.ContainsAny(s, "\r\n \t") {
98 return nil, errors.New("whitespace")
99 }
100 if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
101 return b, nil
102 }
103 return base64.URLEncoding.DecodeString(s)
104 }
105 point, err := decode(p256dh)
106 if err != nil {
107 return "", "", errors.New("p256dh is not base64url")
108 }
109 if _, err := ecdh.P256().NewPublicKey(point); err != nil {
110 return "", "", errors.New("p256dh is not a P-256 point")
111 }
112 secret, err := decode(auth)
113 if err != nil || len(secret) != 16 {
114 return "", "", errors.New("auth is not 16 base64url bytes")
115 }
116 return base64.RawURLEncoding.EncodeToString(point), base64.RawURLEncoding.EncodeToString(secret), nil
117}
118
119type subscribeRequest struct {
120 Subscription struct {
121 Endpoint string `json:"endpoint"`
122 Keys struct {
123 P256dh string `json:"p256dh"`
124 Auth string `json:"auth"`
125 } `json:"keys"`
126 } `json:"subscription"`
127 PublicKey string `json:"publicKey"`
128 PreviousEndpoint string `json:"previousEndpoint"`
129}
130
131// Subscribe stores the caller's subscription. 409 when the endpoint is
132// another subject's or the browser subscribed under a key that is not
133// ours — storing that row would be storing one nothing can sign for.
134func (s *Service) Subscribe(w http.ResponseWriter, r *http.Request) {
135 subject := s.gate(w, r)
136 if subject == "" {
137 return
138 }
139 var req subscribeRequest
140 if !decodeBody(w, r, &req) {
141 return
142 }
143 if req.PublicKey != s.pub {
144 http.Error(w, "subscribed under a different application server key; re-enrol", http.StatusConflict)
145 return
146 }
147 if err := validateEndpoint(req.Subscription.Endpoint); err != nil {
148 http.Error(w, "endpoint refused", http.StatusBadRequest)
149 return
150 }
151 p256dh, auth, err := canonicalKeys(req.Subscription.Keys.P256dh, req.Subscription.Keys.Auth)
152 if err != nil {
153 http.Error(w, "subscription keys refused: "+err.Error(), http.StatusBadRequest)
154 return
155 }
156 if req.PreviousEndpoint != "" && validateEndpoint(req.PreviousEndpoint) != nil {
157 http.Error(w, "previousEndpoint refused", http.StatusBadRequest)
158 return
159 }
160 sub := Subscription{Endpoint: req.Subscription.Endpoint, P256dh: p256dh, Auth: auth}
161 switch err := s.put(r.Context(), subject, sub, req.PreviousEndpoint); {
162 case errors.Is(err, ErrOwnedElsewhere):
163 http.Error(w, "endpoint enrolled by another account", http.StatusConflict)
164 case err != nil:
165 s.cfg.Logger.Error("aviso: subscribe", "err", err)
166 http.Error(w, "could not store subscription", http.StatusInternalServerError)
167 default:
168 w.WriteHeader(http.StatusNoContent)
169 }
170}
171
172// Unsubscribe removes the caller's own row for the endpoint. 204
173// whether or not it existed: the endpoint's existence is not the
174// caller's to learn unless they own it.
175func (s *Service) Unsubscribe(w http.ResponseWriter, r *http.Request) {
176 subject := s.gate(w, r)
177 if subject == "" {
178 return
179 }
180 var req struct {
181 Endpoint string `json:"endpoint"`
182 }
183 if !decodeBody(w, r, &req) {
184 return
185 }
186 if req.Endpoint == "" || len(req.Endpoint) > maxEndpointLen {
187 http.Error(w, "endpoint missing", http.StatusBadRequest)
188 return
189 }
190 if err := s.deleteOwn(r.Context(), subject, req.Endpoint); err != nil {
191 s.cfg.Logger.Error("aviso: unsubscribe", "err", err)
192 http.Error(w, "could not remove subscription", http.StatusInternalServerError)
193 return
194 }
195 w.WriteHeader(http.StatusNoContent)
196}
197