How to serve user-uploaded files without exposing your main origin
In 2013 GitHub moved Pages to github.io because user HTML on a github.com subdomain could toss cookies at the main site. Upload validation decides what gets in; the serving layer decides what a hostile file can do once it is there. A practical guide to sandbox domains and the Public Suffix List, the header set for inert files (nosniff, Content-Disposition, CSP sandbox, CORP), rendering user HTML and SVG safely, signed URLs for private files, and a ten-minute audit.
By The Xalgorix Team
In April 2013 GitHub moved every GitHub Pages site off github.com subdomains and onto a new domain, github.io. The engineering post explaining the change describes a problem no upload validator could have fixed: a Pages site, which is user-controlled HTML served from a subdomain of the main site, could “throw” cookies at the parent domain. Browsers send only cookie names and values in the Cookie header, so the server could not tell a session cookie it had set from one planted by a user's page. GitHub built middleware to detect and strip duplicates, then concluded that cookie-overflow attacks could not be blocked server-side and that the only real fix was a different domain.
That story is the whole subject of this guide. Most upload hardening concentrates on the way in: allow-lists, magic bytes, random names, storage that never executes. All of that matters, and we covered the attacker's side of it in our piece on exploitable file uploads. This one is about the way out: which origin serves the bytes back, which headers travel with them, and how private files are authorised. Get those right and a hostile file that slips past validation lands somewhere it cannot hurt your users.
The serving origin sets the blast radius
When a browser renders an uploaded file, the file runs with the authority of the origin that served it. An HTML or SVG file served inline from app.example.com/uploads/ is, to the browser, a page of your application: its script can read same-origin data, call your APIs with the victim's cookies and rewrite the DOM of any same-origin window it can reach. Moving the files changes what a successful payload can touch.
The middle option is the one teams usually pick, and it is the one GitHub left. A subdomain is a different origin, so it cannot read your app's DOM or responses. It is still the same site, though, and two things follow. Script there can write a cookie scoped to the parent domain: MDN's Set-Cookie reference notes that setting Domain “makes the cookie available to that domain and all its subdomains.” That is enough for session fixation or for shadowing a CSRF token. And requests from that subdomain to your app count as same-site, so SameSite cookies give no protection against it.
If you cannot move yet, the __Host- cookie prefix removes the tossing half of the problem for the cookies that matter. Per MDN, a __Host- cookie must be Secure, must have no Domain attribute and must use Path=/, which “guarantees that such cookies are only sent to the host that set them, and not to any other host on the domain.” Rename the session and CSRF cookies to __Host-session and __Host-csrf, and a sibling subdomain can no longer overwrite them.
What a separate domain buys, and what it leaves open
Google has served user content from a separate domain for well over a decade. In an August 2012 post, Content hosting for the modern web, Michal Zalewski described putting “high-risk content in separate, isolated web origins,” most often googleusercontent.com, using “random or user-specific subdomains, depending on the degree of isolation required between unrelated documents.” The motivation was content sniffing: browsers could be manipulated into treating harmless looking images or text as HTML, and on the main domain that is cross-site scripting.
A separate registrable domain still leaves one gap. Every user's file sits on the same sandbox site, so one user's file can attack another's there, for example by tossing cookies between sibling subdomains of the sandbox domain. Google's 2023 follow-up, Securely hosting user data in modern web applications by David Dworken, closes it by adding the sandbox domain to the Public Suffix List, so that each subdomain becomes its own site, “cross-site and thus fully isolated from each other.”
The PSL is not a quick switch. The maintainers' guidelines require a _psl DNS TXT record for proof of control, at least two years of remaining registration, and patience: there are “NO SERVICE LEVEL AGREEMENTS ON TIME,” and browsers pick up changes only when they ship a new copy of the list. Small services are often declined. For most teams the practical target is a dedicated domain with per-tenant or per-file subdomains plus the headers below; the PSL is for platforms hosting content from many mutually untrusting users.
Headers for files that should never render
Most uploads (images, PDFs, archives, office documents) need to be displayed or downloaded, never executed as a page. For that inactive content the 2023 Google guidance gives a short header set. Each one closes a different path to rendering:
Content-Typeset by you to a known type without active content, falling back toapplication/octet-streamwhen unsure. The uploader'sContent-Typeis attacker input, and the OWASP File Upload Cheat Sheet calls it “trivial to spoof.”X-Content-Type-Options: nosniff, so the browser uses your type as-is. MDN's reference gives the example that fortext/plainwithnosniff, the browser “will not interpret it as HTML, even if the content contains HTML markup,” and the header also blocks non-JavaScript responses loaded as scripts and non-CSS responses loaded as stylesheets.Content-Disposition: attachment, which makes a direct visit download the file instead of rendering it.Content-Security-Policy: sandboxtogether withdefault-src 'none', so if something does render, it runs in an opaque origin with no scripts and no subresources.Cross-Origin-Resource-Policy, which controls which sites may embed the file at all.
One adjustment Google's list does not spell out: CORP same-site is right when files are served from your own site, but if they live on a separate sandbox domain and your app shows them in <img> tags, same-site blocks your own pages, because your app is a different site. On a separate domain, use cross-origin and let the other headers do the work. Here is the serving route as one piece:
import contentDisposition from "content-disposition";
// The ONLY types this route will ever emit. Keyed by the extension you
// validated at upload time, never by anything the client sends now.
const SAFE_TYPES = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["pdf", "application/pdf"],
["csv", "text/csv"],
]);
app.get("/f/:id", requireUser, async (req, res) => {
// 1. Authorize against the object, not just the session.
const file = await db.files.findVisibleTo(req.params.id, req.user.id);
if (!file) return res.sendStatus(404);
// 2. Type comes from your allow-list; unknown means opaque bytes.
const type = SAFE_TYPES.get(file.ext) ?? "application/octet-stream";
res.set({
"Content-Type": type,
"X-Content-Type-Options": "nosniff",
// attachment by default; encodes quotes, ;, and non-ASCII safely
"Content-Disposition": contentDisposition(file.originalName),
"Content-Security-Policy": "sandbox; default-src 'none'",
"Cross-Origin-Resource-Policy": "cross-origin", // separate domain
"Referrer-Policy": "no-referrer",
});
storage.createReadStream(file.key).pipe(res);
});Two details in that route are easy to get wrong. The original filename goes into Content-Disposition through a library, not string concatenation: an uploader controls it, and quotes, semicolons and non-ASCII characters all need encoding (the content-disposition package emits the RFC 6266 filename* form where needed). And the type comes from the extension you checked at upload time. A file stored as .svg is not in this allow-list, so it is served as opaque bytes.
When the product has to render HTML or SVG
Some products exist to display user markup: HTML previews, SVG avatars, rendered notebooks.attachment is off the table there, so the isolation has to come from the origin. The cheapest layer is the CSP sandbox directive on the response. MDN's reference says it applies restrictions like the iframe sandbox attribute, “including preventing popups, preventing the execution of plugins and scripts, and enforcing a same-origin policy.” Without allow-same-origin the document gets an opaque origin and cannot read cookies or localStorage. The directive is ignored in a <meta> tag and in Content-Security-Policy-Report-Only, so it must be a real response header.
Dworken's article says the header alone is probably enough if Spectre-class and renderer-compromise attacks are outside your threat model. Google's own design goes further. Every URL matching *.exampleusercontent.com/shim serves the same static shim file. The app opens an iframe on a random subdomain of that PSL-listed domain, sends it the content with postMessage, and the shim renders it as a Blob inside a sandboxed iframe. Because the app fetched the data itself, there is no capability URL to leak.
For SVG, a lighter option exists when the files only need to be displayed: show them through <img>, where browsers do not run scripts inside the image, and serve the URL itself with attachment and sandbox so a direct visit does not render it as a document.
Private files: cookies, signed URLs and the leak trade-off
A separate domain has a cost: your session cookie does not reach it, and third-party cookie blocking means you cannot easily give it one. The usual answer is a URL that carries its own authorization, such as a presigned object-store URL. Zalewski described the trade-off in 2012: “there are more ways to accidentally leak a capability-bearing URL than there are to accidentally leak cookies,” with the Referer header the obvious path. Google's response was graded by risk: short-lived document-specific cookies on the sandbox domain for high-risk content, user-bound or fast-expiring URLs for limited-risk content, and long-lived URLs only for low-risk material.
If you use Amazon S3 presigned URLs, you can also fix the response headers at signing time. The GetObject API lets you override Content-Type and Content-Disposition (among others) with response-content-type and response-content-disposition query parameters, and AWS notes these “cannot be used with an unsigned (anonymous) request”. Because they are part of the signature, the holder of the URL cannot change them:
url = s3.generate_presigned_url(
"get_object",
Params={
"Bucket": "example-user-files",
"Key": file.key, # random ID, not the user's name
"ResponseContentType": safe_type, # from your allow-list
"ResponseContentDisposition": "attachment",
},
ExpiresIn=300, # minutes, not days
)Two limits are worth knowing. The override does not add nosniff or a CSP; for those, put the bucket behind a CDN or proxy that sets response headers. And the override only covers downloads. If clients upload through presigned PUT URLs, the object stores whatever Content-Type the uploader sent unless you sign that header too, so any plain GET of the object later serves the attacker's choice.
Auditing your own serving layer in ten minutes
You can check most of this from a terminal. Upload a harmless HTML file and a harmless SVG (a visible marker, no network calls) to an account you own, then fetch each URL the app hands back:
curl -sI "$FILE_URL" | grep -iE \ 'content-type|content-disposition|x-content-type-options|content-security-policy|cross-origin-resource-policy|set-cookie'
- Host. Is it your app origin, a subdomain of your site, or a separate registrable domain? A subdomain means you need
__Host-cookies on the main app. - Type. Does the HTML test file come back as
text/htmlorimage/svg+xmlbecause the server echoed the extension or your upload'sContent-Type? That should beapplication/octet-streamor a download. - Headers. Are
nosniff,attachmentand asandboxCSP present on every response, including error pages, and on the CDN path as well as the origin? - Access. Does the URL still work when logged out, from another account, or after its stated expiry? Does it appear in a
Referersent to a third party?
Scanners, ours included, are good at the header half of this list and weaker at the parts that depend on product intent: whether a file was meant to be public, or whether a tenant boundary on a shared sandbox domain matters for your users. Those calls need someone who knows the product. If you want the matching policy for your main application, our guide to deploying a strict Content Security Policy covers that side.
Content-Type yourself, and send nosniff, Content-Disposition: attachment and Content-Security-Policy: sandbox on every response. Use __Host- cookies while uploads still share your site, and give private files short-lived, signed URLs whose response headers are part of the signature.Ready to see it prove a bug?
Start scanning on Cloud →