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 = "" ) 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") }) }