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