Static file serving
GET requests are answered directly by nginx via sendfile – Python is never on the read path. This page covers the security headers you almost certainly want for that path, and when it's safe to drop them.
Stored-XSS hardening
PutFS accepts arbitrary blobs from authorised PUT-ers and serves them back from the same origin. Without hardening, nginx's default mime.types map sets Content-Type from the file extension – .html → text/html, .svg → image/svg+xml, .js → application/javascript. Any client who can PUT a file can then upload a .html containing <script> and trick a browser into executing it on the storage origin.
The shipped docker-compose.yml and contrib/putfs.nginx.conf defend against this with four directives in location /:
types { } default_type application/octet-stream;
add_header Content-Disposition 'attachment' always;
add_header X-Content-Type-Options 'nosniff' always;
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
| Directive | Effect |
|---|---|
types { } default_type application/octet-stream; |
Empties the type-by-extension map – every served file is application/octet-stream. Browsers won't render or execute it. |
Content-Disposition: attachment |
Force-download, no inline rendering. The browser saves the file rather than evaluating it. |
X-Content-Type-Options: nosniff |
Stops the browser from second-guessing the Content-Type and inferring HTML/JS from content. |
Content-Security-Policy: default-src 'none'; sandbox |
Belt-and-suspenders for browsers that ignore the others; disables script execution and origin access on the served document. |
These four directives cover the authenticated location / read path (nginx sendfile). The presigned /_/dl/ path – unauthenticated, and the URL browsers actually point a viewer at – is hardened differently; see Presigned downloads below.
Don't disable this without thinking
The four directives above are the difference between "blob storage" and "anyone with PUT can host an XSS payload on your storage origin". If you need PutFS to serve typed content (e.g. host a static site), do it from a separate hostname that nobody is ever logged into for anything else – not the same origin as anything cookie-bearing. Cookies, OAuth tokens, and localStorage are all same-origin-scoped; a stored XSS on the storage origin steals everything that origin sees.
If your trust model genuinely allows uploaders to host typed content (single-tenant, all uploaders are admins), remove the four directives in location /. Pair the change with a Content-Security-Policy you actually want, and ideally still keep X-Content-Type-Options: nosniff.
Presigned downloads
Presigned URLs (reference) are served from a separate location ^~ /_/dl/ block, gated by an HMAC token instead of an api key. This is the unauthenticated, browser-facing read path – the URL an <img>, <iframe>, or PDF viewer actually loads – so it needs stored-XSS hardening too.
It cannot reuse the location / recipe, though: presigned URLs exist so a browser can view a blob inline (a PDF, an image), which application/octet-stream + Content-Disposition: attachment would break. Instead /_/dl/ keeps the real Content-Type and the signed Content-Disposition, and adds two headers:
add_header X-Content-Type-Options 'nosniff' always;
add_header Content-Security-Policy 'sandbox' always;
| Directive | Effect on /_/dl/ |
|---|---|
X-Content-Type-Options: nosniff |
The browser honours the declared Content-Type exactly – it won't sniff text/html/JS out of the bytes. |
Content-Security-Policy: sandbox |
Loads the response in a unique, opaque origin with scripting disabled. An uploaded .html/.svg may still render, but its <script> / onload cannot execute and it cannot read the storage origin's cookies, localStorage, or make same-origin requests. Images and inline viewers (e.g. PDF) keep working. |
So /_/dl/ is deliberately less strict than location /: it permits inline rendering – the whole point of a presigned link – while still neutralising script execution. Content-Disposition is whatever the signer signed, so sign d=attachment for any link that should download rather than render.
Cross-origin viewers (an app on another origin fetching the presigned URL) additionally need the CORS wiring: snippets/cors.nginx.conf (response headers) plus snippets/cors-preflight.nginx.conf, included at the top of the location, before the key and token checks – wire both or neither. The ordering matters: a browser preflight OPTIONS can never carry a valid token – the HMAC binds $request_method, and the browser sends the preflight on its own before the signed request – so any config that checks credentials first answers every preflight with 403, and the CORS spec requires a 2xx. Simple requests (<img>, plain fetch()) skip the preflight, which makes the breakage easy to miss – it only surfaces on preflighted fetches: an If-Range or multi-range request, or any custom header (a simple single byte Range is CORS-safelisted and sends no preflight in current browsers).
The response-header half also emits Access-Control-Expose-Headers – without it, scripts cannot read Content-Range, Accept-Ranges, or the signed Content-Disposition cross-origin, and e.g. pdf.js silently falls back to downloading the whole file instead of range requests.
The snippets only work where a preflight reaches the location at all: the server-level if ($deny) gate runs before any location-level directive, and a preflight carries no api-key headers. /_/dl/ is exempt via the deny-map URI carve-out; reusing the snippets on an api-key-gated path needs an OPTIONS carve-out in the deny map first, otherwise the preflight include is silently dead code.
Choosing the strictness
- Inline viewers needed, uploads may be untrusted → the default above (real type +
nosniff+ CSPsandbox); script can't run. - No inline rendering ever needed → also drop
snippets/stored-xss-hardening.nginx.confinto/_/dl/, or always signd=attachment, to force-download everything.