Serving User Uploads From a Sandboxed Origin
Validation decides what enters the system; the serving path decides what an accepted file can do. A file that passes every check at upload time is still dangerous if it comes back from the application’s own origin with a guessed content type, because at that point the browser gives it the same security context as the application itself. This guide covers the delivery half: a separate origin, explicit types, headers that stop reinterpretation, and signed URLs that put authorization back on a path that no longer has a session.
It is part of the File Upload & Deserialization Security guide within Vulnerability Patterns & Web Mitigation Strategies, and its headers overlap directly with secure HTTP header configuration.
Prerequisites
- Object storage or a content delivery network you can configure headers on
- A second registrable domain (not a subdomain) available for user content
- The content type detected at upload time, stored with the object
- The ability to issue signed URLs, or a small signing proxy in front of storage
Expected Outcomes
- User content served from a domain that shares no cookies or storage with the application
- Content types sent explicitly and never re-derived from the file extension
- Renderable formats delivered as downloads, with scripting neutralised for anything that is rendered
- Access controlled by short-lived signed URLs scoped to a single object
Step 1: Put User Content on Its Own Registrable Domain
The distinction that matters is the registrable domain, not the hostname. uploads.example.com still shares cookies and same-site status with app.example.com; example-usercontent.net shares nothing.
Keep the application’s own static assets off that domain too. The moment your bundle is served from the content origin, an executing upload can tamper with a script other pages load, and the isolation is gone.
Step 2: Send the Type You Decided, and Stop Reinterpretation
Return the content type recorded at upload time, and tell the browser not to second-guess it.
// Signing proxy in front of object storage.
app.get('/f/:key', verifySignature, async (req, res) => {
const obj = await storage.get(req.params.key);
res.set({
'Content-Type': obj.metadata.detectedType, // decided at upload, never re-derived
'Content-Disposition': `attachment; filename="${asciiFallback(obj.metadata.name)}"; ` +
`filename*=UTF-8''${encodeURIComponent(obj.metadata.name)}`,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-site',
'Cache-Control': 'private, max-age=300',
'Referrer-Policy': 'no-referrer',
});
obj.stream.pipe(res);
});
Four of those headers each remove one specific capability: nosniff prevents the browser from re-guessing the type from content; the attachment disposition means a renderable file downloads instead of rendering; the policy plus sandbox neutralise scripting and fetching in anything that is rendered; and the no-referrer policy stops a signed URL from leaking into a third party’s logs.
Where the product must display images inline, use inline only for the narrow set of types you re-encode at upload, and keep the attachment disposition everywhere else. That trade is explicit and reviewable, which is the point.
Step 3: Re-Attach Authorization With Signed URLs
The content origin has no session, so authorization has to travel in the URL. The application decides, the storage layer enforces.
def signed_url(user, key: str, ttl_seconds: int = 300) -> str:
if not may_read(user, key): # the real authorization decision
raise Forbidden()
expires = int(time.time()) + ttl_seconds
payload = f"GET\n{key}\n{expires}".encode() # method and object are inside the signature
sig = hmac.new(URL_SIGNING_KEY, payload, hashlib.sha256).hexdigest()
return f"https://example-usercontent.net/f/{key}?e={expires}&s={sig}"
Three properties keep this honest. The signature covers the method as well as the object, so a read URL cannot be replayed as a write. The lifetime is minutes, because a signed URL is a bearer credential and will end up in a browser history, a chat message and an access log. And the authorization check happens in the application where the roles live, not in the storage layer where they do not.
Verification
URL=$(curl -s -H "Cookie: $SESSION" http://localhost:3000/api/files/$ID/link | jq -r .url)
# 1. Headers on the content origin.
curl -sI "$URL" | grep -iE 'content-type|content-disposition|x-content-type-options|content-security-policy'
# Expect: recorded type, attachment disposition, nosniff, deny-all policy with sandbox
# 2. No application cookie is sent to the content origin.
curl -sv "$URL" 2>&1 -H "Cookie: session=$SESSION" | grep -i '^> cookie' && echo "LEAK" || echo "no cookies sent"
# 3. The signature expires.
sleep 320 && curl -s -o /dev/null -w '%{http_code}\n' "$URL" # expect 403
# 4. The signature is bound to one object.
curl -s -o /dev/null -w '%{http_code}\n' "${URL/\/f\/$ID/\/f\/$OTHER_ID}" # expect 403
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Images no longer display in the application | Attachment disposition applied to inline image types | Use inline for the narrow set of re-encoded image types, attachment for everything else |
| Downloads have mangled filenames | Only the ASCII disposition parameter is set | Send both the ASCII fallback and the encoded parameter |
| Content origin returns 403 for valid users | Clock skew between signer and verifier | Synchronise clocks and allow a small skew tolerance in the expiry check |
| Signed URLs appear in third-party analytics | Referrer sent from the embedding page | Set a no-referrer policy on pages that embed content URLs |
| A stored file executes as script | Application bundle also served from the content origin | Move application assets back to the application origin |
| Files cached across users | Public caching on a private object | Set private caching and short lifetimes on signed responses |
Common Implementation Mistakes
Frequently Asked Questions
Why not just use a subdomain of the application?
Because a subdomain shares the registrable domain: cookies scoped to the parent are sent to it, it counts as same-site for cookie policies, and a document there can set cookies the application will read. A file that executes on uploads.example.com is considerably closer to first-party than the hostname implies. A separate registrable domain removes cookie sharing and same-site status altogether, which is what turns the separation from cosmetic into real.
Do signed URLs leak through referrers and logs?
They can, which is exactly why lifetimes are short and scopes narrow. Sign a single object rather than a prefix, expire in minutes, bind the signature to the intended method, and set a no-referrer policy on any page that embeds them. A signed URL is a bearer credential with an expiry attached — treat it with the same care you would give a token, and assume it will be copied into a chat window.
How do image transforms fit into this?
Run them server-side and serve only the transformed output. That keeps a parser between the original bytes and the browser, gives you a natural place to strip metadata, and means the original never has to be publicly reachable. If the transformer accepts a source URL parameter, it has become an outbound request surface — apply destination allowlisting exactly as you would for any server-side request forgery risk.
Related
- File Upload & Deserialization Security — the parent guide covering acceptance, storage and delivery together
- Validating File Uploads With Magic Bytes — deciding the type that this page then serves explicitly
- Secure HTTP Header Configuration — the wider header policy these responses fit into
- Server-Side Request Forgery (SSRF) Prevention — the risk introduced when a transformer fetches its own source