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

@@ -41,7 +41,7 @@ Use cases:
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Logging: GORM's lines no longer go to stdout through GORM's own logger. They arrive through [diary](../diary/README.md) as component `db`: slow queries (over 200 ms) and errors at warn, while a missing record (`gorm.ErrRecordNotFound`) is no longer logged. At verbosity 3 (`-vvv`) every query is traced at info, with placeholders instead of the bound values. lagoon's own warnings log as component `lagoon`, and `attach` as component `attach`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since `attach` itself does not encode WebP; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`, and `Content-Disposition: attachment` on the blob for every type `attach.IsInlineContentType` does not report as an inline raster image (JPEG, PNG, GIF, WebP, AVIF), while `content_type` keeps the sniffed type), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since `attach` itself does not encode WebP; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`, and `Content-Disposition: attachment` on the blob for every type `attach.IsInlineContentType` does not report as an inline raster image (JPEG, PNG, GIF, WebP, AVIF), while `content_type` keeps the sniffed type), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving (`attach.StaticHandler`, with an optional `is_public` gate in `attach.StaticHandlerPublic`) that sends each file's stored Content-Type and Content-Disposition with `X-Content-Type-Options: nosniff`, and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- WebP variants (`attach/webp`): `webp.Variant` writes a WebP copy of a thumbnail next to it (the thumbnail is made first when missing), at quality 80 (`webp.Quality`), under `attach.VariantKey` (the thumbnail key with the `webp` extension), and returns its key for `attach.PublicURL`. A variant that exists is not written again, and a failed encode leaves no file. `webp.Encode` encodes any image. Callers run it in a background job, not in an upload request. The encoder uses the system libwebp when it is installed (through purego, no cgo) and libwebp transpiled to Go otherwise. A binary that imports the package is dynamically linked against glibc unless it is built with `-tags nodynamic` (transpiled encoder only, static binary). Importing the package also registers the encoder library's WebP decoder ahead of the pure Go one for the whole process, so every WebP decode, uploads included (thumbnails, size checks), goes through the system libwebp when it is installed: keep libwebp patched, or build with `-tags nodynamic`, which decodes with the transpiled copy.
## Usage
@@ -204,7 +204,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `attach.OpenBucket` | Opens the uploads bucket from config; refuses `s3://` in a binary built without `attach.S3BuildTag`. |
| `attach.S3BuildTag` | The build tag, `summercms_s3`, that links the S3 driver; list it under `build.tags` in `summer.yaml`. |
| `attach.Publish` | Stores the bucket on the `backpack.App`. |
| `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix. |
| `attach.StaticHandler` | Serves stored files and thumbnails under a URL prefix with their stored Content-Type and Content-Disposition and `X-Content-Type-Options: nosniff`. |
| `attach.StaticHandlerPublic` | `attach.StaticHandler` plus a 404 for rows that are not public. |
| `attach.DeleteForOwner` | Deletes an owner's attachment rows in a transaction and reports their blob keys. |
| `attach.DeleteKeys` | Deletes blobs, including thumbnails, after the transaction commits. |

View File

@@ -241,6 +241,41 @@ func TestStaticHandlerPublicLooksUpIsPublic(t *testing.T) {
if code := get("/storage/uploads/abc/123/xyz/missing.jpg"); code != http.StatusNotFound {
t.Fatalf("unknown disk_name status = %d, want 404", code)
}
// The stored disposition and nosniff go through the is_public gate.
htmlFile := attach.File{DiskName: "fed654cba.txt", FileName: "note.txt", FileSize: 1, ContentType: "text/html", IsPublic: &pub}
if err := gdb.Create(&htmlFile).Error; err != nil {
t.Fatal(err)
}
if err := bucket.WriteAll(ctx, attach.BlobKey(htmlFile.DiskName), []byte("<html></html>"), &blob.WriterOptions{ContentType: "text/html", ContentDisposition: "attachment"}); err != nil {
t.Fatal(err)
}
serve := func(path string) *http.Response {
t.Helper()
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
return rr.Result()
}
html := serve("/storage/uploads/fed/654/cba/fed654cba.txt")
if html.StatusCode != http.StatusOK {
t.Fatalf("public html status = %d, want 200", html.StatusCode)
}
if got := html.Header.Get("Content-Disposition"); got != "attachment" {
t.Errorf("public html Content-Disposition = %q, want attachment", got)
}
if got := html.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("public html X-Content-Type-Options = %q, want nosniff", got)
}
jpeg := serve("/storage/uploads/abc/123/xyz/abc123xyz.jpg")
if jpeg.StatusCode != http.StatusOK {
t.Fatalf("public jpeg status = %d, want 200", jpeg.StatusCode)
}
if got := jpeg.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("public jpeg X-Content-Type-Options = %q, want nosniff", got)
}
if got := jpeg.Header.Values("Content-Disposition"); len(got) != 0 {
t.Errorf("public jpeg Content-Disposition = %q, want none", got)
}
}
func assertFileRow(t *testing.T, gdb *gorm.DB, id uint, want bool) {

View File

@@ -3,6 +3,7 @@ package attach
import (
"context"
"io"
"mime"
"net/http"
"path"
"strings"
@@ -23,12 +24,22 @@ const defaultStaticContentType = "application/octet-stream"
// disk). To 404 is_public=false rows, mount StaticHandlerPublic instead.
// Do not mount the ungated handler on the app origin — same-origin
// Content-Type from uploads is XSS-relevant.
//
// Every response carries X-Content-Type-Options: nosniff. A served file gets
// its stored Content-Type and, when the blob has one, its stored
// Content-Disposition: Store stores attachment for every type
// IsInlineContentType rejects, and a stored value mime.ParseMediaType rejects
// is sent as attachment. The handler derives no disposition from the type and
// sends no Content-Security-Policy, so a blob stored without a disposition is
// served inline with its type. Reading the disposition costs one Attributes
// call per request.
func StaticHandler(bucket *blob.Bucket, prefix string) http.Handler {
return servePublicBlobs(bucket, prefix, nil)
}
// StaticHandlerPublic is StaticHandler plus an is_public gate. Missing
// rows and is_public=false both 404. The lookup runs before NewReader.
// It sends the same headers as StaticHandler.
func StaticHandlerPublic(bucket *blob.Bucket, prefix string, db *gorm.DB) http.Handler {
return servePublicBlobs(bucket, prefix, func(ctx context.Context, filename string) (bool, error) {
return fileIsPublic(ctx, db, filename)
@@ -42,6 +53,7 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
if bucket == nil {
http.NotFound(w, r)
return
@@ -63,6 +75,11 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
return
}
}
attrs, err := bucket.Attributes(r.Context(), key)
if err != nil {
http.NotFound(w, r)
return
}
reader, err := bucket.NewReader(r.Context(), key, nil)
if err != nil {
http.NotFound(w, r)
@@ -74,6 +91,9 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
ct = defaultStaticContentType
}
w.Header().Set("Content-Type", ct)
if cd := storedDisposition(attrs.ContentDisposition); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
if r.Method == http.MethodHead {
return
}
@@ -81,6 +101,21 @@ func servePublicBlobs(bucket *blob.Bucket, prefix string, allow func(context.Con
})
}
// storedDisposition is the value of the stored Content-Disposition a handler
// sends: empty for none, the trimmed value when mime.ParseMediaType accepts
// it, and attachment for a value it rejects, so malformed or injected
// metadata never reaches a response header.
func storedDisposition(raw string) string {
v := strings.TrimSpace(raw)
if v == "" {
return ""
}
if _, _, err := mime.ParseMediaType(v); err != nil {
return attachmentDisposition
}
return v
}
func stripStaticPrefix(path, prefix string) (string, bool) {
if prefix == "" {
return strings.TrimPrefix(path, "/"), true

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