rastrillo / aviso Public

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

Plain git — no account needed to clone.

Download

Download this file

1package aviso_test
2
3import (
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
19func 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
29func 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
37func 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
54func 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
72func 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.
147func 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
200func 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.
210func 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
221func 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
260func 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