<?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>Node.js | FirewallSync</title><link>https://firewallsync.com/tags/node.js/</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>Sat, 31 Oct 2026 04:50:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/node.js/index.xml" rel="self" type="application/rss+xml"/><item><title>User-Supplied Regex: How to Run It Without ReDoS</title><link>https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/</link><guid>https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/</guid><pubDate>Sat, 31 Oct 2026 04:50:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Search filters, alert rules, routing configs and log tools all end up accepting regular expressions from users. In a backtracking engine that is a denial-of-service switch you hand to whoever can type a pattern. This guide shows which engines are built for untrusted patterns (RE2, Go, Rust), which ones say plainly that they are not (.NET), what timeouts and possessive quantifiers can and cannot do, and two tested patterns for Python and Node.js.</description><enclosure url="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/user-supplied-regex-redos-safe-engines/cover_hu13911530470990273703.jpg" alt="Taut strands of red, orange, pink, purple, yellow and white thread crossing at angles to form a dense grid against a blurred grey background" width="1200" height="630"></p><p>If your product lets users type a regular expression (a log search box, an alerting rule, a URL routing rule, a &ldquo;block messages matching&rdquo; filter), you have handed them a way to burn your CPU. In a backtracking engine, a short pattern and a short input can keep a thread busy for seconds or years. The fix is not a better regex review process, because you are not the one writing the patterns.</p>
<p><strong>The short answer:</strong> run user-supplied patterns on an engine that guarantees linear-time matching (RE2, Go&rsquo;s <code>regexp</code>, Rust&rsquo;s <code>regex</code>), cap pattern length, and reject features those engines do not support instead of quietly falling back. If you are stuck with a backtracking engine, run the match somewhere you can kill it on a deadline.</p>
<h2 id="why-a-backtracking-engine-is-the-problem">Why a backtracking engine is the problem</h2>
<p>Python&rsquo;s <code>re</code>, JavaScript&rsquo;s <code>RegExp</code> (V8&rsquo;s Irregexp) and .NET&rsquo;s default engine are backtracking engines. When a match fails, they back up and try every other way the pattern could have split the input. For most patterns that is fast. For patterns with nested or overlapping repetition, the number of ways to split the input grows exponentially.</p>
<p>OWASP&rsquo;s wiki page on <a href="https://wiki.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS">ReDoS</a> describes an &ldquo;evil&rdquo; regex as one with grouping with repetition where the repeated group contains repetition or overlapping alternation. Its examples include <code>(a+)+</code>, <code>([a-zA-Z]+)*</code>, <code>(a|aa)+</code> and <code>(a|a?)+</code>.</p>
<p>We timed the first one in Python 3.13.18 against a string of <code>a</code> characters followed by <code>!</code>, so the match has to fail:</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> re<span style="color:#f92672">,</span> time
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>pat <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;^(a+)+$&#39;</span>)
</span></span><span style="display:flex;"><span>pos <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#39;^(?:a++)+$&#39;</span>)   <span style="color:#75715e"># possessive quantifier, Python 3.11+</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> n <span style="color:#f92672">in</span> (<span style="color:#ae81ff">18</span>, <span style="color:#ae81ff">20</span>, <span style="color:#ae81ff">22</span>, <span style="color:#ae81ff">24</span>):
</span></span><span style="display:flex;"><span>    s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;a&#39;</span> <span style="color:#f92672">*</span> n <span style="color:#f92672">+</span> <span style="color:#e6db74">&#39;!&#39;</span>
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter(); pat<span style="color:#f92672">.</span><span style="color:#66d9ef">match</span>(s); t1 <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> t
</span></span><span style="display:flex;"><span>    t <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter(); pos<span style="color:#f92672">.</span><span style="color:#66d9ef">match</span>(s); t2 <span style="color:#f92672">=</span> time<span style="color:#f92672">.</span>perf_counter() <span style="color:#f92672">-</span> t
</span></span><span style="display:flex;"><span>    print(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;n=</span><span style="color:#e6db74">{</span>n<span style="color:#e6db74">:</span><span style="color:#e6db74">2d</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> backtracking=</span><span style="color:#e6db74">{</span>t1<span style="color:#e6db74">:</span><span style="color:#e6db74">8.4f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">s possessive=</span><span style="color:#e6db74">{</span>t2<span style="color:#e6db74">:</span><span style="color:#e6db74">.6f</span><span style="color:#e6db74">}</span><span style="color:#e6db74">s&#34;</span>)
</span></span></code></pre></div><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>n=18 backtracking=  0.0088s possessive=0.000005s
</span></span><span style="display:flex;"><span>n=20 backtracking=  0.0429s possessive=0.000005s
</span></span><span style="display:flex;"><span>n=22 backtracking=  0.1680s possessive=0.000003s
</span></span><span style="display:flex;"><span>n=24 backtracking=  0.5356s possessive=0.000005s
</span></span></code></pre></div><p>Each additional two characters roughly quadrupled the time. Extrapolate to a 40-character input and one request ties up a worker for hours. Node.js 22 behaves the same way: <code>/^(a+)+$/.test('a'.repeat(24) + '!')</code> took 495 ms in our sandbox.</p>
<p>The possessive rewrite fixes this pattern because <code>a++</code> refuses to give characters back. That is a valid fix when <strong>you</strong> write the pattern. It does nothing when a user writes it, because the user will not write the possessive version.</p>
<h2 id="two-different-threats">Two different threats</h2>
<p>It helps to separate the cases, because the defences differ.</p>
<table>
  <thead>
      <tr>
          <th>Situation</th>
          <th>Who controls the pattern</th>
          <th>Main fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Your validator runs on user input</td>
          <td>You</td>
          <td>Review the pattern, use possessive quantifiers or atomic groups, limit input length</td>
      </tr>
      <tr>
          <td>Users submit patterns (search, rules, filters)</td>
          <td>The attacker</td>
          <td>Use a linear-time engine, cap pattern size, isolate matching</td>
      </tr>
      <tr>
          <td>User text is spliced into your pattern</td>
          <td>Partly the attacker</td>
          <td>Escape it (<code>re.escape</code>, or an escape helper in JavaScript) or use a plain substring search</td>
      </tr>
  </tbody>
</table>
<p>This article is about the second row. The third row is the most common way the second row happens by accident: someone builds <code>new RegExp(userInput)</code> to implement a &ldquo;contains&rdquo; search that never needed a regex.</p>
<h2 id="engines-that-are-built-for-untrusted-patterns">Engines that are built for untrusted patterns</h2>
<p><strong>RE2.</strong> Google&rsquo;s <a href="https://github.com/google/re2">RE2 README</a> states the design goal directly: it was built &ldquo;to handle regular expressions from untrusted users without risk&rdquo;, and &ldquo;one of its primary guarantees is that the match time is linear in the length of the input string&rdquo;. It also says its parser, compiler and execution engines &ldquo;limit their memory usage by working within a configurable budget&rdquo;. The price is features: RE2 &ldquo;does not support constructs for which only backtracking solutions are known to exist&rdquo;. That means no backreferences and no look-around.</p>
<p><strong>Go&rsquo;s <code>regexp</code>.</strong> The package uses RE2 syntax, and its source documents that the implementation &ldquo;is guaranteed to run in time linear in the size of the input&rdquo;.</p>
<p><strong>Rust&rsquo;s <code>regex</code> crate.</strong> Its <a href="https://github.com/rust-lang/regex">README</a> says &ldquo;all regex searches in this crate have worst case <code>O(m * n)</code> time complexity, where <code>m</code> is proportional to the size of the regex and <code>n</code> is proportional to the size of the string being searched&rdquo;. Its documentation says the crate &ldquo;also, to a certain extent, supports untrusted patterns&rdquo;, mainly &ldquo;by limiting their size by default&rdquo;, which you can tune with <code>RegexBuilder::size_limit</code>.</p>
<p>Note the <code>m</code> in that complexity. Linear in the input is not the same as free: a huge pattern still costs more to compile and run. That is why a pattern length cap belongs in front of every engine, safe or not.</p>
<h2 id="engines-that-say-they-are-not">Engines that say they are not</h2>
<p><strong>.NET.</strong> Microsoft&rsquo;s <a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-regex">regex best-practices page</a> is unusually direct:</p>
<blockquote>
<p>The .NET regular expression engine does not offer protection against untrusted <em>patterns</em>, and applications should not create regular expression patterns from untrusted user-provided values.</p>
</blockquote>
<p>It adds that time-out values and <code>RegexOptions.NonBacktracking</code> (introduced in .NET 7, which &ldquo;guarantees linear-time processing in the length of the input&rdquo;) &ldquo;help protect against accidental performance problems in developer-authored patterns. They are not intended as a security boundary against malicious patterns.&rdquo; Use them for your own patterns, and do not treat them as permission to accept user-written ones.</p>
<p><strong>Ruby.</strong> Ruby 3.2 added two ReDoS mitigations, described in the <a href="https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/">3.2.0 release notes</a>. A memoization-based matcher completes &ldquo;most Regexp matching (about 90% in our experiments)&rdquo; in linear time, at the cost of memory &ldquo;proportional to the input length&rdquo;. Because that optimization &ldquo;cannot be applied to some kind of regular expressions, such as those including advanced features (e.g., back-references or look-around)&rdquo;, Ruby also added <code>Regexp.timeout</code>, a global setting, and a per-regex <code>timeout:</code> keyword on <code>Regexp.new</code>. Both help. Neither turns Ruby&rsquo;s engine into one designed for hostile patterns.</p>
<p><strong>Python and JavaScript.</strong> Python&rsquo;s <code>re</code> documentation describes no match timeout. Python 3.11 added possessive quantifiers and atomic groups <code>(?&gt;...)</code>, which help you write safer patterns but do nothing about patterns you did not write. JavaScript&rsquo;s <code>RegExp</code> has no timeout either. V8 has shipped an <a href="https://v8.dev/blog/non-backtracking-regexp">experimental non-backtracking engine</a> since V8 8.8, behind <code>--enable-experimental-regexp-engine</code>, which adds a non-standard <code>l</code> (&ldquo;linear&rdquo;) flag. V8&rsquo;s own post calls it experimental &ldquo;because Irregexp is orders of magnitude faster than the new engine on most common patterns&rdquo;. With the flag on, <code>/^(a+)+$/l</code> failed on a 50,000-character evil input in about 3 ms in our Node.js 22.23 test. Running production on an experimental V8 flag is a decision to make deliberately, not a default.</p>
<p>Accepting patterns also exposes you to bugs in the regex parser and compiler themselves, not just slow matching. Our write-up of <a href="/posts/gitlab-cve-2026-97229-ci-regex-rce-patch-and-hunt/">GitLab CVE-2026-97229</a> covers a remote code execution flaw in how GitLab compiled regular expressions from CI/CD configuration. Using a small, heavily fuzzed engine reduces that risk too.</p>
<h2 id="pattern-1-python-with-re2">Pattern 1: Python with RE2</h2>
<p>This uses the <code>google-re2</code> package (we tested 1.1.20251105 with Python 3.13). It caps pattern length, compiles with RE2, and turns unsupported syntax into a clear error your API can return as a 400.</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> re2
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>MAX_PATTERN <span style="color:#f92672">=</span> <span style="color:#ae81ff">256</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">compile_user_pattern</span>(pattern: str):
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;&#34;&#34;Compile a user-supplied pattern with RE2 or raise ValueError.&#34;&#34;&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> len(pattern) <span style="color:#f92672">&gt;</span> MAX_PATTERN:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">&#34;pattern too long&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> re2<span style="color:#f92672">.</span>compile(pattern)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> re2<span style="color:#f92672">.</span>error <span style="color:#66d9ef">as</span> e:
</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;unsupported or invalid pattern: </span><span style="color:#e6db74">{</span>e<span style="color:#e6db74">}</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">from</span> None
</span></span></code></pre></div><p>What happened when we ran it:</p>
<table>
  <thead>
      <tr>
          <th>Input</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>^(a+)+$</code> searched against 1,000,000 <code>a</code> characters plus <code>!</code></td>
          <td>No match, returned in about 2 ms</td>
      </tr>
      <tr>
          <td><code>(\w+)\s\1</code> (backreference)</td>
          <td>Rejected: <code>invalid escape sequence: \1</code></td>
      </tr>
      <tr>
          <td><code>^(?=.*\d).+$</code> (look-ahead)</td>
          <td>Rejected: <code>invalid perl operator: (?=</code></td>
      </tr>
      <tr>
          <td>300-character pattern</td>
          <td>Rejected by the length cap</td>
      </tr>
  </tbody>
</table>
<p>The same evil pattern that took half a second on 24 characters in <code>re</code> finished a million characters in milliseconds. Two practical notes from the test: RE2 logs parse errors to stderr through its own logging, so expect noise in your logs when users submit bad patterns. And do not catch the error and retry with <code>re</code>. That fallback reopens the exact hole you closed.</p>
<h2 id="pattern-2-nodejs-with-a-killable-worker">Pattern 2: Node.js with a killable worker</h2>
<p>If you cannot add a native RE2 binding, isolate the backtracking engine. A worker thread can be terminated mid-match, which a <code>setTimeout</code> on the main thread cannot do: a regex blocks the event loop until it returns. This is tested on Node.js 22.23.4:</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-javascript" data-lang="javascript"><span style="display:flex;"><span><span style="color:#75715e">// safe_match.mjs: run an untrusted pattern in a worker and kill it after a deadline.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">Worker</span> } <span style="color:#a6e22e">from</span> <span style="color:#e6db74">&#39;node:worker_threads&#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">WORKER_SRC</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">`
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  const { parentPort, workerData } = require(&#39;node:worker_threads&#39;);
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  const { pattern, flags, input } = workerData;
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  try {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    parentPort.postMessage({ ok: true, match: new RegExp(pattern, flags).test(input) });
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  } catch (e) {
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">    parentPort.postMessage({ ok: false, error: e.message });
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">  }
</span></span></span><span style="display:flex;"><span><span style="color:#e6db74">`</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">safeTest</span>(<span style="color:#a6e22e">pattern</span>, <span style="color:#a6e22e">input</span>, { <span style="color:#a6e22e">flags</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;&#39;</span>, <span style="color:#a6e22e">timeoutMs</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">100</span>, <span style="color:#a6e22e">maxPattern</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">256</span> } <span style="color:#f92672">=</span> {}) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">pattern</span>.<span style="color:#a6e22e">length</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxPattern</span>) <span style="color:#66d9ef">return</span> Promise.<span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;pattern too long&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#e6db74">/^[gimsuy]*$/</span>.<span style="color:#a6e22e">test</span>(<span style="color:#a6e22e">flags</span>)) <span style="color:#66d9ef">return</span> Promise.<span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;bad flags&#39;</span> });
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> Promise((<span style="color:#a6e22e">resolve</span>) =&gt; {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">w</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Worker</span>(<span style="color:#a6e22e">WORKER_SRC</span>, {
</span></span><span style="display:flex;"><span>      eval<span style="color:#f92672">:</span> <span style="color:#66d9ef">true</span>,
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">workerData</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">pattern</span>, <span style="color:#a6e22e">flags</span>, <span style="color:#a6e22e">input</span> },
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">resourceLimits</span><span style="color:#f92672">:</span> { <span style="color:#a6e22e">maxOldGenerationSizeMb</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">32</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">timer</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">setTimeout</span>(() =&gt; { <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">terminate</span>(); <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;timeout&#39;</span> }); }, <span style="color:#a6e22e">timeoutMs</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">once</span>(<span style="color:#e6db74">&#39;message&#39;</span>, (<span style="color:#a6e22e">m</span>) =&gt; { <span style="color:#a6e22e">clearTimeout</span>(<span style="color:#a6e22e">timer</span>); <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">terminate</span>(); <span style="color:#a6e22e">resolve</span>(<span style="color:#a6e22e">m</span>); });
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">w</span>.<span style="color:#a6e22e">once</span>(<span style="color:#e6db74">&#39;error&#39;</span>, (<span style="color:#a6e22e">e</span>) =&gt; { <span style="color:#a6e22e">clearTimeout</span>(<span style="color:#a6e22e">timer</span>); <span style="color:#a6e22e">resolve</span>({ <span style="color:#a6e22e">ok</span><span style="color:#f92672">:</span> <span style="color:#66d9ef">false</span>, <span style="color:#a6e22e">error</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">e</span>.<span style="color:#a6e22e">message</span> }); });
</span></span><span style="display:flex;"><span>  });
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Results:</p>
<ul>
<li><code>safeTest('^(a+)+$', 'a'.repeat(40) + '!')</code> returned <code>{ ok: false, error: 'timeout' }</code> after 103 ms. On the main thread that match would not finish.</li>
<li><code>safeTest('^[a-z]+@example\\.com$', 'alice@example.com')</code> returned <code>{ ok: true, match: true }</code>.</li>
<li><code>safeTest('(unclosed', 'x')</code> returned the syntax error instead of throwing.</li>
</ul>
<p>The cost: spawning a worker per call averaged 25.4 ms in our sandbox, over 20 calls. That is fine for saving a rule. It is too slow for matching every log line. For high-volume matching, keep a small pool of long-lived workers and replace any worker you have to terminate, or move to an RE2 binding. This is a containment pattern, not a fix: an attacker can still make every match hit the timeout, so rate-limit pattern submissions and count timeouts per user.</p>
<h2 id="a-checklist-for-features-that-accept-patterns">A checklist for features that accept patterns</h2>
<ol>
<li><strong>Ask whether you need regex at all.</strong> Substring, prefix or glob matching covers most &ldquo;search&rdquo; features and has no ReDoS surface.</li>
<li><strong>Pick a linear-time engine</strong> (RE2 or a binding, Go <code>regexp</code>, Rust <code>regex</code>) for any pattern a user can write, including admins and API clients.</li>
<li><strong>Cap pattern length</strong> and, if you can, the number of patterns per user or rule set.</li>
<li><strong>Reject unsupported features with a clear message.</strong> Never fall back to a backtracking engine.</li>
<li><strong>Cap input length</strong> for each match. Linear time on a 2 GB input is still a lot of time. Our post on <a href="/posts/nodejs-decompression-bomb-zlib-limits/">decompression bombs in Node.js</a> covers the related problem of inputs that grow after you accept them.</li>
<li><strong>If you must backtrack, isolate it.</strong> Use a worker or process you can kill, a hard deadline, and a memory limit. Ruby&rsquo;s <code>Regexp.timeout</code> and .NET&rsquo;s <code>matchTimeout</code> are useful extra layers.</li>
<li><strong>Watch for it.</strong> Log match timeouts and compile failures by user, and alert on spikes.</li>
</ol>
<h2 id="limits-of-this-article">Limits of this article</h2>
<ul>
<li>Timings are from a shared cloud sandbox and will differ on your hardware. The growth rate, not the absolute numbers, is the point.</li>
<li>We did not test .NET or Ruby directly, and we do not cover Java, PCRE or other engines here. Statements about them come from their documentation, quoted above.</li>
<li>We tested one Python RE2 binding (<code>google-re2</code>). Node.js RE2 bindings exist, but we did not test one for this article.</li>
<li>A linear-time engine removes catastrophic backtracking. It does not remove the need for size limits, rate limits or patching the engine itself.</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>Backtracking times for <code>^(a+)+$</code> in Python 3.13.18 and Node.js 22.23.4; RE2 and worker results</td>
          <td>Our tests, code above</td>
          <td>Verified (our sandbox)</td>
      </tr>
      <tr>
          <td>&ldquo;Evil&rdquo; regex conditions and examples; attacker can inject an evil regex</td>
          <td><a href="https://wiki.owasp.org/index.php/Regular_expression_Denial_of_Service_-_ReDoS">OWASP wiki: ReDoS</a></td>
          <td>Verified (archived wiki page)</td>
      </tr>
      <tr>
          <td>RE2: built for untrusted users, linear in input, memory budget, no backtracking-only constructs</td>
          <td><a href="https://github.com/google/re2">google/re2 README</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Go regexp: linear in the size of the input</td>
          <td><a href="https://github.com/golang/go/blob/master/src/regexp/regexp.go">Go source, regexp.go</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Rust regex: worst case O(m * n); untrusted patterns handled mainly by size limit</td>
          <td><a href="https://github.com/rust-lang/regex">rust-lang/regex README and docs</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>.NET: no protection against untrusted patterns; timeouts and NonBacktracking not a security boundary</td>
          <td><a href="https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-regex">Microsoft Learn: regex best practices</a></td>
          <td>Verified (docs source on GitHub)</td>
      </tr>
      <tr>
          <td>Ruby 3.2 memoization (about 90% linear) and Regexp.timeout</td>
          <td><a href="https://www.ruby-lang.org/en/news/2022/12/25/ruby-3-2-0-released/">Ruby 3.2.0 release notes</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python 3.11 possessive quantifiers and atomic groups; no timeout documented</td>
          <td><a href="https://docs.python.org/3/library/re.html">Python re documentation</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>V8 experimental non-backtracking engine, <code>l</code> flag, slower than Irregexp</td>
          <td><a href="https://v8.dev/blog/non-backtracking-regexp">V8 blog</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><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>Node.js Decompression Bombs: zlib Limits That Work</title><link>https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/</link><guid>https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/</guid><pubDate>Thu, 29 Oct 2026 04:40:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>AppSec &amp; APIs</category><description>A 100 KB gzip body can decompress to 100 MB. Node's maxOutputLength stops that in the one-shot zlib functions, but streams silently ignore it, and fetch() decompresses responses with no cap at all. We tested where the limits hold in Node.js 22 and Express 5, with code you can copy.</description><enclosure url="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/nodejs-decompression-bomb-zlib-limits/cover_hu6171799600051493283.jpg" alt="Water pouring over a low concrete weir below a calm lake, with a metal railing on one side and bare trees on the far shore" width="1200" height="630"></p><p>A decompression bomb is a small compressed payload that expands into far more data than your service expects, enough to exhaust memory or tie up a CPU core. MITRE files it as <a href="https://cwe.mitre.org/data/definitions/409.html">CWE-409</a>, &ldquo;Improper Handling of Highly Compressed Data (Data Amplification)&rdquo;. In Node.js, the defence is to <strong>count decompressed bytes and stop at a limit you choose</strong>. Node gives you that limit in one place, <code>maxOutputLength</code> on the one-shot zlib functions, and nowhere else. Streams, HTTP clients and custom middleware all need their own cap.</p>
<p>We ran every example below on Node.js 22.23.4. Results for each are in the text.</p>
<h2 id="how-big-the-amplification-gets">How big the amplification gets</h2>
<p>We compressed 100 MiB (104,857,600 bytes) of zeros with each codec Node ships:</p>
<table>
  <thead>
      <tr>
          <th>Codec</th>
          <th>Compressed size</th>
          <th>Ratio</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>gzip (level 9)</td>
          <td>101,941 bytes</td>
          <td>about 1,029 : 1</td>
      </tr>
      <tr>
          <td>zstd (default level)</td>
          <td>3,219 bytes</td>
          <td>about 32,575 : 1</td>
      </tr>
      <tr>
          <td>Brotli (default quality)</td>
          <td>172 bytes</td>
          <td>about 609,637 : 1</td>
      </tr>
  </tbody>
</table>
<p>Zeros are the best case for a compressor, and the ratio for real data is far lower. That&rsquo;s the point: an attacker picks the input. A request-body limit of 1 MB on the wire tells you nothing about the decompressed size.</p>
<h2 id="the-one-shot-functions-maxoutputlength-works">The one-shot functions: maxOutputLength works</h2>
<p>Node&rsquo;s one-shot functions (<code>zlib.gunzip</code>, <code>zlib.inflate</code>, <code>zlib.brotliDecompress</code>, <code>zlib.zstdDecompress</code> and their <code>*Sync</code> forms) accept <code>maxOutputLength</code>. The <a href="https://nodejs.org/api/zlib.html">zlib docs</a> describe it as an option that &ldquo;Limits output size when using convenience methods&rdquo;. The default is <code>buffer.kMaxLength</code>, the largest Buffer Node can allocate, so without it you have no practical limit.</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">const</span> <span style="color:#a6e22e">zlib</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:zlib&#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">LIMIT</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:#75715e">// 1 MiB of decompressed output
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">safeGunzip</span>(<span style="color:#a6e22e">buf</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">gunzipSync</span>(<span style="color:#a6e22e">buf</span>, { <span style="color:#a6e22e">maxOutputLength</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">LIMIT</span> });
</span></span><span style="display:flex;"><span>  } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">err</span>) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;ERR_BUFFER_TOO_LARGE&#39;</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">throw</span> Object.<span style="color:#a6e22e">assign</span>(<span style="color:#66d9ef">new</span> Error(<span style="color:#e6db74">&#39;decompressed payload too large&#39;</span>), { <span style="color:#a6e22e">status</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">413</span> });
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">throw</span> <span style="color:#a6e22e">err</span>; <span style="color:#75715e">// corrupt input, wrong format, etc.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>With the 100 MiB bombs from the table, <code>gunzipSync</code>, <code>brotliDecompressSync</code> and <code>zstdDecompressSync</code> all threw <code>RangeError</code> with code <code>ERR_BUFFER_TOO_LARGE</code> and the message &ldquo;Cannot create a Buffer larger than 1048576 bytes&rdquo;. The async <code>zlib.gunzip()</code> passed the same error to its callback.</p>
<p>Use the one-shot functions when the whole payload is already in memory and you can name a sensible maximum: a webhook body, a cached blob, a small config file.</p>
<h2 id="streams-maxoutputlength-is-ignored">Streams: maxOutputLength is ignored</h2>
<p>The stream classes accept the same options object, so this looks right:</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">// Looks safe. Isn&#39;t.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">createGunzip</span>({ <span style="color:#a6e22e">maxOutputLength</span><span style="color:#f92672">:</span> <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span> });
</span></span></code></pre></div><p>In our test, piping the gzip bomb through that stream delivered all 104,857,600 bytes and finished without an error. The option is documented for the convenience methods only, and the stream path doesn&rsquo;t enforce it. Nothing warns you.</p>
<p>The fix is a Transform that counts bytes after the decompressor and fails the pipeline when the count passes your limit:</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">const</span> <span style="color:#a6e22e">zlib</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:zlib&#39;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">pipeline</span>, <span style="color:#a6e22e">Transform</span> } <span style="color:#f92672">=</span> <span style="color:#a6e22e">require</span>(<span style="color:#e6db74">&#39;node:stream&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Fail the pipeline once decompressed output exceeds maxBytes.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">byteLimit</span>(<span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">seen</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">Transform</span>({
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">transform</span>(<span style="color:#a6e22e">chunk</span>, <span style="color:#a6e22e">_enc</span>, <span style="color:#a6e22e">cb</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">seen</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">chunk</span>.<span style="color:#a6e22e">length</span>;
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">seen</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">err</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RangeError</span>(<span style="color:#e6db74">`decompressed body exceeds </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">maxBytes</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> bytes`</span>);
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">err</span>.<span style="color:#a6e22e">code</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;ERR_DECOMPRESSED_TOO_LARGE&#39;</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">cb</span>(<span style="color:#a6e22e">err</span>);
</span></span><span style="display:flex;"><span>      }
</span></span><span style="display:flex;"><span>      <span style="color:#a6e22e">cb</span>(<span style="color:#66d9ef">null</span>, <span style="color:#a6e22e">chunk</span>);
</span></span><span style="display:flex;"><span>    },
</span></span><span style="display:flex;"><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">pipeline</span>(
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">source</span>,                 <span style="color:#75715e">// e.g. req, a file stream, an upstream response
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>  <span style="color:#a6e22e">zlib</span>.<span style="color:#a6e22e">createGunzip</span>(),
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">byteLimit</span>(<span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>),
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">destination</span>,
</span></span><span style="display:flex;"><span>  (<span style="color:#a6e22e">err</span>) =&gt; { <span style="color:#75715e">/* err.code === &#39;ERR_DECOMPRESSED_TOO_LARGE&#39; on a bomb */</span> },
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>Against the 100 MiB bomb, the pipeline failed with <code>ERR_DECOMPRESSED_TOO_LARGE</code> after exactly 1,048,576 bytes had reached the destination. <code>pipeline()</code> destroys every stream in the chain on error, so the decompressor stops as well.</p>
<p>Two details make this work. The limiter must sit <strong>after</strong> the decompressor, because counting compressed bytes is the mistake you&rsquo;re avoiding. And use <code>pipeline()</code>, not chained <code>.pipe()</code> calls, which don&rsquo;t propagate errors or destroy the upstream streams.</p>
<h2 id="express-the-body-parser-already-counts-decompressed-bytes">Express: the body parser already counts decompressed bytes</h2>
<p>Express&rsquo;s <code>express.json()</code>, <code>express.urlencoded()</code>, <code>express.text()</code> and <code>express.raw()</code> come from <a href="https://github.com/expressjs/body-parser">body-parser</a>. Its <code>inflate</code> option defaults to <code>true</code>, and in body-parser 2.4.0 it decodes <code>deflate</code>, <code>gzip</code>, <code>br</code> and, where Node supports it, <code>zstd</code>.</p>
<p>We sent <code>express.json()</code> (default <code>limit</code> of <code>'100kb'</code>) a 10 MB JSON body compressed each way:</p>
<table>
  <thead>
      <tr>
          <th>Content-Encoding</th>
          <th>Bytes on the wire</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>gzip</td>
          <td>10,232</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>br</td>
          <td>41</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>zstd</td>
          <td>355</td>
          <td>413, <code>entity.too.large</code></td>
      </tr>
      <tr>
          <td>gzip with <code>inflate: false</code></td>
          <td>10,232</td>
          <td>415, <code>encoding.unsupported</code></td>
      </tr>
  </tbody>
</table>
<p>All three compressed bodies were under 100 KB on the wire and still got 413, so the limit is applied to the decoded body. That&rsquo;s the behaviour you want. Tested with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2.</p>
<p>Two cautions. The body-parser README warns that limits &ldquo;above the default, for example payloads of 5 MB or more&rdquo; increase memory use and response times, so raise <code>limit</code> per route, not globally. And if you don&rsquo;t expect compressed request bodies, <code>inflate: false</code> turns them away with 415 before any decoding.</p>
<p>Custom code is where the gaps are: a handler that does <code>req.pipe(zlib.createGunzip())</code> for an upload endpoint, or a framework plugin with its own decoder. Give each one the byte limiter above.</p>
<h2 id="fetch-responses-are-decompressed-with-no-cap">fetch(): responses are decompressed with no cap</h2>
<p>Node&rsquo;s built-in <code>fetch()</code> decodes <code>Content-Encoding: gzip</code> for you. We served the 100 MiB gzip bomb from a local server:</p>
<ul>
<li><code>res.headers.get('content-length')</code> returned <code>101941</code>, the compressed size.</li>
<li><code>await res.arrayBuffer()</code> returned 104,857,600 bytes.</li>
</ul>
<p>There&rsquo;s no size option on <code>arrayBuffer()</code>, <code>json()</code> or <code>text()</code>. If you fetch URLs you don&rsquo;t control (webhooks, link previews, feed readers, anything with a user-supplied URL), read the body with a cap:</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">// Read at most maxBytes of a fetch() response body (after fetch&#39;s own decompression).
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">readCapped</span>(<span style="color:#a6e22e">res</span>, <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">reader</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">res</span>.<span style="color:#a6e22e">body</span>.<span style="color:#a6e22e">getReader</span>();
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">chunks</span> <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">total</span> <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">for</span> (;;) {
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">const</span> { <span style="color:#a6e22e">done</span>, <span style="color:#a6e22e">value</span> } <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">reader</span>.<span style="color:#a6e22e">read</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">done</span>) <span style="color:#66d9ef">break</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">total</span> <span style="color:#f92672">+=</span> <span style="color:#a6e22e">value</span>.<span style="color:#a6e22e">byteLength</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">total</span> <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">maxBytes</span>) {
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">reader</span>.<span style="color:#a6e22e">cancel</span>();
</span></span><span style="display:flex;"><span>      <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">RangeError</span>(<span style="color:#e6db74">`response exceeds </span><span style="color:#e6db74">${</span><span style="color:#a6e22e">maxBytes</span><span style="color:#e6db74">}</span><span style="color:#e6db74"> bytes`</span>);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">chunks</span>.<span style="color:#a6e22e">push</span>(<span style="color:#a6e22e">value</span>);
</span></span><span style="display:flex;"><span>  }
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">Buffer</span>.<span style="color:#a6e22e">concat</span>(<span style="color:#a6e22e">chunks</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">res</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">fetch</span>(<span style="color:#a6e22e">url</span>, { <span style="color:#a6e22e">signal</span><span style="color:#f92672">:</span> <span style="color:#a6e22e">AbortSignal</span>.<span style="color:#a6e22e">timeout</span>(<span style="color:#ae81ff">10_000</span>) });
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">body</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">await</span> <span style="color:#a6e22e">readCapped</span>(<span style="color:#a6e22e">res</span>, <span style="color:#ae81ff">1024</span> <span style="color:#f92672">*</span> <span style="color:#ae81ff">1024</span>);
</span></span></code></pre></div><p>Against the bomb, <code>readCapped(res, 1 MiB)</code> threw &ldquo;response exceeds 1048576 bytes&rdquo;. Pair it with a timeout, because a slow trickle is a different attack that a byte cap doesn&rsquo;t stop. Fetching arbitrary URLs has bigger risks than size, too; our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF guide</a> covers the network side.</p>
<h2 id="a-checklist-for-node-services">A checklist for Node services</h2>
<table>
  <thead>
      <tr>
          <th>Where you decompress</th>
          <th>What enforces the limit</th>
          <th>Default</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>zlib.*Sync()</code> / <code>zlib.*()</code> one-shot</td>
          <td><code>maxOutputLength</code></td>
          <td><code>buffer.kMaxLength</code> (no practical limit)</td>
      </tr>
      <tr>
          <td><code>zlib.create*()</code> streams</td>
          <td>Your own byte-counting Transform</td>
          <td>None; <code>maxOutputLength</code> is ignored</td>
      </tr>
      <tr>
          <td><code>express.json()</code> and other body-parser parsers</td>
          <td><code>limit</code>, counted after decoding</td>
          <td><code>'100kb'</code></td>
      </tr>
      <tr>
          <td><code>fetch()</code> response bodies</td>
          <td>A capped reader</td>
          <td>None</td>
      </tr>
      <tr>
          <td>Uploaded archives (zip, tar.gz)</td>
          <td>Per-entry and total extracted-size limits in your extractor</td>
          <td>Depends on the library</td>
      </tr>
  </tbody>
</table>
<p>Also consider:</p>
<ul>
<li><strong>Keep the decoder patched.</strong> Size limits don&rsquo;t help against bugs in the decompressor itself. CVE-2026-111497 was an out-of-bounds write in Brotli&rsquo;s decoder, fixed in Node.js 22.23.4, 24.22.1 and 26.12.1; see our <a href="/posts/brotli-cve-2026-111497-find-and-patch-every-copy/">Brotli guide</a>.</li>
<li><strong>Decide whether you need each codec.</strong> zstd arrived in Node.js 22.15.0 and is marked Experimental in the Node 22 docs, yet body-parser 2.4.0 accepts it automatically when the runtime supports it. If clients never send it, <code>inflate: false</code> or a proxy rule removes that path.</li>
<li><strong>Push the limit to the edge as well.</strong> A reverse proxy that decompresses request bodies should cap the decoded size too, so the application isn&rsquo;t your only control.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>All results come from Node.js 22.23.4 on Linux with Express 5.3.0, body-parser 2.4.0 and raw-body 3.0.2, using 100 MiB of zeros as the bomb. Other Node release lines, frameworks (Fastify, Koa, NestJS adapters) and HTTP clients (axios, undici&rsquo;s <code>request</code>) may behave differently. Test them the same way. We measured whether limits held, not peak memory use or CPU time.</p>
<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>maxOutputLength &ldquo;Limits output size when using convenience methods&rdquo;; default <code>buffer.kMaxLength</code></td>
          <td><a href="https://nodejs.org/api/zlib.html">Node.js zlib docs</a> (<a href="https://github.com/nodejs/node/blob/v22.23.4/doc/api/zlib.md">v22.23.4 source</a>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>zstd added in v22.15.0; Stability 1, Experimental</td>
          <td>Node.js v22.23.4 zlib docs</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Compression ratios; ERR_BUFFER_TOO_LARGE; streams ignore maxOutputLength; byte limiter; fetch() results</td>
          <td>Our tests on Node.js 22.23.4 (code above)</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>Express limit applies after decompression; 413/415 results</td>
          <td>Our tests on Express 5.3.0 / body-parser 2.4.0</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>body-parser decodes deflate, gzip, br, zstd; inflate default true; limit default &lsquo;100kb&rsquo;; 5 MB warning</td>
          <td><a href="https://github.com/expressjs/body-parser">body-parser</a> 2.4.0 source and README</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>CWE-409 title</td>
          <td><a href="https://cwe.mitre.org/data/definitions/409.html">MITRE CWE-409</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Brotli CVE-2026-111497: Find and Patch Every Copy</title><link>https://firewallsync.com/posts/brotli-cve-2026-111497-find-and-patch-every-copy/</link><guid>https://firewallsync.com/posts/brotli-cve-2026-111497-find-and-patch-every-copy/</guid><pubDate>Wed, 28 Oct 2026 04:40:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>Security Operations</category><description>CVE-2026-111497 is an out-of-bounds write in Google's Brotli decoder, now on CISA's KEV list. It ships inside Node.js, Python packages, Go modules, Envoy and more, and patched builds can still report the old Brotli version. Here is how to find every copy and check it for the fix.</description><enclosure url="https://firewallsync.com/posts/brotli-cve-2026-111497-find-and-patch-every-copy/cover_hu14059145143623990521.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/brotli-cve-2026-111497-find-and-patch-every-copy/cover_hu14059145143623990521.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/brotli-cve-2026-111497-find-and-patch-every-copy/cover_hu14059145143623990521.jpg" alt="Round straw bales stacked in tight columns, each bound with thin blue netting" width="1200" height="630"></p><p>CVE-2026-111497 is a memory-corruption bug in Google&rsquo;s Brotli decompressor. Upstream versions 1.0.3 through 1.2.0 are affected, and 1.0.10, 1.1.1 and 1.2.1 fix it. CISA added it to the <a href="https://www.cisa.gov/known-exploited-vulnerabilities-catalog">Known Exploited Vulnerabilities catalog</a> on October 27, 2026, with a federal remediation date of November 10. The hard part is not the patch. Brotli is compiled into Node.js, Python wheels, Go modules, proxies and service meshes, and several patched builds still report the old version number. Here is where it hides, what &ldquo;fixed&rdquo; looks like for each, and a tested way to check code you vendor yourself.</p>
<h2 id="are-you-affected">Are you affected?</h2>
<p>You are exposed if a vulnerable Brotli decoder processes data an attacker can supply. The <a href="https://www.cve.org/CVERecord?id=CVE-2026-111497">CVE record</a> says an attacker with &ldquo;a crafted Brotli-compressed stream&rdquo; can &ldquo;corrupt adjacent decoder state memory, disclose sensitive memory contents, cause a denial of service, or potentially execute arbitrary code&rdquo;. Google&rsquo;s CNA scored it CVSS 4.0 9.2 (Critical). The Node.js project rates its copy High.</p>
<table>
  <thead>
      <tr>
          <th>Where Brotli lives</th>
          <th>Affected</th>
          <th>Fixed in</th>
          <th>How to check</th>
          <th>Source</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Google Brotli / libbrotli</td>
          <td>1.0.3 to 1.2.0</td>
          <td>1.0.10, 1.1.1, 1.2.1</td>
          <td>Package version, or the code check below for vendored copies</td>
          <td>CVE record, upstream CHANGELOG</td>
      </tr>
      <tr>
          <td>Node.js 22, 24, 26</td>
          <td>Earlier builds on those lines</td>
          <td>22.23.4, 24.22.1, 26.12.1</td>
          <td><code>node -v</code> (not <code>process.versions.brotli</code>)</td>
          <td>Node.js advisory</td>
      </tr>
      <tr>
          <td>End-of-life Node.js</td>
          <td>Assume affected</td>
          <td>None: upgrade</td>
          <td><code>node -v</code></td>
          <td>Node.js advisory</td>
      </tr>
      <tr>
          <td>Python <code>Brotli</code></td>
          <td>1.0.3 to 1.2.0</td>
          <td>1.2.1</td>
          <td><code>pip show Brotli</code></td>
          <td>Aikido; PyPI release date</td>
      </tr>
      <tr>
          <td>Python <code>brotlicffi</code></td>
          <td>1.2.0.x</td>
          <td>1.2.1.0</td>
          <td><code>pip show brotlicffi</code></td>
          <td>Aikido; PyPI release date</td>
      </tr>
      <tr>
          <td>Go <code>github.com/andybalholm/brotli</code></td>
          <td>Before v1.2.7</td>
          <td>v1.2.7</td>
          <td><code>go list -m github.com/andybalholm/brotli</code></td>
          <td>CVE record reference, source diff</td>
      </tr>
      <tr>
          <td>Envoy</td>
          <td>1.36.11, 1.37.7, 1.38.5, 1.39.2</td>
          <td>1.36.12, 1.37.8, 1.38.6, 1.39.3, 1.40.0</td>
          <td><code>envoy --version</code></td>
          <td>Wiz (qualified); we checked 1.39.2 against 1.39.3</td>
      </tr>
      <tr>
          <td>Istio</td>
          <td>1.29.0 to 1.29.8, 1.30.0 to 1.30.5, 1.31.0 to 1.31.1</td>
          <td>1.29.9, 1.30.6, 1.31.2</td>
          <td><code>istioctl version</code></td>
          <td>Wiz (qualified)</td>
      </tr>
      <tr>
          <td>Bun</td>
          <td>1.0.21 to 1.4.3</td>
          <td>1.4.4</td>
          <td><code>bun --version</code></td>
          <td>Wiz (qualified)</td>
      </tr>
      <tr>
          <td>Distro packages (libbrotli1, brotli)</td>
          <td>Depends on backport</td>
          <td>Distro security update</td>
          <td>Your distro&rsquo;s security tracker</td>
          <td>—</td>
      </tr>
  </tbody>
</table>
<p>The Envoy, Istio and Bun ranges come from <a href="https://www.wiz.io/blog/cve-2026-111497-brotli-rce-node-js">Wiz&rsquo;s write-up</a>, not from those projects&rsquo; advisories. We did not read those advisories directly. For Envoy we checked the source instead: <code>bazel/repository_locations.bzl</code> pins Brotli <code>1.2.0</code> at tag v1.39.2, while v1.39.3 pins a Brotli commit whose <code>decode.c</code> contains the fix.</p>
<p>Linux distributions usually backport fixes and keep the upstream version. Our Ubuntu 24.04 sandbox, for example, has <code>libbrotli1 1.1.0-2ubuntu0.1</code>. That string alone does not tell you whether the fix is in. Use your distribution&rsquo;s security tracker for the CVE.</p>
<h3 id="why-the-version-string-can-mislead-you">Why the version string can mislead you</h3>
<p>Node.js patched its bundled Brotli in place. On the fixed Node.js 22.23.4 the reported library version is still the old one:</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-console" data-lang="console"><span style="display:flex;"><span>$ node -v
</span></span><span style="display:flex;"><span>v22.23.4
</span></span><span style="display:flex;"><span>$ node -p process.versions.brotli
</span></span><span style="display:flex;"><span>1.1.0
</span></span></code></pre></div><p>We ran that in our sandbox. The source tree agrees: <code>deps/brotli/c/common/version.h</code> declares 1.1.0 at both v22.23.3 and v22.23.4, and 1.2.0 at v24.22.1 and v26.12.1, but only the fixed tags contain the patched decoder (v22.23.3 and v24.22.0 do not). A scanner that flags <code>process.versions.brotli &lt; 1.2.1</code> will raise false positives on fixed Node.js. A scanner that trusts a vendored <code>version.h</code> can miss real exposure the other way round. For Node.js, use the Node.js version. For anything else you vendor, check the code.</p>
<h2 id="what-the-bug-is">What the bug is</h2>
<p>The <a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases">Node.js advisory</a> gives the clearest description: &ldquo;An invalid block switch was reported as a temporary need for more input instead of a permanent format error, so the streaming decoder kept appending input bytes to an eight-byte internal buffer.&rdquo;</p>
<p>The upstream diff between v1.2.0 and v1.2.1 of <code>c/dec/decode.c</code> shows exactly that. <code>DecodeBlockTypeAndLength()</code> used to return a plain boolean, so &ldquo;invalid stream&rdquo; and &ldquo;need more input&rdquo; looked the same, and the non-safe callers ignored the result altogether (<code>static void DecodeLiteralBlockSwitch(...)</code>). After the fix the function returns a <code>BrotliDecoderErrorCode</code>, emits <code>BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH</code> for the invalid case, and every caller checks it through a new <code>BROTLI_SAFE_WITH_STATUS</code> macro. That matches the two weaknesses in the CVE record: CWE-252 (unchecked return value) and CWE-787 (out-of-bounds write).</p>
<p>The flaw was found by Khai Tran and Sylvie Mayer of OpenAI Codex Security, who are credited in both the CVE record and the Node.js advisory.</p>
<h2 id="where-untrusted-brotli-reaches-your-code">Where untrusted Brotli reaches your code</h2>
<p>The flaw is in decompression, so look for places that decode data you did not produce:</p>
<ul>
<li><strong>Request bodies.</strong> Any endpoint or middleware that inflates <code>Content-Encoding: br</code> uploads.</li>
<li><strong>Outbound HTTP.</strong> HTTP clients decode Brotli responses for you. If an attacker controls or can redirect the URL (webhooks, link previews, feed fetchers, scrapers), they control the compressed bytes.</li>
<li><strong>Files and archives</strong> that contain Brotli streams.</li>
<li><strong>Proxies and meshes</strong> that decompress traffic to inspect or transform it.</li>
</ul>
<p>Two behaviours we checked, because they mean code that never mentions Brotli can still run it:</p>
<ul>
<li><strong>Node.js <code>fetch()</code> decodes Brotli it never asked for.</strong> On Node.js 22.23.4, <code>fetch()</code> sent <code>accept-encoding: gzip, deflate</code>. When our local test server replied with <code>content-encoding: br</code>, <code>fetch()</code> decompressed the body anyway. A hostile server can choose Brotli whatever the client advertises.</li>
<li><strong>Python&rsquo;s requests turns Brotli on when it is installed.</strong> urllib3&rsquo;s <code>util/request.py</code> appends <code>br</code> to its default <code>Accept-Encoding</code> when <code>brotlicffi</code> or <code>brotli</code> can be imported. With Brotli 1.2.1 installed, <code>requests.utils.default_headers()</code> returned <code>gzip, deflate, br</code> in our test. The decoding then runs in whichever Brotli package is installed.</li>
</ul>
<h2 id="what-to-do-now">What to do now</h2>
<ol>
<li><strong>Update the runtimes.</strong> Node.js to 22.23.4, 24.22.1 or 26.12.1 or later. Move off end-of-life Node.js lines: the advisory notes that &ldquo;End-of-Life versions are always affected when a security release occurs.&rdquo;</li>
<li><strong>Update language packages</strong> in every lockfile and image: <code>Brotli&gt;=1.2.1</code>, <code>brotlicffi&gt;=1.2.1.0</code>, <code>github.com/andybalholm/brotli v1.2.7</code>. Both Python fixes were published to PyPI on October 22, 2026.</li>
<li><strong>Update proxies and meshes</strong> (Envoy, Istio) and Bun to the fixed releases above. Confirm the ranges against each project&rsquo;s own advisory.</li>
<li><strong>Apply distro updates</strong> for <code>libbrotli</code>/<code>brotli</code>, then restart the long-running processes that loaded the old library.</li>
<li><strong>Rebuild container images.</strong> A fixed base image does not help a running container that was built from the old one.</li>
<li><strong>Hunt for vendored copies</strong> with the check below.</li>
</ol>
<p>If you cannot patch a service today, reduce what it decodes. As a stopgap, reject <code>Content-Encoding: br</code> request bodies at the edge for services that do not need them, and stop server-side fetchers from decoding responses from untrusted URLs. This is our editorial suggestion, not vendor guidance, and it does not replace the update.</p>
<h3 id="inventory-commands">Inventory commands</h3>
<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-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#75715e"># Node.js: the version that matters</span>
</span></span><span style="display:flex;"><span>node -v
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Python: every environment and image</span>
</span></span><span style="display:flex;"><span>pip show Brotli brotlicffi 2&gt;/dev/null | grep -E <span style="color:#e6db74">&#39;^(Name|Version)&#39;</span>
</span></span><span style="display:flex;"><span>pipdeptree -r -p Brotli          <span style="color:#75715e"># who pulls it in</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Go modules</span>
</span></span><span style="display:flex;"><span>go list -m all | grep andybalholm/brotli
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Debian/Ubuntu and RHEL-family system libraries</span>
</span></span><span style="display:flex;"><span>dpkg -l <span style="color:#e6db74">&#39;libbrotli*&#39;</span> 2&gt;/dev/null | awk <span style="color:#e6db74">&#39;/^ii/{print $2, $3}&#39;</span>
</span></span><span style="display:flex;"><span>rpm -q brotli libbrotli 2&gt;/dev/null
</span></span></code></pre></div><p>The <code>pip</code>, <code>node</code> and <code>dpkg</code> lines were run in our sandbox. The <code>go</code>, <code>pipdeptree</code> and <code>rpm</code> lines are standard tool usage that we did not run here.</p>
<h3 id="checking-vendored-source-for-the-fix">Checking vendored source for the fix</h3>
<p>C and C++ projects often copy <code>c/dec/decode.c</code> into their tree, and Go programs carry <code>decode.go</code> in the module cache or a <code>vendor/</code> directory. This script looks for the patched function signature instead of trusting any version string:</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-sh" data-lang="sh"><span style="display:flex;"><span><span style="color:#75715e">#!/bin/sh
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#75715e"># brotli-fixcheck.sh DIR...: find vendored Brotli decoder sources and report</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># whether they carry the CVE-2026-111497 fix. Looks at the code, not the</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># version string, because patched Node.js still reports the old version.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">for</span> root in <span style="color:#e6db74">&#34;</span>$@<span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>  find <span style="color:#e6db74">&#34;</span>$root<span style="color:#e6db74">&#34;</span> <span style="color:#ae81ff">\(</span> -name decode.c -path <span style="color:#e6db74">&#39;*dec*&#39;</span> <span style="color:#ae81ff">\)</span> -o <span style="color:#ae81ff">\(</span> -name decode.go -path <span style="color:#e6db74">&#39;*brotli*&#39;</span> <span style="color:#ae81ff">\)</span> 2&gt;/dev/null |
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">while</span> read -r f; <span style="color:#66d9ef">do</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> grep -q <span style="color:#e6db74">&#39;BrotliDecoderErrorCode DecodeBlockTypeAndLength&#39;</span> <span style="color:#e6db74">&#34;</span>$f<span style="color:#e6db74">&#34;</span> <span style="color:#f92672">||</span>
</span></span><span style="display:flex;"><span>       grep -q <span style="color:#e6db74">&#39;func decodeBlockTypeAndLength(.*) int {&#39;</span> <span style="color:#e6db74">&#34;</span>$f<span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>      echo <span style="color:#e6db74">&#34;PATCHED    </span>$f<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">elif</span> grep -q <span style="color:#e6db74">&#39;DecodeBlockTypeAndLength\|decodeBlockTypeAndLength&#39;</span> <span style="color:#e6db74">&#34;</span>$f<span style="color:#e6db74">&#34;</span>; <span style="color:#66d9ef">then</span>
</span></span><span style="display:flex;"><span>      echo <span style="color:#e6db74">&#34;VULNERABLE </span>$f<span style="color:#e6db74">&#34;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">fi</span>
</span></span><span style="display:flex;"><span>  <span style="color:#66d9ef">done</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">done</span>
</span></span></code></pre></div><p>We tested it against 12 sources pulled from GitHub: upstream Brotli v1.0.9, v1.0.10, v1.1.0, v1.1.1, v1.2.0 and v1.2.1; Node.js <code>deps/brotli</code> at v22.23.3, v22.23.4, v24.22.0 and v24.22.1; and <code>andybalholm/brotli</code> v1.2.6 and v1.2.7. It reported every fixed release as <code>PATCHED</code> and every earlier one as <code>VULNERABLE</code>. Run it over source trees, <code>vendor/</code> directories and <code>$(go env GOMODCACHE)</code>.</p>
<p>It cannot inspect compiled binaries, and a heavily modified fork may not match either pattern. Treat &ldquo;no output&rdquo; as &ldquo;no recognisable copy found&rdquo;, not as &ldquo;safe&rdquo;.</p>
<h2 id="what-to-hunt-for">What to hunt for</h2>
<p>No vendor or agency has published indicators of compromise for this CVE, and CISA&rsquo;s KEV entry does not require forensic triage (<code>forensicTriage: No</code>). Practical checks:</p>
<ul>
<li><strong>Crashes.</strong> Segfaults or aborts in Node.js, Python or proxy processes since late October, especially in services that fetch remote content or accept compressed uploads.</li>
<li><strong>Unexpected Brotli.</strong> Requests with <code>Content-Encoding: br</code> to endpoints that never receive compressed bodies. Wiz recommends reviewing WAF, application and network logs for suspicious Brotli-compressed traffic.</li>
<li><strong>Outbound fetches</strong> from services that took user-supplied URLs, close in time to a crash.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We did not reproduce the memory corruption or test any exploit. Our checks cover version strings, source code and HTTP client behaviour in a sandbox: Node.js 22.23.4, Python with Brotli 1.2.1, Ubuntu 24.04. The Envoy, Istio and Bun ranges come from a security vendor&rsquo;s summary. The list of affected software is not complete. Wiz notes that other products that bundle or link vulnerable versions of the Brotli C decoder may also be affected.</p>
<p>For the wider process of keeping dependency updates moving, see <a href="/posts/dependency-audits-without-slowing-down-releases/">dependency audits without slowing down releases</a>. For publishing or reading &ldquo;not affected&rdquo; statements about a library like this, see <a href="/posts/vex-explained-what-a-not-affected-statement-means-and-how-to-read-one/">VEX explained</a>. Last week&rsquo;s <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell patch-and-hunt guide</a> covers the two PHP runtime flaws CISA added to KEV the day before.</p>
<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>Affected 1.0.3–1.2.0; fixed 1.0.10, 1.1.1, 1.2.1; CVSS 4.0 9.2; CWE-252/787; credit</td>
          <td><a href="https://github.com/CVEProject/cvelistV5/blob/main/cves/2026/111xxx/CVE-2026-111497.json">CVE-2026-111497 record (cvelistV5)</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>KEV added 2026-10-27, due 2026-11-10, forensicTriage No; SSVC exploitation active</td>
          <td><a href="https://github.com/cisagov/kev-data">CISA KEV data</a>; CISA ADP in the CVE record</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>1.2.1 released 2026-10-21 with the CVE fix; code change</td>
          <td><a href="https://github.com/google/brotli/blob/v1.2.1/CHANGELOG.md">google/brotli CHANGELOG</a>; <code>c/dec/decode.c</code> v1.2.0 vs v1.2.1</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Node.js fixed releases, root cause, EOL statement</td>
          <td><a href="https://nodejs.org/en/blog/vulnerability/october-2026-security-releases">Node.js October 2026 security releases</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Patched Node.js reports the old Brotli version</td>
          <td><code>node -p process.versions.brotli</code> on v22.23.4; <code>deps/brotli</code> at the listed tags</td>
          <td>Verified (our test)</td>
      </tr>
      <tr>
          <td>Go fix in andybalholm/brotli v1.2.7</td>
          <td><a href="https://github.com/andybalholm/brotli/releases/tag/v1.2.7">Release v1.2.7</a>; <code>decode.go</code> v1.2.6 vs v1.2.7</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python Brotli 1.2.1, brotlicffi 1.2.1.0</td>
          <td><a href="https://www.aikido.dev/blog/python-packages-vulnerable-node2shell-brotli-cve-2026-111497">Aikido</a>; PyPI upload dates 2026-10-22</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>urllib3/requests advertise <code>br</code> when Brotli is installed</td>
          <td><a href="https://github.com/urllib3/urllib3/blob/main/src/urllib3/util/request.py">urllib3 <code>util/request.py</code></a>; our test</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Envoy, Istio, Bun ranges</td>
          <td><a href="https://www.wiz.io/blog/cve-2026-111497-brotli-rce-node-js">Wiz</a>; Envoy <code>repository_locations.bzl</code> at v1.39.2/v1.39.3</td>
          <td>Qualified</td>
      </tr>
      <tr>
          <td>Envoy and other consumers affected</td>
          <td><a href="https://www.openwall.com/lists/oss-security/2026/10/23/25">oss-security, 2026-10-23</a>, <a href="https://www.openwall.com/lists/oss-security/2026/10/24/6">2026-10-24</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>