A stored-XSS report we couldn't quite reproduce, and hardened anyway
The Report
A researcher named Dhruv emailed us to report a stored XSS path: someone attaches a PDF with embedded JavaScript to a support ticket in our helpdesk product, and when a support agent opens it, the script runs "in the context of the application."
What We Found When We Checked
The download route has forced Content-Disposition: attachment and application/octet-stream since December 2025 - confirmed with git blame. The frontend attachment viewer has zero iframe, embed, or object anywhere in the code; every non-image attachment goes through a Blob download, never an inline render. Uploads are already validated with python-magic, reading the real file bytes instead of trusting the browser's claimed content type, and image/svg+xml is explicitly excluded from the allow-list for exactly this reason - an SVG can carry a <script> tag.
So the exact path Dhruv described likely doesn't reproduce as written. That's still not a reason to leave the code as it was.
What We Changed Anyway
Upload-time validation only tells you what a file was when someone uploaded it. Nothing stops the stored bytes from being served differently later if some other code path changes. We closed that gap by re-detecting the real MIME type at download time too, from the same bytes on disk, not the type stored in the database:
detected_type = magic.from_file(str(file_path), mime=True)
is_image = FileStorageService.renders_inline_safely(detected_type)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type=detected_type if is_image else "application/octet-stream",
content_disposition_type="inline" if is_image else "attachment",
headers={
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
},
)
Only an allow-listed image type is ever served as inline. Every PDF, and everything we don't explicitly recognize, is forced to download - now with nosniff and a sandboxed CSP on the response, so a browser can't decide otherwise even if some future code path opens the file directly. Four tests were added around this logic and it shipped to production the same day.
The Actual Lesson
A bug report that doesn't reproduce exactly as written is still worth reading carefully. Dhruv's email pointed at a real gap - not an exploitable one today, but a gap between what we validate at upload and what we trust at download. Closing that gap cost an afternoon. Finding out we needed to cost someone else's careful attention, and earned a reply thanking them for it.
AI helped draft this write-up; the investigation, the code, and the fix are our own.
Comments
No comments yet. Start the discussion.