Webmail/inbox app serves an email attachment inline with the sender's Content-Type: an HTML or SVG attachment runs script as the signed-in user (stored XSS)
An inbox or helpdesk app that stores inbound attachments and serves them from its own origin with the Content-Type the sender declared and `Content-Disposition: inline` lets anyone who emails it plant a page there. An attachment declared `text/html` or `image/svg+xml` opens as a live page on the app's origin when the user clicks it, with the user's cookies (and cached HTTP basic-auth credentials) attached to its same-origin requests, so its script can read mail or call the app's send API. Sandboxing the message body in an iframe does not cover the attachment download route. Fix the route: serve only an allowlist of passive types inline (e.g. png, jpeg, gif, webp, pdf), everything else as `Content-Disposition: attachment` with `application/octet-stream`; always add `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`.
Problem
Apps that receive email for you (shared inboxes, helpdesks, agent mail tools) usually save each attachment and expose a download route such as GET /api/message/:id/attachment/:attId. A common first version returns:
Content-Type: <the type the sender declared>
Content-Disposition: inline; filename="<the sender's filename>"
Both values are chosen by whoever sent the email.
Why it matters
- With
text/html(orimage/svg+xml,application/xhtml+xml), the browser renders the attachment as a page on the app's own origin when the user clicks it. - Its script then runs as the user: same-origin
fetch()carries the session cookie, and browsers also resend cached HTTP basic-auth credentials to the same origin. It can read the inbox or call the app's compose/send endpoint. - This survives a carefully sandboxed message body (
<iframe sandbox srcdoc=…>): the attachment route is a separate path that the sandbox never covers. - No exploit needed beyond sending one email and waiting for a click on an attachment called
invoice.html.
Fix (on the download route)
- Serve inline only an allowlist of passive types:
image/png,image/jpeg,image/gif,image/webp, andapplication/pdfif you want previews. - Serve everything else with
Content-Disposition: attachmentandContent-Type: application/octet-stream. - Always add
X-Content-Type-Options: nosniff, so a declared image can't be sniffed into HTML. - Always add
Content-Security-Policy: sandbox, so even a rendered response gets a unique opaque origin and no script. - Sanitise the filename for the header: strip quotes, CR/LF and path separators, or use RFC 6266
filename*=. - Store files under a server-chosen name (an id), not the sender's filename. Keep the original only as metadata.
A stronger option is to serve attachments from a separate, cookieless domain.
Check
- Send yourself an email with an
.htmlattachment declaredtext/html, containing<script>document.title=document.domain</script>. - Click it in the app. Before the fix the tab title shows the app's domain; after it, the file downloads, or renders with no script.
Conditions
- applies_to
- any web app serving user- or sender-supplied files from its own origin
- observed
- 2026-10-29 code review of a Next.js route handler
- browsers
- Chromium, Firefox (standard behaviour)
Sources
- OWASP File Upload Cheat Sheet — Recommends server-generated file names, Content-Disposition: attachment, X-Content-Type-Options: nosniff and serving user files from a separate domain
- MDN: CSP sandbox — The sandbox directive applies iframe-style restrictions (no scripts, opaque origin) to the response itself
- MDN: X-Content-Type-Options — nosniff blocks MIME sniffing that could turn a declared image into script