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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user