rastrillo / aviso Public

Add the three handlers, gated by session and origin

Ownership comes from sessions.Current, never the body. A subscription
made under a different application server key is refused rather than
stored unsendable. Unsubscribe is 204 either way so an endpoint's
existence is not learnable by someone who does not own it. Unknown
JSON fields are tolerated on purpose: a browser's toJSON() carries
expirationTime, and a strict decoder would refuse every genuine
subscription; the byte cap and field validation are the defence.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d7684ea8793aeea26d094406269a6e70588abd2a parent 61fc8a9
2 files changed, +339 −0
  • http.go +149 −0
  • http_test.go +190 −0
diff --git a/http.go b/http.go
new file mode 100644
index 0000000..d432ec1
--- /dev/null
+++ b/http.go
@@ -0,0 +1,149 @@
+package aviso
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+
+ "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)
+ if err := json.NewDecoder(body).Decode(into); err != nil {
+ var mbe *http.MaxBytesError
+ if errors.As(err, &mbe) {
+ http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
+ return false
+ }
+ http.Error(w, "bad request body", http.StatusBadRequest)
+ return false
+ }
+ if _, err := io.Copy(io.Discard, body); err != nil { // trailing bytes over the cap
+ http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
+ return false
+ }
+ return true
+}
+
+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
+ }
+ if req.Subscription.Keys.P256dh == "" || req.Subscription.Keys.Auth == "" {
+ http.Error(w, "subscription keys missing", 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: req.Subscription.Keys.P256dh, Auth: req.Subscription.Keys.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)
+}
diff --git a/http_test.go b/http_test.go
new file mode 100644
index 0000000..bdb0fab
--- /dev/null
+++ b/http_test.go
@@ -0,0 +1,190 @@
+package aviso_test
+
+import (
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ "amadan.net/rastrillo/aviso"
+)
+
+func keys() map[string]string {
+ k, _ := ecdh.P256().GenerateKey(rand.Reader)
+ auth := make([]byte, 16)
+ _, _ = rand.Read(auth)
+ return map[string]string{
+ "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()),
+ "auth": base64.RawURLEncoding.EncodeToString(auth),
+ }
+}
+
+func subscribeBody(s *aviso.Service, endpoint string) string {
+ b, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": endpoint, "keys": keys()},
+ "publicKey": s.PublicKeyString(),
+ })
+ return string(b)
+}
+
+func post(t *testing.T, h http.HandlerFunc, body, subject string, sameOrigin bool) *httptest.ResponseRecorder {
+ t.Helper()
+ r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ if sameOrigin {
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ } else {
+ r.Header.Set("Sec-Fetch-Site", "cross-site")
+ }
+ if subject != "" {
+ r = sessions.WithSession(r, sessions.Session{Subject: subject})
+ }
+ w := httptest.NewRecorder()
+ h(w, r)
+ return w
+}
+
+func TestPublicKey(t *testing.T) {
+ s := newService(t)
+ w := httptest.NewRecorder()
+ s.PublicKey(w, httptest.NewRequest(http.MethodGet, "/aviso/public-key", nil))
+ var got struct{ PublicKey string }
+ if err := json.NewDecoder(w.Body).Decode(&got); err != nil || got.PublicKey != s.PublicKeyString() {
+ t.Fatalf("status %d body %s", w.Code, w.Body)
+ }
+ if w.Header().Get("Cache-Control") != "no-cache" {
+ t.Fatal("public key cacheable")
+ }
+ w = httptest.NewRecorder()
+ s.PublicKey(w, httptest.NewRequest(http.MethodPost, "/aviso/public-key", nil))
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("POST: %d", w.Code)
+ }
+}
+
+func TestSubscribeGating(t *testing.T) {
+ s := newService(t)
+ ctx := context.Background()
+ body := subscribeBody(s, "https://push.example/e1")
+ if w := post(t, s.Subscribe, body, "", true); w.Code != http.StatusUnauthorized {
+ t.Errorf("no session: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, body, "alice", false); w.Code != http.StatusForbidden {
+ t.Errorf("cross-site: %d", w.Code)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("a refused request stored a row")
+ }
+ if w := post(t, s.Subscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("good: %d %s", w.Code, w.Body)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("row not stored")
+ }
+ if w := post(t, s.Subscribe, body, "bob", true); w.Code != http.StatusConflict {
+ t.Errorf("cross-owner: %d", w.Code)
+ }
+ wrongKey := strings.Replace(body, s.PublicKeyString(), "BOTHER", 1)
+ if w := post(t, s.Subscribe, wrongKey, "alice", true); w.Code != http.StatusConflict {
+ t.Errorf("wrong key: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, subscribeBody(s, "http://push.example/e1"), "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("http endpoint: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, subscribeBody(s, "https://10.0.0.1/e1"), "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("private endpoint: %d", w.Code)
+ }
+ noKeys := strings.Replace(body, `"p256dh"`, `"p256dhx"`, 1)
+ if w := post(t, s.Subscribe, noKeys, "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("missing keys: %d", w.Code)
+ }
+ if w := post(t, s.Subscribe, "{not json", "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("bad json: %d", w.Code)
+ }
+ huge := `{"subscription":{"endpoint":"https://push.example/` + strings.Repeat("x", 8193) + `"}}`
+ if w := post(t, s.Subscribe, huge, "alice", true); w.Code != http.StatusRequestEntityTooLarge {
+ t.Errorf("oversize body: %d", w.Code)
+ }
+ r := httptest.NewRequest(http.MethodGet, "/aviso/subscribe", nil)
+ w := httptest.NewRecorder()
+ s.Subscribe(w, r)
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("GET: %d", w.Code)
+ }
+}
+
+// Real browsers' toJSON() carries expirationTime; a strict decoder
+// would refuse every genuine subscription.
+func TestSubscribeToleratesBrowserFields(t *testing.T) {
+ s := newService(t)
+ b, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": "https://push.example/e1", "expirationTime": nil, "keys": keys()},
+ "publicKey": s.PublicKeyString(),
+ })
+ if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent {
+ t.Fatalf("%d %s", w.Code, w.Body)
+ }
+}
+
+func TestSubscribeHonoursPreviousEndpoint(t *testing.T) {
+ s := newService(t)
+ _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true)
+ b, _ := json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": "https://push.example/new", "keys": keys()},
+ "publicKey": s.PublicKeyString(),
+ "previousEndpoint": "https://push.example/old",
+ })
+ if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent {
+ t.Fatalf("%d %s", w.Code, w.Body)
+ }
+ rows, _ := s.List(context.Background(), "alice")
+ if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" {
+ t.Fatalf("rows: %+v", rows)
+ }
+ b, _ = json.Marshal(map[string]any{
+ "subscription": map[string]any{"endpoint": "https://push.example/new2", "keys": keys()},
+ "publicKey": s.PublicKeyString(),
+ "previousEndpoint": "http://push.example/new",
+ })
+ if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusBadRequest {
+ t.Fatalf("bad previousEndpoint accepted: %d", w.Code)
+ }
+}
+
+func TestUnsubscribe(t *testing.T) {
+ s := newService(t)
+ ctx := context.Background()
+ _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true)
+ body := `{"endpoint":"https://push.example/e1"}`
+ if w := post(t, s.Unsubscribe, body, "", true); w.Code != http.StatusUnauthorized {
+ t.Errorf("no session: %d", w.Code)
+ }
+ if w := post(t, s.Unsubscribe, body, "alice", false); w.Code != http.StatusForbidden {
+ t.Errorf("cross-site: %d", w.Code)
+ }
+ if w := post(t, s.Unsubscribe, body, "bob", true); w.Code != http.StatusNoContent {
+ t.Errorf("other subject: %d", w.Code)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 1 {
+ t.Fatal("bob's unsubscribe removed alice's row")
+ }
+ if w := post(t, s.Unsubscribe, `{}`, "alice", true); w.Code != http.StatusBadRequest {
+ t.Errorf("missing endpoint: %d", w.Code)
+ }
+ if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("own: %d", w.Code)
+ }
+ if rows, _ := s.List(ctx, "alice"); len(rows) != 0 {
+ t.Fatal("own unsubscribe did nothing")
+ }
+ if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent {
+ t.Errorf("repeat: %d", w.Code)
+ }
+}