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

@@ -51,7 +51,13 @@ storage:
serve: true
```
The route serves originals and thumbnails and answers 404 for a missing file, an invalid path and a file whose `system_files` row is not public (`is_public` false). Every response carries `X-Content-Type-Options: nosniff`, so a browser does not sniff a stored file into HTML or script. 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.
The route serves originals and thumbnails and answers 404 for a missing file, an invalid path and a file whose `system_files` row is not public (`is_public` false). The stored content type is the one sniffed from the file's bytes at upload, so an uploaded text file that starts with `<html>` is stored as `text/html`; `X-Content-Type-Options: nosniff` alone does not stop a browser from rendering a file whose declared type is already HTML. The route therefore protects the application origin in three ways:
- Every response carries `X-Content-Type-Options: nosniff`.
- Every response carries `Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; sandbox`. A file opened as a page runs no script and gets an opaque origin, so it cannot read the application's cookies or storage. The policy does not affect an `<img>` or `<video>` that embeds the file.
- A file whose type is not a raster image (`image/jpeg`, `image/png`, `image/gif`, `image/webp` or `image/avif`) is sent with `Content-Disposition: attachment`, so the browser downloads it instead of showing it. That includes HTML, SVG, PDF and plain text.
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.

View File

@@ -55,7 +55,7 @@ These are the keys most applications set. Each module's reference page lists all
| `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](../../modules/surf/README.md) |
| `http.access_log` | Logs one record per request with method, matched route pattern, status and duration. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `storage.uploads.bucket_url` | Uploads bucket, `file://` or `mem://`. Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `admin.jwt.secret` | Secret for admin tokens. Required as soon as a plugin registers an admin controller. | [cabana](../../modules/cabana/README.md) |
| `backend.uri` | Path the admin is served under. Defaults to `/backend`. | [cabana](../../modules/cabana/README.md) |
| `mail.driver`, `mail.from`, `mail.smtp.*` | Mail delivery: `memory`, `log` or `smtp`. | [postcard](../../modules/postcard/README.md) |