eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "encoding/base64"
6 "encoding/json"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "strconv"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/go-webauthn/webauthn/webauthn"
17)
18
19func newTestServer(t *testing.T) (*httptest.Server, *Server) {
20 t.Helper()
21 store, err := openSQLiteStore(t.TempDir() + "/home.db")
22 if err != nil {
23 t.Fatalf("openSQLiteStore: %v", err)
24 }
25 s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret"))
26 if err != nil {
27 t.Fatalf("newServer: %v", err)
28 }
29 ts := httptest.NewServer(s.handler())
30 t.Cleanup(ts.Close)
31 return ts, s
32}
33
34// seedUser creates a user with one credential (a real webauthn.Credential, as
35// a verified ceremony would store) and a device link token — the state a
36// register/finish leaves behind, without simulating an authenticator.
37func seedUser(t *testing.T, s *Server) (userID, token string) {
38 t.Helper()
39 userID, err := randToken()
40 if err != nil {
41 t.Fatalf("randToken: %v", err)
42 }
43 if err := s.store.createUser(userID); err != nil {
44 t.Fatalf("createUser: %v", err)
45 }
46 cred := &webauthn.Credential{
47 ID: []byte("cred-" + userID[:8]),
48 PublicKey: []byte("test-cose-public-key"),
49 Authenticator: webauthn.Authenticator{
50 AAGUID: make([]byte, 16),
51 SignCount: 1,
52 },
53 }
54 if err := s.store.insertCredential(userID, cred); err != nil {
55 t.Fatalf("insertCredential: %v", err)
56 }
57 token, err = s.store.createLinkToken(userID)
58 if err != nil {
59 t.Fatalf("createLinkToken: %v", err)
60 }
61 return userID, token
62}
63
64func do(t *testing.T, method, url, token string, headers map[string]string, body []byte) *http.Response {
65 t.Helper()
66 req, _ := http.NewRequest(method, url, bytes.NewReader(body))
67 if token != "" {
68 req.Header.Set("Authorization", "Bearer "+token)
69 }
70 for k, v := range headers {
71 req.Header.Set(k, v)
72 }
73 resp, err := http.DefaultClient.Do(req)
74 if err != nil {
75 t.Fatalf("request: %v", err)
76 }
77 return resp
78}
79
80func TestBlobAuthGating(t *testing.T) {
81 ts, _ := newTestServer(t)
82 if r := do(t, "GET", ts.URL+"/api/blob", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
83 t.Fatalf("GET no token: want 401, got %d", r.StatusCode)
84 }
85 if r := do(t, "PUT", ts.URL+"/api/blob", "", map[string]string{"If-Match": `"0"`}, []byte("x")); r.StatusCode != http.StatusUnauthorized {
86 t.Fatalf("PUT no token: want 401, got %d", r.StatusCode)
87 }
88 if r := do(t, "GET", ts.URL+"/api/blob", "wrong", nil, nil); r.StatusCode != http.StatusUnauthorized {
89 t.Fatalf("GET bad token: want 401, got %d", r.StatusCode)
90 }
91}
92
93func TestBlobPutGet304(t *testing.T) {
94 ts, s := newTestServer(t)
95 _, tok := seedUser(t, s)
96
97 // No blob yet.
98 if r := do(t, "GET", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusNotFound {
99 t.Fatalf("empty GET: want 404, got %d", r.StatusCode)
100 }
101
102 // First write claims version 0 -> 1.
103 blob := []byte("opaque-ciphertext")
104 r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, blob)
105 if r.StatusCode != http.StatusOK {
106 t.Fatalf("PUT: want 200, got %d", r.StatusCode)
107 }
108 if got := r.Header.Get("ETag"); got != `"1"` {
109 t.Fatalf("PUT ETag: want %q, got %q", `"1"`, got)
110 }
111 // The response's version is an opaque JSON string, like the ETag (#550
112 // Phase 0) — a future backend's non-numeric versions must not need a wire
113 // change.
114 if body, _ := io.ReadAll(r.Body); !strings.Contains(string(body), `"version":"1"`) {
115 t.Fatalf("PUT body should carry version as a JSON string, got %s", body)
116 }
117
118 r = do(t, "GET", ts.URL+"/api/blob", tok, nil, nil)
119 if r.StatusCode != http.StatusOK {
120 t.Fatalf("GET: want 200, got %d", r.StatusCode)
121 }
122 if got := r.Header.Get("ETag"); got != `"1"` {
123 t.Fatalf("GET ETag: want %q, got %q", `"1"`, got)
124 }
125 body, _ := io.ReadAll(r.Body)
126 if !bytes.Equal(body, blob) {
127 t.Fatalf("GET body mismatch")
128 }
129
130 // Conditional read: matching version returns 304 with no body.
131 r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"1"`}, nil)
132 if r.StatusCode != http.StatusNotModified {
133 t.Fatalf("If-None-Match: want 304, got %d", r.StatusCode)
134 }
135 // A stale version still gets the full blob.
136 r = do(t, "GET", ts.URL+"/api/blob", tok, map[string]string{"If-None-Match": `"0"`}, nil)
137 if r.StatusCode != http.StatusOK {
138 t.Fatalf("stale If-None-Match: want 200, got %d", r.StatusCode)
139 }
140}
141
142func TestBlobVersionConflict(t *testing.T) {
143 ts, s := newTestServer(t)
144 _, tok := seedUser(t, s)
145
146 if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1")); r.StatusCode != http.StatusOK {
147 t.Fatalf("first PUT: want 200, got %d", r.StatusCode)
148 }
149 // A second writer that still believes version 0 must lose.
150 r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("v1-race"))
151 if r.StatusCode != http.StatusConflict {
152 t.Fatalf("stale PUT: want 409, got %d", r.StatusCode)
153 }
154 if got := r.Header.Get("ETag"); got != `"1"` {
155 t.Fatalf("conflict ETag should name the current version: want %q, got %q", `"1"`, got)
156 }
157 // If-Match is mandatory: an unconditional overwrite is not a thing.
158 if r := do(t, "PUT", ts.URL+"/api/blob", tok, nil, []byte("x")); r.StatusCode != http.StatusPreconditionRequired {
159 t.Fatalf("missing If-Match: want 428, got %d", r.StatusCode)
160 }
161}
162
163func TestBlobSizeCap(t *testing.T) {
164 ts, s := newTestServer(t)
165 _, tok := seedUser(t, s)
166 big := make([]byte, maxBlobBytes+1)
167 r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, big)
168 if r.StatusCode != http.StatusRequestEntityTooLarge {
169 t.Fatalf("oversize PUT: want 413, got %d", r.StatusCode)
170 }
171 // At the cap is fine.
172 ok := make([]byte, maxBlobBytes)
173 if r := do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, ok); r.StatusCode != http.StatusOK {
174 t.Fatalf("cap-size PUT: want 200, got %d", r.StatusCode)
175 }
176}
177
178func TestBlobVersionHistory(t *testing.T) {
179 ts, s := newTestServer(t)
180 uid, tok := seedUser(t, s)
181 for i := 0; i < blobKeepVersions+2; i++ {
182 r := do(t, "PUT", ts.URL+"/api/blob", tok,
183 map[string]string{"If-Match": `"` + itoa(i) + `"`}, []byte("v"+itoa(i+1)))
184 if r.StatusCode != http.StatusOK {
185 t.Fatalf("PUT %d: want 200, got %d", i+1, r.StatusCode)
186 }
187 }
188 versions, err := s.store.listBlobVersions(uid)
189 if err != nil {
190 t.Fatalf("listBlobVersions: %v", err)
191 }
192 if len(versions) != blobKeepVersions {
193 t.Fatalf("history: want %d versions kept, got %d", blobKeepVersions, len(versions))
194 }
195 if oldest := versions[len(versions)-1]; oldest != "3" { // 7 writes, keep 5 -> versions 3..7
196 t.Fatalf("history: want oldest kept = 3, got %s", oldest)
197 }
198}
199
200func itoa(i int) string { return strconv.Itoa(i) }
201
202func TestDevicesListing(t *testing.T) {
203 ts, s := newTestServer(t)
204 uid, tok := seedUser(t, s)
205 tok2, _ := s.store.createLinkToken(uid) // a second device
206
207 if r := do(t, "GET", ts.URL+"/api/devices", "", nil, nil); r.StatusCode != http.StatusUnauthorized {
208 t.Fatalf("no token: want 401, got %d", r.StatusCode)
209 }
210 r := do(t, "GET", ts.URL+"/api/devices", tok, nil, nil)
211 if r.StatusCode != http.StatusOK {
212 t.Fatalf("list: want 200, got %d", r.StatusCode)
213 }
214 raw, _ := io.ReadAll(r.Body)
215 // Token values (or hashes) must never appear in the listing.
216 for _, secret := range []string{tok, tok2, hashToken(tok), hashToken(tok2)} {
217 if strings.Contains(string(raw), secret) {
218 t.Fatalf("devices listing leaks a token value")
219 }
220 }
221 var out struct{ Devices []Device }
222 if err := json.Unmarshal(raw, &out); err != nil {
223 t.Fatalf("bad json: %v", err)
224 }
225 if len(out.Devices) != 2 {
226 t.Fatalf("want 2 devices, got %d", len(out.Devices))
227 }
228 currents := 0
229 for _, d := range out.Devices {
230 if d.ID == "" || d.CreatedAt == 0 || d.LastSeen == 0 {
231 t.Fatalf("device metadata incomplete: %+v", d)
232 }
233 if d.Current {
234 currents++
235 }
236 }
237 if currents != 1 {
238 t.Fatalf("exactly one device should be marked current, got %d", currents)
239 }
240}
241
242func TestDestructiveOpsRequireCeremony(t *testing.T) {
243 ts, s := newTestServer(t)
244 uid, tok := seedUser(t, s)
245 do(t, "PUT", ts.URL+"/api/blob", tok, map[string]string{"If-Match": `"0"`}, []byte("precious"))
246
247 devices, _ := s.store.listDevices(uid)
248 devID := devices[0].ID
249
250 // A link token alone must never authorise a destructive op — that's the
251 // whole point of the proof mechanism (a stolen token can't lock you out).
252 if r := do(t, "DELETE", ts.URL+"/api/blob", tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
253 t.Fatalf("blob delete with link token: want 401, got %d", r.StatusCode)
254 }
255 if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
256 t.Fatalf("device delete with link token: want 401, got %d", r.StatusCode)
257 }
258
259 // A STALE proof (past its embedded expiry) is refused.
260 stale := s.keys.mintCeremonyProofAt(uid, time.Now().Add(-time.Minute))
261 if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": stale}, nil); r.StatusCode != http.StatusUnauthorized {
262 t.Fatalf("stale proof: want 401, got %d", r.StatusCode)
263 }
264
265 // A fresh proof from ANOTHER user can't touch this user's devices.
266 other, _ := seedUser(t, s)
267 otherProof := s.keys.mintCeremonyProof(other)
268 if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
269 t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
270 }
271
272 // A fresh proof for the right user works.
273 proof := s.keys.mintCeremonyProof(uid)
274 if r := do(t, "DELETE", ts.URL+"/api/devices/"+devID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
275 t.Fatalf("device delete with fresh proof: want 200, got %d", r.StatusCode)
276 }
277 if r := do(t, "DELETE", ts.URL+"/api/blob", "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
278 t.Fatalf("blob delete with fresh proof: want 200, got %d", r.StatusCode)
279 }
280 if _, _, found, _ := s.store.getBlob(uid); found {
281 t.Fatalf("blob (and history) should be gone after ceremony-gated delete")
282 }
283}
284
285func TestRegisterBeginOptions(t *testing.T) {
286 ts, s := newTestServer(t)
287 r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil)
288 if r.StatusCode != http.StatusOK {
289 t.Fatalf("begin: want 200, got %d", r.StatusCode)
290 }
291 var out struct {
292 Session string `json:"session"`
293 Options struct {
294 PublicKey struct {
295 Challenge string `json:"challenge"`
296 AuthenticatorSelection struct {
297 ResidentKey string `json:"residentKey"`
298 } `json:"authenticatorSelection"`
299 ExcludeCredentials []struct {
300 ID string `json:"id"`
301 } `json:"excludeCredentials"`
302 } `json:"publicKey"`
303 } `json:"options"`
304 }
305 if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
306 t.Fatalf("bad json: %v", err)
307 }
308 if out.Session == "" || out.Options.PublicKey.Challenge == "" {
309 t.Fatalf("begin response missing session/challenge")
310 }
311 // Login is discoverable-only, so registration must demand a resident key.
312 if out.Options.PublicKey.AuthenticatorSelection.ResidentKey != "required" {
313 t.Fatalf("residentKey: want required, got %q", out.Options.PublicKey.AuthenticatorSelection.ResidentKey)
314 }
315
316 // With a FRESH ceremony proof, begin targets the existing account: its
317 // credentials are excluded so the same authenticator can't double-enrol.
318 uid, _ := seedUser(t, s)
319 creds, _ := s.store.credentialsFor(uid)
320 proof := s.keys.mintCeremonyProof(uid)
321 r = do(t, "POST", ts.URL+"/api/register/begin", "", map[string]string{"X-Ceremony-Proof": proof}, nil)
322 if r.StatusCode != http.StatusOK {
323 t.Fatalf("begin with proof: want 200, got %d", r.StatusCode)
324 }
325 if err := json.NewDecoder(r.Body).Decode(&out); err != nil {
326 t.Fatalf("bad json: %v", err)
327 }
328 want := base64.RawURLEncoding.EncodeToString(creds[0].ID)
329 if len(out.Options.PublicKey.ExcludeCredentials) != 1 || out.Options.PublicKey.ExcludeCredentials[0].ID != want {
330 t.Fatalf("expected the existing credential to be excluded, got %+v", out.Options.PublicKey.ExcludeCredentials)
331 }
332}
333
334// A link token must never open an add-a-passkey ceremony: an enrolled
335// credential can mint fresh ceremony proofs, so accepting one here would
336// escalate a stolen read/write token into full account takeover.
337func TestRegisterBeginRefusesLinkToken(t *testing.T) {
338 ts, s := newTestServer(t)
339 _, tok := seedUser(t, s)
340 if r := do(t, "POST", ts.URL+"/api/register/begin", tok, nil, nil); r.StatusCode != http.StatusForbidden {
341 t.Fatalf("begin with link token: want 403, got %d", r.StatusCode)
342 }
343 // An unknown/garbage bearer token is refused the same way, not treated as
344 // an anonymous new enrolment.
345 if r := do(t, "POST", ts.URL+"/api/register/begin", "stolen-or-stale", nil, nil); r.StatusCode != http.StatusForbidden {
346 t.Fatalf("begin with bogus token: want 403, got %d", r.StatusCode)
347 }
348}
349
350func TestCredentialDeleteRequiresCeremony(t *testing.T) {
351 ts, s := newTestServer(t)
352 uid, tok := seedUser(t, s)
353 // A second credential, as register/finish with a fresh proof would store it.
354 second := &webauthn.Credential{ID: []byte("cred-second"), PublicKey: []byte("pk2")}
355 if err := s.store.insertCredential(uid, second); err != nil {
356 t.Fatalf("insertCredential: %v", err)
357 }
358 creds, _ := s.store.credentialsFor(uid)
359 firstID := base64.RawURLEncoding.EncodeToString(creds[0].ID)
360 secondID := base64.RawURLEncoding.EncodeToString(second.ID)
361
362 // A link token must not remove a passkey (it reduces access).
363 if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, tok, nil, nil); r.StatusCode != http.StatusUnauthorized {
364 t.Fatalf("delete with link token: want 401, got %d", r.StatusCode)
365 }
366 // Nor may another user's fresh proof.
367 other, _ := seedUser(t, s)
368 otherProof := s.keys.mintCeremonyProof(other)
369 if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": otherProof}, nil); r.StatusCode != http.StatusNotFound {
370 t.Fatalf("cross-user proof: want 404, got %d", r.StatusCode)
371 }
372 // A fresh proof for the owner works.
373 proof := s.keys.mintCeremonyProof(uid)
374 if r := do(t, "DELETE", ts.URL+"/api/credentials/"+secondID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusOK {
375 t.Fatalf("delete with fresh proof: want 200, got %d", r.StatusCode)
376 }
377 // The LAST credential is refused — this user has no SiwA/email anchors, so
378 // the passkey is the last sign-in method overall. (The guard evolved from
379 // last-passkey to last-method with #1089: an ANCHORED account may now shed
380 // its final passkey — that side of the matrix lives in anchors_test.go.
381 // Blob delete remains the wind-down path.)
382 if r := do(t, "DELETE", ts.URL+"/api/credentials/"+firstID, "", map[string]string{"X-Ceremony-Proof": proof}, nil); r.StatusCode != http.StatusConflict {
383 t.Fatalf("delete last credential: want 409, got %d", r.StatusCode)
384 }
385 if left, _ := s.store.credentialsFor(uid); len(left) != 1 {
386 t.Fatalf("want exactly 1 credential left, got %d", len(left))
387 }
388}
389
390func TestFinishRejectsUnknownSession(t *testing.T) {
391 ts, _ := newTestServer(t)
392 for _, path := range []string{"/api/register/finish", "/api/login/finish"} {
393 r := do(t, "POST", ts.URL+path+"?session=nope", "", nil, []byte("{}"))
394 if r.StatusCode != http.StatusBadRequest {
395 t.Fatalf("%s: want 400, got %d", path, r.StatusCode)
396 }
397 }
398}
399
400func TestBeginEndpointsRateLimit(t *testing.T) {
401 ts, _ := newTestServer(t)
402 limited := false
403 for i := 0; i < 30; i++ {
404 r := do(t, "POST", ts.URL+"/api/register/begin", "", nil, nil)
405 if r.StatusCode == http.StatusTooManyRequests {
406 limited = true
407 break
408 }
409 }
410 if !limited {
411 t.Fatalf("register/begin never rate-limited after 30 rapid requests")
412 }
413 // The bucket is shared per IP across both ceremony begin endpoints.
414 if r := do(t, "POST", ts.URL+"/api/login/begin", "", nil, nil); r.StatusCode != http.StatusTooManyRequests {
415 t.Fatalf("login/begin should share the exhausted bucket: want 429, got %d", r.StatusCode)
416 }
417}
418
419// The enrolment popup must serve, be HTML with a locked-down CSP (frame-ancestors
420// none — it must never be iframed), and carry no secrets (the entry payload
421// arrives client-side in the URL fragment, so a plain GET returns a static page).
422func TestServeEnrollPage(t *testing.T) {
423 ts, _ := newTestServer(t)
424 r := do(t, "GET", ts.URL+"/enroll", "", nil, nil)
425 if r.StatusCode != http.StatusOK {
426 t.Fatalf("GET /enroll: want 200, got %d", r.StatusCode)
427 }
428 if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
429 t.Fatalf("/enroll content-type = %q", ct)
430 }
431 if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") {
432 t.Fatalf("/enroll CSP too loose: %q", csp)
433 }
434 body, _ := io.ReadAll(r.Body)
435 if !strings.Contains(string(body), "/static/enroll.js") {
436 t.Fatalf("/enroll page missing enroll.js")
437 }
438}
439
440// The restore page must serve with the same locked-down CSP as the others and
441// carry no secrets (the request rides in the URL fragment).
442func TestServeRestorePage(t *testing.T) {
443 ts, _ := newTestServer(t)
444 r := do(t, "GET", ts.URL+"/restore", "", nil, nil)
445 if r.StatusCode != http.StatusOK {
446 t.Fatalf("GET /restore: want 200, got %d", r.StatusCode)
447 }
448 if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") {
449 t.Fatalf("/restore CSP too loose: %q", csp)
450 }
451 body, _ := io.ReadAll(r.Body)
452 if !strings.Contains(string(body), "/static/restore.js") {
453 t.Fatalf("/restore page missing restore.js")
454 }
455}
456
457// With no LCHAT_HOME_INFRASTRUCTURE_DIR configured (the default, and every
458// existing self-hosted deployment today), /infrastructure/ must 404 — not
459// panic, not serve an empty directory listing.
460func TestInfrastructureDisabledByDefault(t *testing.T) {
461 ts, _ := newTestServer(t)
462 for _, path := range []string{"/infrastructure", "/infrastructure/", "/infrastructure/index.html"} {
463 r := do(t, "GET", ts.URL+path, "", nil, nil)
464 if r.StatusCode != http.StatusNotFound {
465 t.Fatalf("GET %s with no infrastructureDir: want 404, got %d", path, r.StatusCode)
466 }
467 }
468}
469
470// With LCHAT_HOME_INFRASTRUCTURE_DIR set, /infrastructure/ serves straight
471// from that directory — not embedded, not copied — with a strict
472// same-origin CSP and no caching.
473func TestServeInfrastructureFromDisk(t *testing.T) {
474 store, err := openSQLiteStore(t.TempDir() + "/home.db")
475 if err != nil {
476 t.Fatalf("openSQLiteStore: %v", err)
477 }
478 s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret"))
479 if err != nil {
480 t.Fatalf("newServer: %v", err)
481 }
482 s.infrastructureDir = t.TempDir()
483 if err := os.WriteFile(s.infrastructureDir+"/index.html", []byte("<h1>planet-scale guide</h1>"), 0o644); err != nil {
484 t.Fatalf("write fixture guide page: %v", err)
485 }
486 ts := httptest.NewServer(s.handler())
487 t.Cleanup(ts.Close)
488
489 r := do(t, "GET", ts.URL+"/infrastructure/index.html", "", nil, nil)
490 if r.StatusCode != http.StatusOK {
491 t.Fatalf("GET /infrastructure/index.html: want 200, got %d", r.StatusCode)
492 }
493 if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") {
494 t.Fatalf("/infrastructure CSP too loose: %q", csp)
495 }
496 if cc := r.Header.Get("Cache-Control"); cc != "no-cache" {
497 t.Fatalf("/infrastructure Cache-Control = %q, want no-cache", cc)
498 }
499 body, _ := io.ReadAll(r.Body)
500 if !strings.Contains(string(body), "planet-scale guide") {
501 t.Fatalf("/infrastructure/index.html body = %q, missing fixture content", body)
502 }
503
504 // Bare "/infrastructure" (no trailing slash) redirects rather than
505 // 404ing or silently listing the directory.
506 noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
507 resp, err := noRedirect.Get(ts.URL + "/infrastructure")
508 if err != nil {
509 t.Fatalf("GET /infrastructure: %v", err)
510 }
511 if resp.StatusCode != http.StatusMovedPermanently {
512 t.Fatalf("GET /infrastructure: want 301, got %d", resp.StatusCode)
513 }
514 if loc := resp.Header.Get("Location"); loc != "/infrastructure/" {
515 t.Fatalf("GET /infrastructure Location = %q, want /infrastructure/", loc)
516 }
517}
518
519// Path traversal must not escape infrastructureDir — http.Dir/http.FileServer
520// already guard this, but it is exactly the kind of thing worth pinning with
521// a test given this route serves straight off the filesystem.
522func TestInfrastructureRejectsPathTraversal(t *testing.T) {
523 store, err := openSQLiteStore(t.TempDir() + "/home.db")
524 if err != nil {
525 t.Fatalf("openSQLiteStore: %v", err)
526 }
527 s, err := newServer(store, "http://localhost:1748", []byte("test-state-secret"))
528 if err != nil {
529 t.Fatalf("newServer: %v", err)
530 }
531 s.infrastructureDir = t.TempDir()
532 ts := httptest.NewServer(s.handler())
533 t.Cleanup(ts.Close)
534
535 r := do(t, "GET", ts.URL+"/infrastructure/../../../../etc/passwd", "", nil, nil)
536 if r.StatusCode == http.StatusOK {
537 t.Fatalf("GET /infrastructure/../../../../etc/passwd: want non-200, got 200")
538 }
539}
540
541// The landing page's inline pre-paint gate must be allowed by the CSP — a
542// hardcoded script-src silently discarded it for months (the sign-in flash).
543func TestLandingCSPAllowsInlineGate(t *testing.T) {
544 ts, s := newTestServer(t)
545 if !strings.Contains(string(s.page), "<script>") {
546 t.Skip("no inline scripts on the page")
547 }
548 resp := do(t, "GET", ts.URL+"/", "", nil, nil)
549 csp := resp.Header.Get("Content-Security-Policy")
550 if !strings.Contains(csp, "'sha256-") {
551 t.Fatalf("CSP has no inline-script hash: %q", csp)
552 }
553 if s.pageHashes == "" {
554 t.Fatalf("pageHashes empty despite inline script")
555 }
556}
557