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.

What Executing Content Inherits, by Hosting Choice On a subdomain, executing content receives cookies scoped to the parent domain, counts as same-site for cookie policies, and can set cookies the application will read. On a separate registrable domain it receives none of these, so the worst case is limited to whatever the file itself contains. uploads.example.com receives cookies scoped to example.com counts as same-site for cookie policies can set cookies the application reads shares storage partitioning in some cases A file that executes here is much closer to first-party than the hostname suggests. example-usercontent.net no application cookies are ever sent cross-site for every cookie policy cannot set anything the application reads separate storage partition entirely Worst case is bounded by what the file itself contains — nothing of yours.

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.

Each Header Removes One Capability The no-sniff header stops the browser re-guessing the type from content. The attachment disposition makes a renderable file download instead of rendering. The deny-all policy with a sandbox neutralises scripting and fetching in anything that is rendered. And a no-referrer policy stops a signed URL leaking into a third party log. No-sniff — the declared type is final without it, the browser may reinterpret the bytes and render what you called an image Attachment disposition — download rather than render the renderable case is opt-in, per type, and reviewable Deny-all policy plus sandbox — nothing executes or fetches the last line of defence for anything that is rendered anyway No-referrer — the signed URL stays out of other logs a bearer credential should not travel in a referrer header

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.

Who Decides, Who Enforces The browser asks the application for a download link. The application performs the authorization decision using the session and roles, then returns a short-lived signed URL. The browser fetches from the content origin, which sends no cookies. The proxy there verifies only the signature and expiry, which is all it needs to know. Browser asks for a link Application session + roles decide, then signs one object Content origin no cookies arrive here verifies signature + expiry Bytes served The split is deliberate: authority lives where the roles are, enforcement lives where the bytes are, and the content origin never needs to know who anyone is.

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.