Static file serving
GET requests are answered by nginx via sendfile; Python is never on the read path. This page covers the security headers that path needs, and when it is safe to drop them.
Stored-XSS hardening
PutFS accepts arbitrary blobs from authorised PUT-ers and serves them back from the same origin. With nginx's default mime.types, .html is served as text/html, so anyone who can PUT can host a <script> on the storage origin.
The shipped contrib/putfs.nginx.conf defends location / with four directives (snippets/stored-xss-hardening.nginx.conf):
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; |
Every served file is application/octet-stream; browsers neither render nor execute it. |
Content-Disposition: attachment |
Force-download, no inline rendering. |
X-Content-Type-Options: nosniff |
The browser cannot infer HTML/JS from the content. |
Content-Security-Policy: default-src 'none'; sandbox |
Belt and suspenders: no script execution, no origin access. |
Don't disable this without thinking
These four directives are the difference between blob storage and "anyone with PUT can host an XSS payload on your storage origin". To serve typed content (a static site), do it from a separate hostname nobody is ever logged into: cookies, OAuth tokens and localStorage are same-origin-scoped, and a stored XSS on the storage origin steals all of them. If your trust model allows uploaders to host typed content (single-tenant, all uploaders are admins), remove the directives in location /, pair the change with a CSP you actually want, and keep nosniff.
Presigned downloads
Presigned URLs (reference) are served from location ^~ /_/dl/, 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 loads, so it needs hardening too. It cannot reuse the recipe above: presigned URLs exist so a browser can view a blob inline, which octet-stream + attachment would break. /_/dl/ keeps the real Content-Type and the signed Content-Disposition, and adds:
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 declared Content-Type is honoured exactly; no sniffing HTML/JS out of the bytes. |
Content-Security-Policy: sandbox |
The response loads in a unique opaque origin with scripting disabled. An uploaded .html/.svg may still render, but its scripts cannot run and it cannot read the storage origin's cookies or make same-origin requests. Images and inline viewers keep working. |
Content-Disposition is whatever the signer signed, so sign d=attachment for links that should download rather than render.
Cross-origin viewers (an app on another origin fetching the presigned URL) also need the CORS wiring: snippets/cors.nginx.conf (response headers, including Access-Control-Expose-Headers for Content-Range, Accept-Ranges and Content-Disposition, without which pdf.js silently downloads whole files instead of range requests) plus snippets/cors-preflight.nginx.conf, included at the top of the location, before the key and token checks. Wire both or neither. The order matters: a preflight OPTIONS can never carry a valid token (the HMAC binds $request_method), so a config that checks credentials first answers every preflight 403, where the CORS spec needs a 2xx. Simple requests (<img>, a plain fetch(), a single byte Range) send no preflight, so the breakage only shows on preflighted fetches: If-Range, multi-range, or any custom header.
The snippets only work where a preflight reaches the location: the server-level if ($deny) gate runs first and a preflight carries no api-key headers. /_/dl/ is exempt via the deny map's URI carve-out; reusing the snippets on an api-key-gated path needs an OPTIONS carve-out in the deny map, or the preflight include is dead code.
Choosing the strictness
- Inline viewers needed, uploads may be untrusted: the default above (real type +
nosniff+ CSPsandbox); scripts cannot run. - No inline rendering ever needed: also include
snippets/stored-xss-hardening.nginx.confin/_/dl/, or always signd=attachment.