package cabana_test import ( "bytes" "encoding/json" "fmt" "image" "image/color" "image/gif" "image/jpeg" "net/http" "net/http/httptest" "strings" "testing" ) // dfWebP is a 16x12 lossless WebP (the attach package's thumbnail fixture). var dfWebP = []byte{ 0x52, 0x49, 0x46, 0x46, 0x2a, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x4c, 0x1d, 0x00, 0x00, 0x00, 0x2f, 0x0f, 0xc0, 0x02, 0x00, 0x0f, 0x70, 0x14, 0xfb, 0x53, 0xd0, 0x5e, 0x88, 0x7b, 0xfe, 0x83, 0x07, 0x62, 0xc1, 0x64, 0xfe, 0xd2, 0xbd, 0x21, 0x44, 0xf4, 0x3f, 0x74, 0x01, 0x00, } func dfGIF(t *testing.T) []byte { t.Helper() img := image.NewPaletted(image.Rect(0, 0, 4, 3), []color.Color{color.Black, color.White}) var buf bytes.Buffer if err := gif.Encode(&buf, img, nil); err != nil { t.Fatal(err) } return buf.Bytes() } func dfJPEG(t *testing.T) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 4, 3)) var buf bytes.Buffer if err := jpeg.Encode(&buf, img, nil); err != nil { t.Fatal(err) } return buf.Bytes() } // securityHeaders checks the D-10 headers every protected file response // carries. func securityHeaders(t *testing.T, what string, rec *httptest.ResponseRecorder) { t.Helper() h := rec.Header() if h.Get("X-Content-Type-Options") != "nosniff" || h.Get("Cache-Control") != "private, no-store" || h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" { t.Fatalf("%s security headers = %v", what, h) } } // manualPath is a gadget's protected manual file route. func manualPath(gadget, file uint, rest string) string { return dfPath(gadget, fmt.Sprintf("/files/manual/%d%s", file, rest)) } // TestProtectedFileScope: the protected download and thumb routes answer // 404 for another gadget's file, for another admin's pending file on id 0 // and for any is_public=true row; the caption, remove and reorder routes // answer 404 for another gadget's file id and change nothing (D-10, D-15). func TestProtectedFileScope(t *testing.T) { env := newDeferredEnv(t) g1 := env.gadget(t, "g1-"+env.stamp, false) g2 := env.gadget(t, "g2-"+env.stamp, false) f2 := env.storeFile(t, dfGadgetMorph, g2, "manual", "g2.png", conformPNG(t), false) p2 := env.storeFile(t, dfGadgetMorph, g2, "photos", "g2-photo.png", conformPNG(t), true) // The owner serves both (the thumb is generated and stored here, before // the snapshot). for _, rest := range []string{"/download", "/thumb"} { want(t, "G2's manual through G2"+rest, env.a.do(t, http.MethodGet, manualPath(g2, f2.ID, rest), nil, nil), http.StatusOK) } before := env.state(t) for _, rest := range []string{"/download", "/thumb"} { want(t, "G2's manual through G1"+rest, env.a.do(t, http.MethodGet, manualPath(g1, f2.ID, rest), nil, nil), http.StatusNotFound) } for name, rec := range map[string]*httptest.ResponseRecorder{ "caption": env.a.do(t, http.MethodPut, manualPath(g1, f2.ID, ""), map[string]any{"title": "stolen"}, sk(newSessionKey(t))), "remove": env.a.do(t, http.MethodDelete, manualPath(g1, f2.ID, ""), nil, sk(newSessionKey(t))), } { want(t, name+" of G2's file through G1", rec, http.StatusNotFound) } // Reorder takes the whole id set: G2's photo id is not in G1's set, so // it is the same 422 set mismatch as an id that exists nowhere (no // existence oracle), and nothing is reordered. foreign := env.a.do(t, http.MethodPost, dfPath(g1, "/files/photos/reorder"), map[string]any{"ids": []uint{p2.ID}}, sk(newSessionKey(t))) nowhere := env.a.do(t, http.MethodPost, dfPath(g1, "/files/photos/reorder"), map[string]any{"ids": []uint{p2.ID + 1000}}, sk(newSessionKey(t))) want(t, "reorder with G2's photo through G1", foreign, http.StatusUnprocessableEntity) if foreign.Body.String() != nowhere.Body.String() || nowhere.Code != foreign.Code { t.Fatalf("reorder answers differ: foreign %d %s, nowhere %d %s", foreign.Code, foreign.Body.String(), nowhere.Code, nowhere.Body.String()) } env.unchanged(t, "foreign file requests", before) t.Run("public rows", func(t *testing.T) { // A public photo, and a public row attached under the protected // field, are never served by the protected routes. mixed := env.storeFile(t, dfGadgetMorph, g2, "manual", "public.png", conformPNG(t), true) for _, path := range []string{ dfPath(g2, fmt.Sprintf("/files/photos/%d/download", p2.ID)), dfPath(g2, fmt.Sprintf("/files/photos/%d/thumb", p2.ID)), manualPath(g2, mixed.ID, "/download"), manualPath(g2, mixed.ID, "/thumb"), } { want(t, path, env.a.do(t, http.MethodGet, path, nil, nil), http.StatusNotFound) } }) t.Run("pending file of another admin", func(t *testing.T) { key := newSessionKey(t) up := env.a.upload(t, dfPath(0, "/files/manual"), "pending.png", conformPNG(t), sk(key)) want(t, "A uploads to id 0", up, http.StatusCreated) id := dataID(t, up.Body.Bytes()) want(t, "A downloads its pending file", env.a.do(t, http.MethodGet, manualPath(0, id, "/download"), nil, sk(key)), http.StatusOK) before := env.state(t) for _, rest := range []string{"/download", "/thumb"} { want(t, "B downloads A's pending file"+rest, env.b.do(t, http.MethodGet, manualPath(0, id, rest), nil, sk(key)), http.StatusNotFound) } want(t, "B removes A's pending file", env.b.do(t, http.MethodDelete, manualPath(0, id, ""), nil, sk(key)), http.StatusNotFound) want(t, "B captions A's pending file", env.b.do(t, http.MethodPut, manualPath(0, id, ""), map[string]any{"title": "x"}, sk(key)), http.StatusNotFound) if got := fileList(t, env.b.do(t, http.MethodGet, dfPath(0, "/files/manual"), nil, sk(key))); len(got) != 0 { t.Fatalf("B lists A's pending files %#v", got) } env.unchanged(t, "B's file requests with A's key", before) }) } // TestProtectedFileHeaders: only jpeg, png, gif and webp are served inline // with their own type; SVG, HTML and text download as an octet-stream // attachment. Every response carries nosniff, private no-store and the // sandbox CSP (D-10). func TestProtectedFileHeaders(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) svg := []byte(``) html := []byte("") for _, tc := range []struct { name string data []byte inline string filename string }{ {"logo one.svg", svg, "", "logo%20one.svg"}, {"page.html", html, "", "page.html"}, {"notes.txt", []byte("plain text\n"), "", "notes.txt"}, {"shot.png", conformPNG(t), "image/png", ""}, {"anim.gif", dfGIF(t), "image/gif", ""}, {"photo.jpg", dfJPEG(t), "image/jpeg", ""}, {"pic.webp", dfWebP, "image/webp", ""}, } { f := env.storeFile(t, dfGadgetMorph, g, "manual", tc.name, tc.data, false) rec := env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/download"), nil, nil) want(t, tc.name, rec, http.StatusOK) securityHeaders(t, tc.name, rec) h := rec.Header() if !bytes.Equal(rec.Body.Bytes(), tc.data) { t.Fatalf("%s body differs", tc.name) } if tc.inline != "" { if h.Get("Content-Type") != tc.inline || h.Get("Content-Disposition") != "" { t.Fatalf("%s inline headers = %v", tc.name, h) } thumb := env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/thumb"), nil, nil) want(t, tc.name+" thumb", thumb, http.StatusOK) securityHeaders(t, tc.name+" thumb", thumb) if ct := thumb.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") { t.Fatalf("%s thumb content type %q", tc.name, ct) } continue } if h.Get("Content-Type") != "application/octet-stream" || h.Get("Content-Disposition") != "attachment; filename*=UTF-8''"+tc.filename { t.Fatalf("%s attachment headers = %v", tc.name, h) } want(t, tc.name+" thumb", env.a.do(t, http.MethodGet, manualPath(g, f.ID, "/thumb"), nil, nil), http.StatusNotFound) } } // TestProtectedFileListHasNoURLs: the file list of the protected manual // field carries no url or thumb_url key, while the public photos list does. func TestProtectedFileListHasNoURLs(t *testing.T) { env := newDeferredEnv(t) g := env.gadget(t, "g-"+env.stamp, false) env.storeFile(t, dfGadgetMorph, g, "manual", "m.png", conformPNG(t), false) env.storeFile(t, dfGadgetMorph, g, "photos", "p.png", conformPNG(t), true) keys := func(field string) []map[string]any { rec := env.a.do(t, http.MethodGet, dfPath(g, "/files/"+field), nil, nil) want(t, field+" list", rec, http.StatusOK) var body struct { Data []map[string]any `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || len(body.Data) != 1 { t.Fatalf("%s list = %s (%v)", field, rec.Body.String(), err) } return body.Data } for _, item := range keys("manual") { if _, ok := item["url"]; ok { t.Fatalf("protected item has url: %v", item) } if _, ok := item["thumb_url"]; ok { t.Fatalf("protected item has thumb_url: %v", item) } } for _, item := range keys("photos") { if item["url"] == nil || item["thumb_url"] == nil { t.Fatalf("public item lacks urls: %v", item) } } }