<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hamze Zare</title>
    <description>The latest articles on DEV Community by Hamze Zare (@hamzezn).</description>
    <link>https://dev.to/hamzezn</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4086325%2F12b09804-e611-48c6-bad6-ae06d98f197b.jpg</url>
      <title>DEV Community: Hamze Zare</title>
      <link>https://dev.to/hamzezn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hamzezn"/>
    <language>en</language>
    <item>
      <title>A stored-XSS report we couldn't quite reproduce, and hardened anyway</title>
      <dc:creator>Hamze Zare</dc:creator>
      <pubDate>Thu, 17 Sep 2026 00:45:34 +0000</pubDate>
      <link>https://dev.to/hamzezn/a-stored-xss-report-we-couldnt-quite-reproduce-and-hardened-anyway-5f1i</link>
      <guid>https://dev.to/hamzezn/a-stored-xss-report-we-couldnt-quite-reproduce-and-hardened-anyway-5f1i</guid>
      <description>&lt;p&gt;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."&lt;/p&gt;

&lt;h2&gt;
  
  
  What we found when we checked
&lt;/h2&gt;

&lt;p&gt;The download route has forced &lt;code&gt;Content-Disposition: attachment&lt;/code&gt; and &lt;code&gt;application/octet-stream&lt;/code&gt; since December 2025 — confirmed with &lt;code&gt;git blame&lt;/code&gt;. The frontend attachment viewer has zero &lt;code&gt;iframe&lt;/code&gt;, &lt;code&gt;embed&lt;/code&gt;, or &lt;code&gt;object&lt;/code&gt; anywhere in the code; every non-image attachment goes through a Blob download, never an inline render. Uploads are already validated with &lt;code&gt;python-magic&lt;/code&gt;, reading the real file bytes instead of trusting the browser's claimed content type, and &lt;code&gt;image/svg+xml&lt;/code&gt; is explicitly excluded from the allow-list for exactly this reason — an SVG can carry a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed anyway
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;detected_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;magic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;mime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;is_image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FileStorageService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renders_inline_safely&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;detected_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;FileResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;media_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;detected_type&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_image&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/octet-stream&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content_disposition_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_image&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attachment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Content-Type-Options&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nosniff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default-src &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;; sandbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only an allow-listed image type is ever served as &lt;code&gt;inline&lt;/code&gt;. Every PDF, and everything we don't explicitly recognize, is forced to download — now with &lt;code&gt;nosniff&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI helped draft this write-up; the investigation, the code, and the fix are our own.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>fastapi</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
