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

@@ -84,7 +84,7 @@ fmt.Println(url)
URLs start with `storage.uploads.public_path_prefix` (`/storage/uploads` by default), or with `storage.uploads.public_url` when a public bucket or CDN serves the files (see [Public URL base](../services/storage.md#public-url-base)). `attach.File.URL` returns the URL of the original, the path WinterCMS's `File::getPath()` returns, and `attach.PublicURL` the URL of any blob key; [Storage](../services/storage.md#the-wintercms-layout) shows the configuration that reproduces WinterCMS's URLs exactly.
Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot write WebP, so the thumbnail of a `.webp` original holds JPEG bytes under the original's `.webp` name, and it is stored with the `image/jpeg` content type. Before decoding, the thumbnailer reads the image size from the file header, so an image larger than 4096 by 4096 pixels is never decoded. As WinterCMS's `File::makeThumb` does, an original that is missing, does not decode or is too large gets WinterCMS's 200 by 200 broken-image picture (`attach.BrokenImagePNG`) stored as its thumbnail, and the reason is logged at warn level: one unusable upload never makes the pages that list it fail. `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Mount the gated handler when a bucket holds any private file.
Originals in JPEG, PNG, GIF and WebP can be thumbnailed. The thumbnailer cannot write WebP, so the thumbnail of a `.webp` original holds JPEG bytes under the original's `.webp` name, and it is stored with the `image/jpeg` content type. Before decoding, the thumbnailer reads the image size from the file header, so an image larger than 4096 by 4096 pixels is never decoded. As WinterCMS's `File::makeThumb` does, an original that is missing, does not decode or is too large gets WinterCMS's 200 by 200 broken-image picture (`attach.BrokenImagePNG`) stored as its thumbnail, and the reason is logged at warn level: one unusable upload never makes the pages that list it fail. `attach.StaticHandler` serves originals and thumbnails under that prefix; `attach.StaticHandlerPublic` does the same and answers 404 for a row whose `is_public` flag is false. Both send `X-Content-Type-Options: nosniff` and each file's stored Content-Type and Content-Disposition, and send no Content-Security-Policy, which is why the warning below still applies. Mount the gated handler when a bucket holds any private file.
> [!WARNING]
> Serve uploads from a separate origin, or at least never mount the ungated handler on the application's own origin. An uploaded file served with its own content type from the API's origin can run script in that origin.
@@ -137,7 +137,7 @@ The client file name never reaches a blob key. The disk name is 22 random hexade
The disk name's extension follows the content, not the client's name, because a static web server in front of the uploads directory picks the Content-Type from the extension. A public bucket, or a CDN that pulls from it, sends the type stored with the object instead; see the [deploy note](../services/storage.md#deploy-note) for both cases. `attach.DiskExtension` derives the extension from the sniffed type and the client's extension. For a specific type such as PNG, JPEG, PDF, MP3 or WebM, the client's extension is kept when it names that type, else the type's usual extension is used: a PNG uploaded as `x.html` (with `html` allowed) is stored as `<hex>.png`, and its URL ends in `.png`. For a generic sniff (plain text, HTML, XML, a zip container or unknown bytes) the client's extension is kept, so `report.docx` stays `.docx` and an HTML body uploaded as `note.txt` stays `.txt`, unless the extension is one a browser renders or runs (html, svg, xml, js, css, php and the like), which becomes `.txt` for text and `.bin` otherwise. `file_name` always keeps the client's name, so downloads still carry it.
`attach.Store` writes every file whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF) with `Content-Disposition: attachment`, so a browser that opens it from a public bucket downloads it instead of rendering it. `<img>`, `<video>` and `<audio>` embeds and the `content_type` column are unaffected, and thumbnails stay inline.
`attach.Store` writes every file whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF) with `Content-Disposition: attachment`, so a browser that opens it from a public bucket or through `attach.StaticHandler` and `attach.StaticHandlerPublic` downloads it instead of rendering it. `<img>`, `<video>` and `<audio>` embeds and the `content_type` column are unaffected, and thumbnails stay inline.
The content type is sniffed from the first bytes of the body, never taken from the client. `Limits.MIMETypes` narrows it further: an entry with a slash is a MIME pattern such as `image/png` or `image/*`, an entry without one is an extension, and a file that matches no entry returns `attach.ErrMIMEType`. `Limits.MaxBytes` is enforced while the body streams into the bucket; a body one byte over the limit returns `attach.ErrTooLarge` and leaves no blob and no row behind.

View File

@@ -119,7 +119,7 @@ Admin fileupload previews do not depend on this route: when a public image's URL
The route looks up the bucket and the database on each request: `route:list` lists it without opening either, and it answers 404 until `serve` has published them. The route owns the whole prefix, so start-up fails when a plugin registers a route at or under it.
Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.
Without the key, the application decides where files are served: mount `attach.StaticHandlerPublic` itself, or put a web server or CDN in front of the bucket directory. The handler sends the stored Content-Type, the stored Content-Disposition (so a file `attach.Store` stored as an attachment downloads) and `X-Content-Type-Options: nosniff`, but not the route's sandboxing Content-Security-Policy, and it adds no disposition to a file stored without one (see the [deploy note](#deploy-note)). Either way, serve uploads from a separate origin when you can; the [Attachments](../database/attachments.md) page explains why. The built-in route is meant for development and for deployments that cannot split the origin yet.
### Deploy note
@@ -159,9 +159,9 @@ Extend the extension list only with types the application accepts (see `attach.D
**A public S3 bucket, or a CDN that pulls from it, serving the files under `storage.uploads.public_url`.** It does not look at the extension: it sends the headers stored with the object. The stored Content-Type is the sniffed type, so an HTML body uploaded as `note.txt` (txt is in `attach.DefaultFileExtensions`) is stored as `<hex>.txt` with `text/html`. `attach.Store` therefore also stores `Content-Disposition: attachment` with every object whose type `attach.IsInlineContentType` does not report as a raster image (JPEG, PNG, GIF, WebP, AVIF). S3, and a CDN that forwards the stored headers, make a browser that opens the URL download HTML, SVG, PDF or plain text instead of rendering it. `<img>`, `<video>` and `<audio>` embeds are not affected, and thumbnails, being images, carry no disposition.
Objects written before this change, or by code that bypasses `attach.Store`, carry no disposition: set it on them, or add the rule in the CDN. A CDN that rewrites or drops the origin's headers needs the rule itself. Either way, serve such a bucket from its own origin, never the application's, and set `X-Content-Type-Options: nosniff` through the bucket's or the CDN's response-header settings, since S3 cannot store that header on an object.
Objects written before this change, or by code that bypasses `attach.Store`, carry no disposition: set it on them, or add the rule in the CDN. `attach.StaticHandler` and `attach.StaticHandlerPublic` send the stored disposition too, so the same caveat applies to them, while the built-in route also decides from the type. A CDN that rewrites or drops the origin's headers needs the rule itself. Either way, serve such a bucket from its own origin, never the application's, and set `X-Content-Type-Options: nosniff` through the bucket's or the CDN's response-header settings, since S3 cannot store that header on an object.
A `file://` bucket keeps the disposition in the file driver's `.attrs` sidecar files, which a static server does not read, so the static-server case above still applies to it.
A `file://` bucket keeps the disposition in the file driver's `.attrs` sidecar files. `attach.StaticHandler`, `attach.StaticHandlerPublic` and the built-in route read the sidecar; a static server does not, so the static-server case above applies only to a web server reading the directory.
## Upload size

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

View File

@@ -35,9 +35,9 @@ const uploadsContentSecurityPolicy = "default-src 'none'; img-src 'self'; media-
// (attach.IsInlineContentType) is served as an attachment: attach.Store
// records the type sniffed from the bytes, so an uploaded text file that
// starts with <html> is text/html. attach.Store also stores the attachment
// disposition with the blob, but the route still decides from the type on
// every response: attach.StaticHandlerPublic sends only the stored type, and
// blobs written earlier or outside attach.Store carry no disposition. The
// disposition with the blob and attach.StaticHandlerPublic sends it, but the
// route still decides from the type on every response, because blobs written
// earlier or outside attach.Store carry no disposition. The
// bucket and the database are looked up per request: boot and route:list
// build the router before serve publishes either.
// The route owns the whole prefix: a plugin route at or under it fails