| 1 | package aviso_test |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ecdh" |
| 6 | "crypto/rand" |
| 7 | "encoding/base64" |
| 8 | "encoding/json" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "amadan.net/rastrillo/rastrillo/sessions" |
| 15 | |
| 16 | "amadan.net/rastrillo/aviso" |
| 17 | ) |
| 18 | |
| 19 | func keys() map[string]string { |
| 20 | k, _ := ecdh.P256().GenerateKey(rand.Reader) |
| 21 | auth := make([]byte, 16) |
| 22 | _, _ = rand.Read(auth) |
| 23 | return map[string]string{ |
| 24 | "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), |
| 25 | "auth": base64.RawURLEncoding.EncodeToString(auth), |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | func subscribeBody(s *aviso.Service, endpoint string) string { |
| 30 | b, _ := json.Marshal(map[string]any{ |
| 31 | "subscription": map[string]any{"endpoint": endpoint, "keys": keys()}, |
| 32 | "publicKey": s.PublicKeyString(), |
| 33 | }) |
| 34 | return string(b) |
| 35 | } |
| 36 | |
| 37 | func post(t *testing.T, h http.HandlerFunc, body, subject string, sameOrigin bool) *httptest.ResponseRecorder { |
| 38 | t.Helper() |
| 39 | r := httptest.NewRequest(http.MethodPost, "/aviso/subscribe", strings.NewReader(body)) |
| 40 | r.Header.Set("Content-Type", "application/json") |
| 41 | if sameOrigin { |
| 42 | r.Header.Set("Sec-Fetch-Site", "same-origin") |
| 43 | } else { |
| 44 | r.Header.Set("Sec-Fetch-Site", "cross-site") |
| 45 | } |
| 46 | if subject != "" { |
| 47 | r = sessions.WithSession(r, sessions.Session{Subject: subject}) |
| 48 | } |
| 49 | w := httptest.NewRecorder() |
| 50 | h(w, r) |
| 51 | return w |
| 52 | } |
| 53 | |
| 54 | func TestPublicKey(t *testing.T) { |
| 55 | s := newService(t) |
| 56 | w := httptest.NewRecorder() |
| 57 | s.PublicKey(w, httptest.NewRequest(http.MethodGet, "/aviso/public-key", nil)) |
| 58 | var got struct{ PublicKey string } |
| 59 | if err := json.NewDecoder(w.Body).Decode(&got); err != nil || got.PublicKey != s.PublicKeyString() { |
| 60 | t.Fatalf("status %d body %s", w.Code, w.Body) |
| 61 | } |
| 62 | if w.Header().Get("Cache-Control") != "no-cache" { |
| 63 | t.Fatal("public key cacheable") |
| 64 | } |
| 65 | w = httptest.NewRecorder() |
| 66 | s.PublicKey(w, httptest.NewRequest(http.MethodPost, "/aviso/public-key", nil)) |
| 67 | if w.Code != http.StatusMethodNotAllowed { |
| 68 | t.Fatalf("POST: %d", w.Code) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestSubscribeGating(t *testing.T) { |
| 73 | s := newService(t) |
| 74 | ctx := context.Background() |
| 75 | body := subscribeBody(s, "https://push.example/e1") |
| 76 | if w := post(t, s.Subscribe, body, "", true); w.Code != http.StatusUnauthorized { |
| 77 | t.Errorf("no session: %d", w.Code) |
| 78 | } |
| 79 | if w := post(t, s.Subscribe, body, "alice", false); w.Code != http.StatusForbidden { |
| 80 | t.Errorf("cross-site: %d", w.Code) |
| 81 | } |
| 82 | if rows, _ := s.List(ctx, "alice"); len(rows) != 0 { |
| 83 | t.Fatal("a refused request stored a row") |
| 84 | } |
| 85 | if w := post(t, s.Subscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| 86 | t.Errorf("good: %d %s", w.Code, w.Body) |
| 87 | } |
| 88 | if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { |
| 89 | t.Fatal("row not stored") |
| 90 | } |
| 91 | if w := post(t, s.Subscribe, body, "bob", true); w.Code != http.StatusConflict { |
| 92 | t.Errorf("cross-owner: %d", w.Code) |
| 93 | } |
| 94 | wrongKey := strings.Replace(body, s.PublicKeyString(), "BOTHER", 1) |
| 95 | if w := post(t, s.Subscribe, wrongKey, "alice", true); w.Code != http.StatusConflict { |
| 96 | t.Errorf("wrong key: %d", w.Code) |
| 97 | } |
| 98 | if w := post(t, s.Subscribe, subscribeBody(s, "http://push.example/e1"), "alice", true); w.Code != http.StatusBadRequest { |
| 99 | t.Errorf("http endpoint: %d", w.Code) |
| 100 | } |
| 101 | if w := post(t, s.Subscribe, subscribeBody(s, "https://10.0.0.1/e1"), "alice", true); w.Code != http.StatusBadRequest { |
| 102 | t.Errorf("private endpoint: %d", w.Code) |
| 103 | } |
| 104 | noKeys := strings.Replace(body, `"p256dh"`, `"p256dhx"`, 1) |
| 105 | if w := post(t, s.Subscribe, noKeys, "alice", true); w.Code != http.StatusBadRequest { |
| 106 | t.Errorf("missing keys: %d", w.Code) |
| 107 | } |
| 108 | if w := post(t, s.Subscribe, "{not json", "alice", true); w.Code != http.StatusBadRequest { |
| 109 | t.Errorf("bad json: %d", w.Code) |
| 110 | } |
| 111 | for name, tail := range map[string]string{"garbage": "garbage", "second doc": `{"x":1}`, "stray brace": "}"} { |
| 112 | if w := post(t, s.Subscribe, body+tail, "alice", true); w.Code != http.StatusBadRequest { |
| 113 | t.Errorf("trailing %s: %d", name, w.Code) |
| 114 | } |
| 115 | } |
| 116 | huge := `{"subscription":{"endpoint":"https://push.example/` + strings.Repeat("x", 8193) + `"}}` |
| 117 | if w := post(t, s.Subscribe, huge, "alice", true); w.Code != http.StatusRequestEntityTooLarge { |
| 118 | t.Errorf("oversize body: %d", w.Code) |
| 119 | } |
| 120 | // A valid body padded with whitespace to exactly the cap passes; |
| 121 | // one byte over does not. |
| 122 | exact := body + strings.Repeat(" ", 8192-len(body)) |
| 123 | if w := post(t, s.Subscribe, exact, "alice", true); w.Code != http.StatusNoContent { |
| 124 | t.Errorf("8192-byte body: %d", w.Code) |
| 125 | } |
| 126 | if w := post(t, s.Subscribe, exact+" ", "alice", true); w.Code != http.StatusRequestEntityTooLarge { |
| 127 | t.Errorf("8193-byte body: %d", w.Code) |
| 128 | } |
| 129 | // Endpoint length: 2048 accepted, 2049 refused. |
| 130 | prefix := "https://push.example/" |
| 131 | if w := post(t, s.Subscribe, subscribeBody(s, prefix+strings.Repeat("e", 2048-len(prefix))), "alice", true); w.Code != http.StatusNoContent { |
| 132 | t.Errorf("2048-byte endpoint: %d", w.Code) |
| 133 | } |
| 134 | if w := post(t, s.Subscribe, subscribeBody(s, prefix+strings.Repeat("e", 2049-len(prefix))), "alice", true); w.Code != http.StatusBadRequest { |
| 135 | t.Errorf("2049-byte endpoint: %d", w.Code) |
| 136 | } |
| 137 | r := httptest.NewRequest(http.MethodGet, "/aviso/subscribe", nil) |
| 138 | w := httptest.NewRecorder() |
| 139 | s.Subscribe(w, r) |
| 140 | if w.Code != http.StatusMethodNotAllowed { |
| 141 | t.Errorf("GET: %d", w.Code) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // Bad keys are refused before put, so a mangled re-subscribe can |
| 146 | // neither replace working keys nor delete previousEndpoint. |
| 147 | func TestSubscribeRefusesInvalidKeysWithoutTouchingRows(t *testing.T) { |
| 148 | s := newService(t) |
| 149 | ctx := context.Background() |
| 150 | _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true) |
| 151 | _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true) |
| 152 | before, _ := s.List(ctx, "alice") |
| 153 | good := keys() |
| 154 | for name, k := range map[string]map[string]string{ |
| 155 | "p256dh not base64": {"p256dh": "!", "auth": good["auth"]}, |
| 156 | "p256dh not a point": {"p256dh": base64.RawURLEncoding.EncodeToString(make([]byte, 65)), "auth": good["auth"]}, |
| 157 | "p256dh compressed": {"p256dh": good["p256dh"][:44], "auth": good["auth"]}, |
| 158 | "auth short": {"p256dh": good["p256dh"], "auth": base64.RawURLEncoding.EncodeToString(make([]byte, 15))}, |
| 159 | "auth not base64": {"p256dh": good["p256dh"], "auth": "!"}, |
| 160 | // Go's decoder forgives these; webpush-go's padding arithmetic |
| 161 | // does not, so they would be stored and then fail every send. |
| 162 | "p256dh newline": {"p256dh": good["p256dh"] + "\n", "auth": good["auth"]}, |
| 163 | "auth crlf": {"p256dh": good["p256dh"], "auth": good["auth"][:5] + "\r\n" + good["auth"][5:]}, |
| 164 | } { |
| 165 | b, _ := json.Marshal(map[string]any{ |
| 166 | "subscription": map[string]any{"endpoint": "https://push.example/e1", "keys": k}, |
| 167 | "publicKey": s.PublicKeyString(), |
| 168 | "previousEndpoint": "https://push.example/old", |
| 169 | }) |
| 170 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusBadRequest { |
| 171 | t.Errorf("%s: %d", name, w.Code) |
| 172 | } |
| 173 | } |
| 174 | after, _ := s.List(ctx, "alice") |
| 175 | if len(after) != 2 || after[0].Auth != before[0].Auth || after[1].Auth != before[1].Auth || after[1].Revision != before[1].Revision { |
| 176 | t.Fatalf("rows changed by refused requests:\n%+v\n%+v", before, after) |
| 177 | } |
| 178 | // Padded base64url, which some toJSON() implementations emit, is fine. |
| 179 | padded := map[string]string{ |
| 180 | "p256dh": base64.URLEncoding.EncodeToString(mustDecode(good["p256dh"])), |
| 181 | "auth": base64.URLEncoding.EncodeToString(mustDecode(good["auth"])), |
| 182 | } |
| 183 | b, _ := json.Marshal(map[string]any{ |
| 184 | "subscription": map[string]any{"endpoint": "https://push.example/e2", "keys": padded}, |
| 185 | "publicKey": s.PublicKeyString(), |
| 186 | }) |
| 187 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| 188 | t.Fatalf("padded keys: %d %s", w.Code, w.Body) |
| 189 | } |
| 190 | // ...and stored canonically, so what Send hands webpush-go is the |
| 191 | // unpadded form it decodes. |
| 192 | rows, _ := s.List(ctx, "alice") |
| 193 | for _, r := range rows { |
| 194 | if r.Endpoint == "https://push.example/e2" && (r.P256dh != good["p256dh"] || r.Auth != good["auth"]) { |
| 195 | t.Fatalf("keys not stored canonically: %q %q", r.P256dh, r.Auth) |
| 196 | } |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func mustDecode(s string) []byte { |
| 201 | b, err := base64.RawURLEncoding.DecodeString(s) |
| 202 | if err != nil { |
| 203 | panic(err) |
| 204 | } |
| 205 | return b |
| 206 | } |
| 207 | |
| 208 | // Real browsers' toJSON() carries expirationTime; a strict decoder |
| 209 | // would refuse every genuine subscription. |
| 210 | func TestSubscribeToleratesBrowserFields(t *testing.T) { |
| 211 | s := newService(t) |
| 212 | b, _ := json.Marshal(map[string]any{ |
| 213 | "subscription": map[string]any{"endpoint": "https://push.example/e1", "expirationTime": nil, "keys": keys()}, |
| 214 | "publicKey": s.PublicKeyString(), |
| 215 | }) |
| 216 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| 217 | t.Fatalf("%d %s", w.Code, w.Body) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestSubscribeHonoursPreviousEndpoint(t *testing.T) { |
| 222 | s := newService(t) |
| 223 | _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/old"), "alice", true) |
| 224 | b, _ := json.Marshal(map[string]any{ |
| 225 | "subscription": map[string]any{"endpoint": "https://push.example/new", "keys": keys()}, |
| 226 | "publicKey": s.PublicKeyString(), |
| 227 | "previousEndpoint": "https://push.example/old", |
| 228 | }) |
| 229 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| 230 | t.Fatalf("%d %s", w.Code, w.Body) |
| 231 | } |
| 232 | rows, _ := s.List(context.Background(), "alice") |
| 233 | if len(rows) != 1 || rows[0].Endpoint != "https://push.example/new" { |
| 234 | t.Fatalf("rows: %+v", rows) |
| 235 | } |
| 236 | b, _ = json.Marshal(map[string]any{ |
| 237 | "subscription": map[string]any{"endpoint": "https://push.example/new2", "keys": keys()}, |
| 238 | "publicKey": s.PublicKeyString(), |
| 239 | "previousEndpoint": "http://push.example/new", |
| 240 | }) |
| 241 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusBadRequest { |
| 242 | t.Fatalf("bad previousEndpoint accepted: %d", w.Code) |
| 243 | } |
| 244 | // Naming another subject's endpoint as previous is a 204 that |
| 245 | // deletes nothing of theirs. |
| 246 | _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/bobs"), "bob", true) |
| 247 | b, _ = json.Marshal(map[string]any{ |
| 248 | "subscription": map[string]any{"endpoint": "https://push.example/new3", "keys": keys()}, |
| 249 | "publicKey": s.PublicKeyString(), |
| 250 | "previousEndpoint": "https://push.example/bobs", |
| 251 | }) |
| 252 | if w := post(t, s.Subscribe, string(b), "alice", true); w.Code != http.StatusNoContent { |
| 253 | t.Fatalf("%d %s", w.Code, w.Body) |
| 254 | } |
| 255 | if rows, _ := s.List(context.Background(), "bob"); len(rows) != 1 { |
| 256 | t.Fatal("alice's previousEndpoint deleted bob's row") |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | func TestUnsubscribe(t *testing.T) { |
| 261 | s := newService(t) |
| 262 | ctx := context.Background() |
| 263 | _ = post(t, s.Subscribe, subscribeBody(s, "https://push.example/e1"), "alice", true) |
| 264 | body := `{"endpoint":"https://push.example/e1"}` |
| 265 | if w := post(t, s.Unsubscribe, body, "", true); w.Code != http.StatusUnauthorized { |
| 266 | t.Errorf("no session: %d", w.Code) |
| 267 | } |
| 268 | if w := post(t, s.Unsubscribe, body, "alice", false); w.Code != http.StatusForbidden { |
| 269 | t.Errorf("cross-site: %d", w.Code) |
| 270 | } |
| 271 | if w := post(t, s.Unsubscribe, body, "bob", true); w.Code != http.StatusNoContent { |
| 272 | t.Errorf("other subject: %d", w.Code) |
| 273 | } |
| 274 | if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { |
| 275 | t.Fatal("bob's unsubscribe removed alice's row") |
| 276 | } |
| 277 | if w := post(t, s.Unsubscribe, `{}`, "alice", true); w.Code != http.StatusBadRequest { |
| 278 | t.Errorf("missing endpoint: %d", w.Code) |
| 279 | } |
| 280 | if w := post(t, s.Unsubscribe, body+"garbage", "alice", true); w.Code != http.StatusBadRequest { |
| 281 | t.Errorf("trailing garbage: %d", w.Code) |
| 282 | } |
| 283 | if rows, _ := s.List(ctx, "alice"); len(rows) != 1 { |
| 284 | t.Fatal("a refused unsubscribe removed the row") |
| 285 | } |
| 286 | if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| 287 | t.Errorf("own: %d", w.Code) |
| 288 | } |
| 289 | if rows, _ := s.List(ctx, "alice"); len(rows) != 0 { |
| 290 | t.Fatal("own unsubscribe did nothing") |
| 291 | } |
| 292 | if w := post(t, s.Unsubscribe, body, "alice", true); w.Code != http.StatusNoContent { |
| 293 | t.Errorf("repeat: %d", w.Code) |
| 294 | } |
| 295 | } |
| 296 | |