<?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>Express | FirewallSync</title><link>https://firewallsync.com/tags/express/</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/express/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></channel></rss>