The record and relation-child thumb routes now also serve the thumbnail of a public JPEG, PNG, GIF or WebP file, under the same record or pending-session scope and D-10 headers as a protected one, so the admin can preview a public image whose public URL does not load. The download routes still answer 404 for every public file, and a public non-image thumb stays 404. TestPublicImageThumb covers owner, foreign and hidden records, image types, a public row under a protected field, another admin's pending upload and the relation-child route.
303 lines
14 KiB
Go
303 lines
14 KiB
Go
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 and for another admin's pending file on
|
|
// id 0; the download route (not the thumb route, see TestPublicImageThumb)
|
|
// answers 404 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 streamed by the download route (their thumbs are
|
|
// covered by TestPublicImageThumb).
|
|
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)),
|
|
manualPath(g2, mixed.ID, "/download"),
|
|
} {
|
|
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)
|
|
})
|
|
}
|
|
|
|
// TestPublicImageThumb: the admin thumb routes (record and relation child)
|
|
// also serve the thumbnail of a public JPEG, PNG, GIF or WebP file, under
|
|
// the same scope and D-10 headers as a protected one, so the admin can
|
|
// preview a public image whose public URL does not load. The download
|
|
// routes still refuse every public file.
|
|
func TestPublicImageThumb(t *testing.T) {
|
|
env := newDeferredEnv(t)
|
|
g := env.gadget(t, "g-"+env.stamp, false)
|
|
photoPath := func(gadget, file uint, rest string) string {
|
|
return dfPath(gadget, fmt.Sprintf("/files/photos/%d%s", file, rest))
|
|
}
|
|
imageThumb := func(t *testing.T, what string, rec *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
want(t, what, rec, http.StatusOK)
|
|
securityHeaders(t, what, rec)
|
|
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/") {
|
|
t.Fatalf("%s content type %q", what, ct)
|
|
}
|
|
if rec.Body.Len() == 0 {
|
|
t.Fatalf("%s empty body", what)
|
|
}
|
|
}
|
|
pub := env.storeFile(t, dfGadgetMorph, g, "photos", "pub.png", conformPNG(t), true)
|
|
|
|
t.Run("owner", func(t *testing.T) {
|
|
imageThumb(t, "owner thumb", env.a.do(t, http.MethodGet, photoPath(g, pub.ID, "/thumb"), nil, nil))
|
|
want(t, "owner download", env.a.do(t, http.MethodGet, photoPath(g, pub.ID, "/download"), nil, nil), http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("foreign and hidden records", func(t *testing.T) {
|
|
g2 := env.gadget(t, "g2-"+env.stamp, false)
|
|
hidden := env.gadget(t, "hidden-"+env.stamp, true)
|
|
p2 := env.storeFile(t, dfGadgetMorph, g2, "photos", "g2.png", conformPNG(t), true)
|
|
ph := env.storeFile(t, dfGadgetMorph, hidden, "photos", "hidden.png", conformPNG(t), true)
|
|
before := env.state(t)
|
|
want(t, "G2's public photo through G", env.a.do(t, http.MethodGet, photoPath(g, p2.ID, "/thumb"), nil, nil), http.StatusNotFound)
|
|
want(t, "hidden gadget's public photo", env.a.do(t, http.MethodGet, photoPath(hidden, ph.ID, "/thumb"), nil, nil), http.StatusNotFound)
|
|
env.unchanged(t, "foreign public thumbs", before)
|
|
})
|
|
|
|
t.Run("image types", func(t *testing.T) {
|
|
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
|
txt := env.storeFile(t, dfGadgetMorph, g, "photos", "notes.txt", []byte("plain text\n"), true)
|
|
logo := env.storeFile(t, dfGadgetMorph, g, "photos", "logo.svg", svg, true)
|
|
before := env.state(t)
|
|
for _, f := range []uint{txt.ID, logo.ID} {
|
|
want(t, fmt.Sprintf("public non-image %d thumb", f), env.a.do(t, http.MethodGet, photoPath(g, f, "/thumb"), nil, nil), http.StatusNotFound)
|
|
}
|
|
env.unchanged(t, "public non-image thumbs", before)
|
|
for name, data := range map[string][]byte{"anim.gif": dfGIF(t), "pic.webp": dfWebP, "photo.jpg": dfJPEG(t)} {
|
|
f := env.storeFile(t, dfGadgetMorph, g, "photos", name, data, true)
|
|
imageThumb(t, name+" thumb", env.a.do(t, http.MethodGet, photoPath(g, f.ID, "/thumb"), nil, nil))
|
|
}
|
|
})
|
|
|
|
t.Run("public row under the protected field", func(t *testing.T) {
|
|
mixed := env.storeFile(t, dfGadgetMorph, g, "manual", "public.png", conformPNG(t), true)
|
|
imageThumb(t, "mixed thumb", env.a.do(t, http.MethodGet, manualPath(g, mixed.ID, "/thumb"), nil, nil))
|
|
want(t, "mixed download", env.a.do(t, http.MethodGet, manualPath(g, mixed.ID, "/download"), nil, nil), http.StatusNotFound)
|
|
})
|
|
|
|
t.Run("pending upload of another admin", func(t *testing.T) {
|
|
key := newSessionKey(t)
|
|
up := env.a.upload(t, dfPath(0, "/files/photos"), "pending.png", conformPNG(t), sk(key))
|
|
want(t, "A uploads a public photo to id 0", up, http.StatusCreated)
|
|
id := dataID(t, up.Body.Bytes())
|
|
imageThumb(t, "A's pending photo thumb", env.a.do(t, http.MethodGet, photoPath(0, id, "/thumb"), nil, sk(key)))
|
|
before := env.state(t)
|
|
want(t, "B's thumb of A's pending photo", env.b.do(t, http.MethodGet, photoPath(0, id, "/thumb"), nil, sk(key)), http.StatusNotFound)
|
|
want(t, "A's pending photo download", env.a.do(t, http.MethodGet, photoPath(0, id, "/download"), nil, sk(key)), http.StatusNotFound)
|
|
env.unchanged(t, "B's thumb with A's key", before)
|
|
})
|
|
|
|
t.Run("relation child", func(t *testing.T) {
|
|
g2 := env.gadget(t, "g2c-"+env.stamp, false)
|
|
p := env.part(t, g, "p-pub")
|
|
f := env.storeFile(t, dfPartMorph, p, "images", "part.png", conformPNG(t), true)
|
|
childPath := func(gadget uint, rest string) string {
|
|
return dfPath(gadget, fmt.Sprintf("/relations/parts/records/%d/files/images/%d%s", p, f.ID, rest))
|
|
}
|
|
imageThumb(t, "child thumb", env.a.do(t, http.MethodGet, childPath(g, "/thumb"), nil, ck(newSessionKey(t))))
|
|
before := env.state(t)
|
|
want(t, "child download", env.a.do(t, http.MethodGet, childPath(g, "/download"), nil, ck(newSessionKey(t))), http.StatusNotFound)
|
|
want(t, "child thumb through G2", env.a.do(t, http.MethodGet, childPath(g2, "/thumb"), nil, ck(newSessionKey(t))), http.StatusNotFound)
|
|
env.unchanged(t, "refused child file requests", 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(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)
|
|
html := []byte("<!DOCTYPE html><html><body><script>alert(1)</script></body></html>")
|
|
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)
|
|
}
|
|
}
|
|
}
|