Skip to content

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 – .htmltext/html, .svgimage/svg+xml, .jsapplication/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 content_disposition=attachment for any link that should download rather than render.

Choosing the strictness

  • Inline viewers needed, uploads may be untrusted → the default above (real type + nosniff + CSP sandbox); script can't run.
  • No inline rendering ever needed → also drop snippets/stored-xss-hardening.nginx.conf into /_/dl/, or always sign content_disposition=attachment, to force-download everything.

Further reading