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) } for name, tail := range map[string]string{"garbage": "garbage", "second doc": `{"x":1}`, "stray brace": "}"} { if w := post(t, s.Subscribe, body+tail, "alice", true); w.Code != http.StatusBadRequest { t.Errorf("trailing %s: %d", name, 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) } // A valid body padded with whitespace to exactly the cap passes; // one byte over does not. exact := body + strings.Repeat(" ", 8192-len(body)) if w := post(t, s.Subscribe, exact, "alice", true); w.Code != http.StatusNoContent { t.Errorf("8192-byte body: %d", w.Code) } if w := post(t, s.Subscribe, exact+" ", "alice", true); w.Code != http.StatusRequestEntityTooLarge { t.Errorf("8193-byte body: %d", w.Code) } // Endpoint length: 2048 accepted, 2049 refused. prefix := "https://push.example/" if w := post(t, s.Subscribe, subscribeBody(s, prefix+strings.Repeat("e", 2048-len(prefix))), "alice", true); w.Code != http.StatusNoContent { t.Errorf("2048-byte endpoint: %d", w.Code) } if w := post(t, s.Subscribe, subscribeBody(s, prefix+strings.Repeat("e", 2049-len(prefix))), "alice", true); w.Code != http.StatusBadRequest { t.Errorf("2049-byte endpoint: %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) } } // Bad keys are refused before put, so a mangled re-subscribe can // neither replace working keys nor delete previousEndpoint. func TestSubscribeRefusesInvalidKeysWithoutTouchingRows(t *testing.T) { s := newService(t) ctx := context.Background() _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true) _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true) before, _ := s.List(ctx, "alice") good := keys() for name, k := range map[string]map[string]string{ "p256dh not base64": {"p256dh": "!", "auth": good["auth"]}, "p256dh not a point": {"p256dh": base64.RawURLEncoding.EncodeToString(make([]byte, 65)), "auth": good["auth"]}, "p256dh compressed": {"p256dh": good["p256dh"][:44], "auth": good["auth"]}, "auth short": {"p256dh": good["p256dh"], "auth": base64.RawURLEncoding.EncodeToString(make([]byte, 15))}, "auth not base64": {"p256dh": good["p256dh"], "auth": "!"}, // Go's decoder forgives these; webpush-go's padding arithmetic // does not, so they would be stored and then fail every send. "p256dh newline": {"p256dh": good["p256dh"] + "\n", "auth": good["auth"]}, "auth crlf": {"p256dh": good["p256dh"], "auth": good["auth"][:5] + "\r\n" + good["auth"][5:]}, } { b, _ := json.Marshal(map[string]any{ "subscription": map[string]any{"endpoint": "https://push.example/e1", "keys": k}, "publicKey": s.PublicKeyString(), "previousEndpoint": "https://push.example/old", }) if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusBadRequest { t.Errorf("%s: %d", name, w.Code) } } after, _ := s.List(ctx, "alice") if len(after) != 2 || after[0].Auth != before[0].Auth || after[1].Auth != before[1].Auth || after[1].Revision != before[1].Revision { t.Fatalf("rows changed by refused requests:\n%+v\n%+v", before, after) } // Padded base64url, which some toJSON() implementations emit, is fine. padded := map[string]string{ "p256dh": base64.URLEncoding.EncodeToString(mustDecode(good["p256dh"])), "auth": base64.URLEncoding.EncodeToString(mustDecode(good["auth"])), } b, _ := json.Marshal(map[string]any{ "subscription": map[string]any{"endpoint": "https://push.example/e2", "keys": padded}, "publicKey": s.PublicKeyString(), }) if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { t.Fatalf("padded keys: %d %s", w.Code, w.Body) } // ...and stored canonically, so what Send hands webpush-go is the // unpadded form it decodes. rows, _ := s.List(ctx, "alice") for _, r := range rows { if r.Endpoint == "https://push.example/e2" && (r.P256dh != good["p256dh"] || r.Auth != good["auth"]) { t.Fatalf("keys not stored canonically: %q %q", r.P256dh, r.Auth) } } } func mustDecode(s string) []byte { b, err := base64.RawURLEncoding.DecodeString(s) if err != nil { panic(err) } return b } // 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) } // Naming another subject's endpoint as previous is a 204 that // deletes nothing of theirs. _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/bobs"), "bob", true) b, _ = json.Marshal(map[string]any{ "subscription": map[string]any{"endpoint": "https://push.example/new3", "keys": keys()}, "publicKey": s.PublicKeyString(), "previousEndpoint": "https://push.example/bobs", }) if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { t.Fatalf("%d %s", w.Code, w.Body) } if rows, _ := s.List(context.Background(), "bob"); len(rows) != 1 { t.Fatal("alice's previousEndpoint deleted bob's row") } } 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+"garbage", "alice", true); w.Code != http.StatusBadRequest { t.Errorf("trailing garbage: %d", w.Code) } if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { t.Fatal("a refused unsubscribe removed the row") } 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) } }