fix(14.2): WR-03 uploads route sandboxes files and downloads non-images

The route answered with the blob's stored Content-Type, which attach.Store
sniffs from the bytes, so a public text upload starting with <html> was
served as text/html on the application origin; nosniff does not help when
the declared type is already HTML. Every response now carries a sandboxing
Content-Security-Policy, and a successful response whose type is not a
raster image (JPEG, PNG, GIF, WebP, AVIF) gets Content-Disposition:
attachment, on GET and HEAD. Covers stay inline.
This commit is contained in:
2026-10-12 11:20:43 +02:00
parent c30145ed25
commit 41a03b9987
5 changed files with 172 additions and 19 deletions

View File

@@ -122,6 +122,7 @@ const (
uploadsPublicDisk = "abc123xyz.png"
uploadsPrivateDisk = "def456uvw.png"
uploadsOrphanDisk = "ghi789rst.png"
uploadsHTMLDisk = "jkl012mno.txt"
uploadsPublicBody = "public-png-bytes"
)
@@ -208,6 +209,7 @@ func TestUploadsMount(t *testing.T) {
for _, f := range []attach.File{
{DiskName: uploadsPublicDisk, FileName: "cover.png", FileSize: 1, ContentType: "image/png", IsPublic: &pub},
{DiskName: uploadsPrivateDisk, FileName: "secret.png", FileSize: 1, ContentType: "image/png", IsPublic: &priv},
{DiskName: uploadsHTMLDisk, FileName: "notes.txt", FileSize: 1, ContentType: "text/html; charset=utf-8", IsPublic: &pub},
} {
if err := gdb.Create(&f).Error; err != nil {
t.Fatal(err)
@@ -222,6 +224,10 @@ func TestUploadsMount(t *testing.T) {
t.Fatal(err)
}
}
if err := bucket.WriteAll(ctx, attach.BlobKey(uploadsHTMLDisk), []byte("<html><script>alert(1)</script></html>"), &blob.WriterOptions{ContentType: "text/html; charset=utf-8"}); err != nil {
t.Fatal(err)
}
htmlPath := "/storage/uploads/" + attach.BlobKey(uploadsHTMLDisk)
t.Run("database without a bucket answers 404", func(t *testing.T) {
app := backpack.New(uploadsConfig(t, map[string]any{"storage.uploads.serve": true}))
@@ -269,6 +275,15 @@ func TestUploadsMount(t *testing.T) {
if got := r.header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
if r.header.Get("Content-Disposition") != "" || r.header.Get("Content-Security-Policy") != uploadsContentSecurityPolicy {
t.Fatalf("image/png: Content-Disposition=%q CSP=%q, want inline with the sandbox CSP", r.header.Get("Content-Disposition"), r.header.Get("Content-Security-Policy"))
}
// A public file whose stored type is text/html (a .txt upload whose
// bytes start with <html>) is served as an attachment, sandboxed.
html := uploadsDo(h, http.MethodGet, htmlPath)
if html.code != http.StatusOK || html.header.Get("Content-Disposition") != "attachment" || html.header.Get("Content-Security-Policy") != uploadsContentSecurityPolicy {
t.Fatalf("GET %s = %d Content-Disposition=%q CSP=%q, want 200 attachment with the sandbox CSP", htmlPath, html.code, html.header.Get("Content-Disposition"), html.header.Get("Content-Security-Policy"))
}
head := uploadsDo(h, http.MethodHead, publicPath)
if head.code != http.StatusOK || head.body != "" || head.header.Get("Content-Type") != "image/png" || head.header.Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("HEAD %s = %d %q %v", publicPath, head.code, head.body, head.header)
@@ -329,3 +344,64 @@ func TestUploadsMount(t *testing.T) {
}
})
}
// TestUploadsResponseHeaders: every uploads response carries nosniff and the
// sandboxing Content-Security-Policy; a successful response whose type is
// not an inline raster image (HTML, SVG, PDF, text, a missing type) gets
// Content-Disposition: attachment, on GET and HEAD; images stay inline and
// error responses get no disposition.
func TestUploadsResponseHeaders(t *testing.T) {
serve := func(ct string, status int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if status == http.StatusNotFound {
http.NotFound(w, req)
return
}
if ct != "" {
w.Header().Set("Content-Type", ct)
}
if req.Method == http.MethodHead {
return
}
_, _ = w.Write([]byte("<html><script>alert(1)</script></html>"))
})
}
for _, tc := range []struct {
ct string
status int
attachment bool
}{
{"image/png", 200, false},
{"image/jpeg", 200, false},
{"IMAGE/WEBP; charset=binary", 200, false},
{"image/gif", 200, false},
{"image/avif", 200, false},
{"text/html; charset=utf-8", 200, true},
{"image/svg+xml", 200, true},
{"application/pdf", 200, true},
{"text/plain; charset=utf-8", 200, true},
{"application/octet-stream", 200, true},
{"", 200, true},
{"not a media type;;", 200, true},
{"text/plain", http.StatusNotFound, false},
} {
for _, method := range []string{http.MethodGet, http.MethodHead} {
rec := httptest.NewRecorder()
serveUploads(rec, httptest.NewRequest(method, "/storage/uploads/abc/123/xyz/abc123xyz.png", nil), serve(tc.ct, tc.status))
h := rec.Header()
if h.Get("X-Content-Type-Options") != "nosniff" || h.Get("Content-Security-Policy") != uploadsContentSecurityPolicy {
t.Fatalf("%s %q: nosniff=%q csp=%q", method, tc.ct, h.Get("X-Content-Type-Options"), h.Get("Content-Security-Policy"))
}
got := h.Get("Content-Disposition")
if tc.attachment && got != "attachment" {
t.Fatalf("%s %q (%d): Content-Disposition = %q, want attachment", method, tc.ct, tc.status, got)
}
if !tc.attachment && got != "" {
t.Fatalf("%s %q (%d): Content-Disposition = %q, want none", method, tc.ct, tc.status, got)
}
}
}
if !strings.Contains(uploadsContentSecurityPolicy, "sandbox") || !strings.Contains(uploadsContentSecurityPolicy, "default-src 'none'") {
t.Fatalf("uploads CSP %q does not sandbox", uploadsContentSecurityPolicy)
}
}