package web
import (
"context"
"encoding/base64"
"net/http/httptest"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
type viewIn struct {
Repo string `json:"repo" validate:"required,max=64"`
Hash string `json:"hash" validate:"required,min=1,max=64"`
}
type viewOut struct {
Name string `json:"name"`
Size int `json:"size"`
Content string `json:"content"`
}
// viewRegistry answers attachment.get with bytes the test chose, so the
// route's own decision — sniff, then serve or refuse — is what is under
// test rather than a store.
func viewRegistry(t *testing.T, files map[string][]byte) *ops.Registry {
t.Helper()
r := ops.NewRegistry()
if err := ops.Register(r, ops.Op[viewIn, viewOut]{
Name: "attachment.get",
Caps: "jr",
Doc: "test stub",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/r/{repo}/attachment/{hash}",
Run: func(_ context.Context, in viewIn) (viewOut, error) {
raw, ok := files[in.Hash]
if !ok {
return viewOut{}, ops.ErrNotFound
}
return viewOut{Name: in.Hash + ".bin", Size: len(raw),
Content: base64.StdEncoding.EncodeToString(raw)}, nil
},
}); err != nil {
t.Fatal(err)
}
// The not-found page carries the ordinary chrome, which reads the
// version through the registry like every other page.
if err := ops.Register(r, ops.Op[struct{}, struct {
Version string `json:"version"`
}]{
Name: "meta.version",
Doc: "test stub",
Kind: ops.Query,
Auth: ops.Anonymous,
Route: "GET /api/v1/version",
Run: func(context.Context, struct{}) (struct {
Version string `json:"version"`
}, error) {
return struct {
Version string `json:"version"`
}{Version: "t"}, nil
},
}); err != nil {
t.Fatal(err)
}
return r
}
// A minimal PNG: the signature is what the route may decide from, and
// nothing else about the file is.
var pngBytes = append([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}, []byte("IHDRrest")...)
func TestAttachmentViewServesSniffedImageInline(t *testing.T) {
r := viewRegistry(t, map[string][]byte{"png1": pngBytes})
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/test/attachment/png1/view", nil))
if rec.Code != 200 {
t.Fatalf("FAIL: status %d", rec.Code)
}
h := rec.Header()
if h.Get("Content-Type") != "image/png" {
t.Errorf("FAIL: content-type %q", h.Get("Content-Type"))
}
if h.Get("X-Content-Type-Options") != "nosniff" {
t.Errorf("FAIL: nosniff missing")
}
if h.Get("Content-Security-Policy") != "default-src 'none'; sandbox" {
t.Errorf("FAIL: csp %q", h.Get("Content-Security-Policy"))
}
if !strings.HasPrefix(h.Get("Content-Disposition"), "inline; filename*=UTF-8''") {
t.Errorf("FAIL: disposition %q", h.Get("Content-Disposition"))
}
if h.Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Errorf("FAIL: cache-control %q", h.Get("Cache-Control"))
}
if rec.Body.Len() != len(pngBytes) {
t.Errorf("FAIL: served %d bytes, want %d", rec.Body.Len(), len(pngBytes))
}
t.Log("ok: a sniffed PNG is served inline under a denying CSP")
}
// The allowlist is by MAGIC BYTES. An SVG is XML however it is named or
// labelled, so it can never reach this route's inline path — which is
// the whole reason the route does not read the stored type.
func TestAttachmentViewRefusesNonImages(t *testing.T) {
r := viewRegistry(t, map[string][]byte{
"svg1": []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`),
"html1": []byte("<!doctype html><script>alert(1)</script>"),
"short": []byte{0x89},
})
for _, hash := range []string{"svg1", "html1", "short", "missing"} {
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/test/attachment/"+hash+"/view", nil))
if rec.Code != 404 {
t.Errorf("FAIL: %s answered %d, want 404", hash, rec.Code)
}
}
t.Log("ok: anything that does not sniff as an allowed image is 404, SVG included")
}
// The other three formats on the allowlist, each by its own signature.
func TestAttachmentViewAllowlist(t *testing.T) {
files := map[string][]byte{
"jpg1": append([]byte{0xff, 0xd8, 0xff, 0xe0}, []byte("0000JFIF")...),
"gif1": []byte("GIF89a............"),
"webp1": append([]byte("RIFF....WEBPVP8 "), []byte("rest")...),
}
want := map[string]string{"jpg1": "image/jpeg", "gif1": "image/gif", "webp1": "image/webp"}
r := viewRegistry(t, files)
for hash, ctype := range want {
rec := httptest.NewRecorder()
Handler(r, nil, nil).ServeHTTP(rec,
httptest.NewRequest("GET", "/r/test/attachment/"+hash+"/view", nil))
if rec.Code != 200 || rec.Header().Get("Content-Type") != ctype {
t.Errorf("FAIL: %s answered %d %q, want 200 %q",
hash, rec.Code, rec.Header().Get("Content-Type"), ctype)
}
}
t.Log("ok: PNG, JPEG, GIF and WebP each sniff to their own type")
}