- attach.StaticHandler and attach.StaticHandlerPublic set X-Content-Type-Options: nosniff on every response - the stored disposition is read with one Attributes call after the is_public gate and sent on GET and HEAD; a value mime.ParseMediaType rejects is sent as attachment - blobs without a stored disposition are served as before - lagoon README, storage and attachments docs pages, surf uploads comment updated
317 lines
10 KiB
Go
317 lines
10 KiB
Go
package attach
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gocloud.dev/blob"
|
|
"gocloud.dev/blob/fileblob"
|
|
"gocloud.dev/blob/memblob"
|
|
)
|
|
|
|
func TestStaticHandler(t *testing.T) {
|
|
ctx := t.Context()
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
|
|
diskName := "abc123xyz.jpg"
|
|
key := BlobKey(diskName)
|
|
body := []byte("cover-bytes")
|
|
if err := bucket.WriteAll(ctx, key, body, &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
h := StaticHandler(bucket, "/storage/uploads")
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
|
|
res, err := http.Get(srv.URL + "/storage/uploads/abc/123/xyz/abc123xyz.jpg")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d", res.StatusCode)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "image/jpeg" {
|
|
t.Fatalf("Content-Type = %q, want image/jpeg", ct)
|
|
}
|
|
got, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(got) != string(body) {
|
|
t.Fatalf("body = %q", got)
|
|
}
|
|
|
|
missing, err := http.Get(srv.URL + "/storage/uploads/mis/sin/g.j/missing.jpg")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer missing.Body.Close()
|
|
if missing.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("missing status = %d, want 404", missing.StatusCode)
|
|
}
|
|
|
|
for _, path := range []string{
|
|
"/storage/uploads/abc/123/xyz/../abc123xyz.jpg",
|
|
"/storage/uploads/abc/123/xyz//abc123xyz.jpg",
|
|
"/storage/uploads/foo/bar/baz/abc123xyz.jpg",
|
|
"/storage/uploads/abc/123/xyz/abc123xyz.jpg/extra",
|
|
"/storage/uploads",
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
h.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("path %q status = %d, want 404", path, rr.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStaticHandlerServesThumbURL(t *testing.T) {
|
|
ctx := t.Context()
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
|
|
f := &File{ID: 42, DiskName: "abc123xyz.jpg"}
|
|
if err := bucket.WriteAll(ctx, BlobKey(f.DiskName), testJPEG(t), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thumbURL, err := f.Thumb(ctx, bucket, 200, 200, "crop")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const wantURL = "/storage/uploads/abc/123/xyz/thumb_42_200_200_0_0_crop.jpg"
|
|
if thumbURL != wantURL {
|
|
t.Fatalf("Thumb URL = %q, want %q", thumbURL, wantURL)
|
|
}
|
|
|
|
h := StaticHandler(bucket, "/storage/uploads")
|
|
srv := httptest.NewServer(h)
|
|
t.Cleanup(srv.Close)
|
|
|
|
res, err := http.Get(srv.URL + thumbURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("thumb GET status = %d, want 200", res.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("thumb body is empty")
|
|
}
|
|
|
|
mismatch := httptest.NewRecorder()
|
|
h.ServeHTTP(mismatch, httptest.NewRequest(http.MethodGet, "/storage/uploads/foo/bar/baz/abc123xyz.jpg", nil))
|
|
if mismatch.Code != http.StatusNotFound {
|
|
t.Fatalf("mismatched original partition status = %d, want 404", mismatch.Code)
|
|
}
|
|
}
|
|
|
|
func TestStaticHandlerPublicGate(t *testing.T) {
|
|
ctx := t.Context()
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
|
|
diskName := "abc123xyz.jpg"
|
|
body := []byte("cover-bytes")
|
|
if err := bucket.WriteAll(ctx, BlobKey(diskName), body, &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thumbName := ThumbFilename(42, 200, 200, 0, 0, "crop", "jpg")
|
|
thumbKey := PartitionDirectory(diskName) + thumbName
|
|
if err := bucket.WriteAll(ctx, thumbKey, []byte("thumb-bytes"), &blob.WriterOptions{ContentType: "image/jpeg"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var seen []string
|
|
deny := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) {
|
|
seen = append(seen, name)
|
|
return false, nil
|
|
})
|
|
for _, path := range []string{
|
|
"/storage/uploads/abc/123/xyz/abc123xyz.jpg",
|
|
"/storage/uploads/abc/123/xyz/" + thumbName,
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
deny.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("private %s status = %d, want 404", path, rr.Code)
|
|
}
|
|
}
|
|
if len(seen) != 2 || seen[0] != diskName || seen[1] != thumbName {
|
|
t.Fatalf("allow saw %v, want [%s %s] before NewReader", seen, diskName, thumbName)
|
|
}
|
|
|
|
allow := servePublicBlobs(bucket, "/storage/uploads", func(_ context.Context, name string) (bool, error) {
|
|
return name == diskName, nil
|
|
})
|
|
ok := httptest.NewRecorder()
|
|
allow.ServeHTTP(ok, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil))
|
|
if ok.Code != http.StatusOK {
|
|
t.Fatalf("public original status = %d, want 200", ok.Code)
|
|
}
|
|
blocked := httptest.NewRecorder()
|
|
allow.ServeHTTP(blocked, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/"+thumbName, nil))
|
|
if blocked.Code != http.StatusNotFound {
|
|
t.Fatalf("denied thumb status = %d, want 404", blocked.Code)
|
|
}
|
|
|
|
nilDB := StaticHandlerPublic(bucket, "/storage/uploads", nil)
|
|
missing := httptest.NewRecorder()
|
|
nilDB.ServeHTTP(missing, httptest.NewRequest(http.MethodGet, "/storage/uploads/abc/123/xyz/abc123xyz.jpg", nil))
|
|
if missing.Code != http.StatusNotFound {
|
|
t.Fatalf("nil db status = %d, want 404", missing.Code)
|
|
}
|
|
}
|
|
|
|
func TestStoredDisposition(t *testing.T) {
|
|
cases := []struct{ in, want string }{
|
|
{"", ""},
|
|
{" ", ""},
|
|
{"attachment", "attachment"},
|
|
{"inline", "inline"},
|
|
{`attachment; filename="a.png"`, `attachment; filename="a.png"`},
|
|
{" attachment ", "attachment"},
|
|
{"attachment;;", "attachment"},
|
|
{"attachment; filename", "attachment"},
|
|
{"attachment\r\nX-Evil: 1", "attachment"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := storedDisposition(tc.in); got != tc.want {
|
|
t.Errorf("storedDisposition(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStaticHandlerSendsStoredDisposition writes through writeBlob, the
|
|
// writer attach.Store uses, and reads the headers back through the handlers:
|
|
// the stored disposition and nosniff reach the response on GET and HEAD.
|
|
func TestStaticHandlerSendsStoredDisposition(t *testing.T) {
|
|
sidecar, err := fileblob.OpenBucket(t.TempDir(), nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
buckets := []struct {
|
|
name string
|
|
bucket *blob.Bucket
|
|
}{
|
|
{"memblob", memblob.OpenBucket(nil)},
|
|
{"fileblob sidecar", sidecar},
|
|
}
|
|
const (
|
|
prefix = "/storage/uploads"
|
|
htmlName = "abc123xyz.html"
|
|
pngName = "def456uvw.png"
|
|
htmlBody = "<html><script>alert(1)</script></html>"
|
|
)
|
|
pngBody := []byte{0x89, 'P', 'N', 'G'}
|
|
url := func(diskName string) string { return prefix + "/" + BlobKey(diskName) }
|
|
serve := func(h http.Handler, method, target string) *httptest.ResponseRecorder {
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, httptest.NewRequest(method, target, nil))
|
|
return rr
|
|
}
|
|
assertHeaders := func(t *testing.T, label string, rr *httptest.ResponseRecorder, wantCT, wantCD string) {
|
|
t.Helper()
|
|
res := rr.Result()
|
|
if res.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s: status = %d, want 200", label, res.StatusCode)
|
|
}
|
|
if got := res.Header.Get("Content-Type"); got != wantCT {
|
|
t.Errorf("%s: Content-Type = %q, want %q", label, got, wantCT)
|
|
}
|
|
if got := res.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("%s: X-Content-Type-Options = %q, want nosniff", label, got)
|
|
}
|
|
got := res.Header.Values("Content-Disposition")
|
|
if wantCD == "" {
|
|
if len(got) != 0 {
|
|
t.Errorf("%s: Content-Disposition = %q, want none", label, got)
|
|
}
|
|
} else if len(got) != 1 || got[0] != wantCD {
|
|
t.Errorf("%s: Content-Disposition = %q, want %q", label, got, wantCD)
|
|
}
|
|
}
|
|
|
|
for _, b := range buckets {
|
|
bucket := b.bucket
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
t.Run(b.name, func(t *testing.T) {
|
|
ctx := t.Context()
|
|
if _, err := writeBlob(ctx, bucket, BlobKey(htmlName), strings.NewReader(htmlBody), "text/html", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := writeBlob(ctx, bucket, BlobKey(pngName), strings.NewReader(string(pngBody)), "image/png", 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := StaticHandler(bucket, prefix)
|
|
|
|
get := serve(h, http.MethodGet, url(htmlName))
|
|
assertHeaders(t, "html GET", get, "text/html", "attachment")
|
|
if got := get.Body.String(); got != htmlBody {
|
|
t.Errorf("html GET body = %q, want %q", got, htmlBody)
|
|
}
|
|
head := serve(h, http.MethodHead, url(htmlName))
|
|
assertHeaders(t, "html HEAD", head, "text/html", "attachment")
|
|
if head.Body.Len() != 0 {
|
|
t.Errorf("html HEAD body = %q, want empty", head.Body.String())
|
|
}
|
|
|
|
png := serve(h, http.MethodGet, url(pngName))
|
|
assertHeaders(t, "png GET", png, "image/png", "")
|
|
if got := png.Body.Bytes(); string(got) != string(pngBody) {
|
|
t.Errorf("png GET body = %q, want %q", got, pngBody)
|
|
}
|
|
assertHeaders(t, "png HEAD", serve(h, http.MethodHead, url(pngName)), "image/png", "")
|
|
|
|
gated := servePublicBlobs(bucket, prefix, func(context.Context, string) (bool, error) { return true, nil })
|
|
gatedGet := serve(gated, http.MethodGet, url(htmlName))
|
|
assertHeaders(t, "gated html GET", gatedGet, "text/html", "attachment")
|
|
if got := gatedGet.Body.String(); got != htmlBody {
|
|
t.Errorf("gated html GET body = %q, want %q", got, htmlBody)
|
|
}
|
|
|
|
missing := serve(h, http.MethodGet, url("mis123sin.html")).Result()
|
|
if missing.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("missing status = %d, want 404", missing.StatusCode)
|
|
}
|
|
if got := missing.Header.Get("X-Content-Type-Options"); got != "nosniff" {
|
|
t.Errorf("missing X-Content-Type-Options = %q, want nosniff", got)
|
|
}
|
|
if got := missing.Header.Values("Content-Disposition"); len(got) != 0 {
|
|
t.Errorf("missing Content-Disposition = %q, want none", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("memblob explicit dispositions", func(t *testing.T) {
|
|
ctx := t.Context()
|
|
bucket := memblob.OpenBucket(nil)
|
|
t.Cleanup(func() { _ = bucket.Close() })
|
|
const (
|
|
namedPNG = "ghi789rst.png"
|
|
brokenTXT = "jkl012mno.txt"
|
|
)
|
|
if err := bucket.WriteAll(ctx, BlobKey(namedPNG), pngBody, &blob.WriterOptions{ContentType: "image/png", ContentDisposition: `attachment; filename="a.png"`}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := bucket.WriteAll(ctx, BlobKey(brokenTXT), []byte(htmlBody), &blob.WriterOptions{ContentType: "text/html", ContentDisposition: "attachment;;"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := StaticHandler(bucket, prefix)
|
|
assertHeaders(t, "verbatim", serve(h, http.MethodGet, url(namedPNG)), "image/png", `attachment; filename="a.png"`)
|
|
assertHeaders(t, "fail closed", serve(h, http.MethodGet, url(brokenTXT)), "text/html", "attachment")
|
|
})
|
|
}
|