fix(lagoon/attach): StaticHandler sends the stored Content-Disposition and nosniff (quick-261105-oc8)

- 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
This commit is contained in:
Jakub Zych
2026-11-05 18:13:27 +01:00
parent 5aa846c5e8
commit 27c98bf22a
7 changed files with 222 additions and 10 deletions

View File

@@ -5,9 +5,11 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gocloud.dev/blob"
"gocloud.dev/blob/fileblob"
"gocloud.dev/blob/memblob"
)
@@ -172,3 +174,143 @@ func TestStaticHandlerPublicGate(t *testing.T) {
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")
})
}