docs(storage): pin the script-chunk PNG upload and split the uploads deploy note (quick-261028-r16)

- TestPNGScriptChunkNaming (no Docker) and TestStoreScriptChunkPNG (Postgres)
  pin the todo exploit: a decodable PNG with a script tEXt chunk uploaded in
  image mode as x.html is stored and served as .png, bytes unchanged
- storage deploy note separates static servers (nosniff, nginx extension
  allow-list, 404 otherwise) from public buckets, which send the stored
  sniffed Content-Type; attachments.md points to it
This commit is contained in:
Jakub Zych
2026-10-28 19:42:28 +01:00
parent 6ea2cb70e0
commit ebdfd78ab4
3 changed files with 130 additions and 2 deletions

View File

@@ -121,7 +121,43 @@ The route looks up the bucket and the database on each request: `route:list` lis
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.
> **Deploy note.** When a static server such as nginx, or a CDN, serves the uploads directory or bucket directly, it picks the Content-Type from the file extension and sends none of the route's headers. `attach.Store` names each file after its sniffed content (`attach.DiskExtension`), so a PNG uploaded as `x.html` is stored as `.png`, but configure the server as the route behaves anyway: send `X-Content-Type-Options: nosniff` on every upload, and serve only the extensions the application accepts (for example the image and document types in `attach.DefaultFileExtensions`), answering 404 for anything else.
### Deploy note
`attach.Store` names each file after its sniffed content (`attach.DiskExtension`), so a PNG uploaded as `x.html` is stored as `.png`. What reaches the browser still depends on who serves the uploads when the built-in route does not, and there are two cases.
**A static server serving the uploads directory.** nginx and similar servers pick the Content-Type from the file extension and send none of the route's headers. Send `X-Content-Type-Options: nosniff` on every response, serve an allow-list of extensions with a types map of its own, and answer 404 for everything else:
```nginx
server {
# nosniff for every response. An add_header inside a location replaces
# every add_header inherited from the server level, so keep the security
# headers here.
add_header X-Content-Type-Options nosniff always;
# Raster images only. The types come from this block, not from the
# server's mime.types.
location ~* ^/storage/app/uploads/public/.+\.(?:jpe?g|png|gif|webp)$ {
root /srv/acme;
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
image/webp webp;
}
default_type application/octet-stream;
}
# Everything else under /storage/ is refused. This must stay a plain
# prefix location, not ^~, so that nginx tries the regex above first.
location /storage/ {
return 404;
}
}
```
Extend the extension list only with types the application accepts (see `attach.DefaultFileExtensions`), and serve anything that is not a raster image as a download (`Content-Disposition: attachment`) or from a separate origin, the way the built-in route does.
**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 Content-Type stored with the object, and `attach.Store` stores the sniffed type. An HTML body uploaded as `note.txt` (txt is in `attach.DefaultFileExtensions`) is stored as `<hex>.txt` but served as `text/html`. Serve such a bucket from its own origin, never the application's, and set `X-Content-Type-Options: nosniff` and, for everything but raster images, `Content-Disposition: attachment` through the bucket's or the CDN's response-header settings.
## Upload size