<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>security &amp;mdash; ShapesFormas Journal</title>
    <link>https://blog.shapesformas.com/tag:security</link>
    <description>David&#39;s notes: self-hosting, travel, film, whatever&#39;s on my mind.</description>
    <pubDate>Sat, 14 Nov 2026 23:06:31 +0000</pubDate>
    <item>
      <title>Any container on your server can log in as you</title>
      <link>https://blog.shapesformas.com/any-container-on-your-server-can-log-in-as-you</link>
      <description>&lt;![CDATA[Caddy + Authentik header auth, the side door nobody closes, and a Caddy quirk that let users forge their own groups&#xA;&#xA;If an app behind Caddy and Authentik trusts the X-Authentik-Username header, then every other container that can reach that app&#39;s port can send the header itself and become any user, admin included. It needs no password and no session, and Authentik never sees the request.&#xA;&#xA;This is the most common way people wire up &#34;SSO for apps that don&#39;t do SSO&#34;, and it is in a lot of tutorials. I found it on my own server, about 80 containers, in four apps at once. One of them was an admin panel where a forged X-Authentik-Groups: authentik Admins header could approve or delete user sign\-ups.&#xA;&#xA;Why SSO doesn&#39;t cover it&#xA;&#xA;Forward auth protects the front door. A request comes in to Caddy, Caddy asks Authentik &#34;is this person logged in?&#34;, and if so it copies the username into a header and passes the request to the app. The app trusts that header.&#xA;&#xA;The app&#39;s port is a side door, and it is open to:&#xA;&#xA;every container on the same Docker network, by name (http://myapp:8080);&#xA;every container on the host, if you published the port as 127.0.0.1:8080. Containers reach it through host.docker.internal. Binding to localhost hides the port from your LAN, not from your other containers.&#xA;&#xA;So one compromised image, whether a scraper with an RCE, a typosquatted package or a hijacked upstream, only has to do this:&#xA;&#xA;curl -H &#39;X-Authentik-Username: admin&#39; \&#xA;     -H &#39;X-Authentik-Groups: admins&#39; \&#xA;     http://myapp:8080/admin/&#xA;&#xA;Reproducing it&#xA;&#xA;I built a small compose lab: Caddy, a stand\-in for the Authentik outpost, a naive backend that trusts the header, a hardened one, and an &#34;attacker&#34; container that&#39;s just another service on the network. From the attacker:&#xA;&#xA; NAIVE: other container hits backend directly ==&#xA;{&#34;user&#34;:&#34;admin&#34;,&#34;groups&#34;:&#34;admins&#34;}&#xA; HARDENED: other container hits backend directly ==&#xA;{&#34;detail&#34;:&#34;forbidden&#34;}&#xA;&#xA;The quirk: forging headers through Caddy&#xA;&#xA;There&#39;s a second, subtler hole. forwardauth … copyheaders copies headers from Authentik&#39;s response onto the request. What happens when Authentik&#39;s response doesn&#39;t include one of those headers, but the client sent it? I tested a logged\-in, non\-admin user adding X-Authentik-Groups: admins to their own request:&#xA;&#xA;| Caddy | What the backend received as groups |&#xA;| --- | --- |&#xA;| 2\.8\.4 | the literal text {http.reverseproxy.header.X-Authentik-Groups} (a different bug) |&#xA;| 2\.10\.2 | &#34;admins&#34;, the forged value passed straight through |&#xA;| 2\.11\.4 | null (fixed) |&#xA;&#xA;If you&#39;re on 2\.10 or older and your app reads a header that Authentik doesn&#39;t always send, a user can grant themselves whatever that header says. Upgrade, and strip client identity headers anyway, since it costs one line.&#xA;&#xA;The fix, in two layers&#xA;&#xA;1\. In Caddy: strip client\-supplied identity headers before forward auth, and attach a secret that only Caddy knows, one secret per backend:&#xA;&#xA;(authentikgate) {&#xA;&#x9;requestheader -X-Authentik-&#xA;&#x9;requestheader -Remote-User&#xA;&#x9;reverseproxy /outpost.goauthentik.io/ http://authentik-outpost:9000&#xA;&#x9;forwardauth http://authentik-outpost:9000 {&#xA;&#x9;&#x9;uri /outpost.goauthentik.io/auth/caddy&#xA;&#x9;&#x9;copyheaders X-Authentik-Username X-Authentik-Groups X-Authentik-Email&#xA;&#x9;}&#xA;}&#xA;&#xA;myapp.example.com {&#xA;&#x9;route {&#xA;&#x9;&#x9;import authentikgate&#xA;&#x9;&#x9;reverseproxy myapp:8080 {&#xA;&#x9;&#x9;&#x9;headerup X-Proxy-Secret {env.MYAPPPROXYSECRET}&#xA;&#x9;&#x9;}&#xA;&#x9;}&#xA;}&#xA;&#xA;2\. In the app: reject any request that doesn&#39;t carry the secret. Compare in constant time, and refuse to start without a secret:&#xA;&#xA;import hmac&#xA;from starlette.middleware.base import BaseHTTPMiddleware&#xA;from starlette.responses import JSONResponse&#xA;&#xA;class ProxySecretGuard(BaseHTTPMiddleware):&#xA;    def init(self, app, secret: str):&#xA;        super().init(app)&#xA;        if not secret or len(secret) &lt; 32:&#xA;            raise ValueError(&#34;proxy secret missing or too short&#34;)&#xA;        self.secret = secret.encode()&#xA;&#xA;    async def dispatch(self, request, callnext):&#xA;        sent = request.headers.get(&#34;X-Proxy-Secret&#34;, &#34;&#34;).encode()&#xA;        if not hmac.comparedigest(sent, self.secret):&#xA;            return JSONResponse({&#34;detail&#34;: &#34;forbidden&#34;}, statuscode=403)&#xA;        return await callnext(request)&#xA;&#xA;A tip if you have many backends: Caddy can read a secret from a file with {file./secrets/myapp}. Adding a backend then means dropping in a file and reloading, instead of recreating the Caddy container to add an env var.&#xA;&#xA;Apps whose code you can&#39;t change&#xA;&#xA;The app has a &#34;trusted reverse proxy&#34; setting (Navidrome and many others): give Caddy a fixed IP on the Docker network and trust exactly that one address. Trusting the whole subnet is the hole you&#39;re trying to close.&#xA;The app has its own login (the \arr apps, SABnzbd, qBittorrent): turn it on, and don&#39;t use &#34;disabled for local addresses&#34;, because every container counts as local. For HTTP Basic, Caddy can inject the credentials after Authentik, so users still see a single login.&#xA;Neither (log viewers, dashboards): move it onto a private Docker network shared only with Caddy, and remove its host port.&#xA;&#xA;Check your own server in one line&#xA;&#xA;From a throwaway container on the same network as each app that reads identity headers:&#xA;&#xA;docker run --rm --network net curlimages/curl -s \&#xA;  -H &#39;X-Authentik-Username: admin&#39; http://app:port/&#xA;&#xA;Anything other than a 401 or 403 means you have a side door.&#xA;&#xA;Written from a real fix on a production homelab. The lab, the Express version of the middleware, and four other modules for failures that never raise an error (silent backup gaps, Docker Desktop on Windows dying quietly, ports that never bind) are in the Hardened Self\-Hosting Kit.*&#xA;&#xA;#selfhosting #security]]&gt;</description>
      <content:encoded><![CDATA[<p><em>Caddy + Authentik header auth, the side door nobody closes, and a Caddy quirk that let users forge their own groups</em></p>

<p>If an app behind Caddy and Authentik trusts the <code>X-Authentik-Username</code> header, then every other container that can reach that app&#39;s port can send the header itself and become any user, admin included. It needs no password and no session, and Authentik never sees the request.</p>

<p>This is the most common way people wire up “SSO for apps that don&#39;t do SSO”, and it is in a lot of tutorials. I found it on my own server, about 80 containers, in four apps at once. One of them was an admin panel where a forged <code>X-Authentik-Groups: authentik Admins</code> header could approve or delete user sign-ups.</p>

<h2 id="why-sso-doesn-t-cover-it">Why SSO doesn&#39;t cover it</h2>

<p>Forward auth protects the <em>front door</em>. A request comes in to Caddy, Caddy asks Authentik “is this person logged in?”, and if so it copies the username into a header and passes the request to the app. The app trusts that header.</p>

<p>The app&#39;s port is a <em>side door</em>, and it is open to:</p>
<ul><li>every container on the same Docker network, by name (<code>http://myapp:8080</code>);</li>
<li>every container on the host, if you published the port as <code>127.0.0.1:8080</code>. Containers reach it through <code>host.docker.internal</code>. Binding to localhost hides the port from your LAN, not from your other containers.</li></ul>

<p>So one compromised image, whether a scraper with an RCE, a typosquatted package or a hijacked upstream, only has to do this:</p>

<pre><code>curl -H &#39;X-Authentik-Username: admin&#39; \
     -H &#39;X-Authentik-Groups: admins&#39; \
     http://myapp:8080/admin/
</code></pre>

<h2 id="reproducing-it">Reproducing it</h2>

<p>I built a small compose lab: Caddy, a stand-in for the Authentik outpost, a naive backend that trusts the header, a hardened one, and an “attacker” container that&#39;s just another service on the network. From the attacker:</p>

<pre><code>== NAIVE: other container hits backend directly ==
{&#34;user&#34;:&#34;admin&#34;,&#34;groups&#34;:&#34;admins&#34;}
== HARDENED: other container hits backend directly ==
{&#34;detail&#34;:&#34;forbidden&#34;}
</code></pre>

<h2 id="the-quirk-forging-headers-through-caddy">The quirk: forging headers <em>through</em> Caddy</h2>

<p>There&#39;s a second, subtler hole. <code>forward_auth … copy_headers</code> copies headers from Authentik&#39;s response onto the request. What happens when Authentik&#39;s response <em>doesn&#39;t include</em> one of those headers, but the client sent it? I tested a logged-in, non-admin user adding <code>X-Authentik-Groups: admins</code> to their own request:</p>

<table>
<thead>
<tr>
<th>Caddy</th>
<th>What the backend received as groups</th>
</tr>
</thead>

<tbody>
<tr>
<td>2.8.4</td>
<td>the literal text <code>{http.reverse_proxy.header.X-Authentik-Groups}</code> (a different bug)</td>
</tr>

<tr>
<td>2.10.2</td>
<td>“admins”, the forged value passed straight through</td>
</tr>

<tr>
<td>2.11.4</td>
<td>null (fixed)</td>
</tr>
</tbody>
</table>

<p>If you&#39;re on 2.10 or older and your app reads a header that Authentik doesn&#39;t always send, a user can grant themselves whatever that header says. Upgrade, and strip client identity headers anyway, since it costs one line.</p>

<h2 id="the-fix-in-two-layers">The fix, in two layers</h2>

<p><strong>1. In Caddy:</strong> strip client-supplied identity headers before forward auth, and attach a secret that only Caddy knows, one secret per backend:</p>

<pre><code>(authentik_gate) {
	request_header -X-Authentik-*
	request_header -Remote-User
	reverse_proxy /outpost.goauthentik.io/* http://authentik-outpost:9000
	forward_auth http://authentik-outpost:9000 {
		uri /outpost.goauthentik.io/auth/caddy
		copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
	}
}

myapp.example.com {
	route {
		import authentik_gate
		reverse_proxy myapp:8080 {
			header_up X-Proxy-Secret {env.MYAPP_PROXY_SECRET}
		}
	}
}
</code></pre>

<p><strong>2. In the app:</strong> reject any request that doesn&#39;t carry the secret. Compare in constant time, and refuse to start without a secret:</p>

<pre><code>import hmac
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse

class ProxySecretGuard(BaseHTTPMiddleware):
    def __init__(self, app, secret: str):
        super().__init__(app)
        if not secret or len(secret) &lt; 32:
            raise ValueError(&#34;proxy secret missing or too short&#34;)
        self._secret = secret.encode()

    async def dispatch(self, request, call_next):
        sent = request.headers.get(&#34;X-Proxy-Secret&#34;, &#34;&#34;).encode()
        if not hmac.compare_digest(sent, self._secret):
            return JSONResponse({&#34;detail&#34;: &#34;forbidden&#34;}, status_code=403)
        return await call_next(request)
</code></pre>

<p>A tip if you have many backends: Caddy can read a secret from a file with <code>{file./secrets/myapp}</code>. Adding a backend then means dropping in a file and reloading, instead of recreating the Caddy container to add an env var.</p>

<h2 id="apps-whose-code-you-can-t-change">Apps whose code you can&#39;t change</h2>
<ul><li><strong>The app has a “trusted reverse proxy” setting</strong> (Navidrome and many others): give Caddy a fixed IP on the Docker network and trust exactly that one address. Trusting the whole subnet is the hole you&#39;re trying to close.</li>
<li><strong>The app has its own login</strong> (the *arr apps, SABnzbd, qBittorrent): turn it on, and don&#39;t use “disabled for local addresses”, because every container counts as local. For HTTP Basic, Caddy can inject the credentials after Authentik, so users still see a single login.</li>
<li><strong>Neither</strong> (log viewers, dashboards): move it onto a private Docker network shared only with Caddy, and remove its host port.</li></ul>

<h2 id="check-your-own-server-in-one-line">Check your own server in one line</h2>

<p>From a throwaway container on the same network as each app that reads identity headers:</p>

<pre><code>docker run --rm --network &lt;net&gt; curlimages/curl -s \
  -H &#39;X-Authentik-Username: admin&#39; http://&lt;app&gt;:&lt;port&gt;/
</code></pre>

<p>Anything other than a 401 or 403 means you have a side door.</p>

<p>*Written from a real fix on a production homelab. The lab, the Express version of the middleware, and four other modules for failures that never raise an error (silent backup gaps, Docker Desktop on Windows dying quietly, ports that never bind) are in the <strong>Hardened Self-Hosting Kit</strong>.*</p>

<p><a href="https://blog.shapesformas.com/tag:selfhosting" class="hashtag"><span>#</span><span class="p-category">selfhosting</span></a> <a href="https://blog.shapesformas.com/tag:security" class="hashtag"><span>#</span><span class="p-category">security</span></a></p>
]]></content:encoded>
      <guid>https://blog.shapesformas.com/any-container-on-your-server-can-log-in-as-you</guid>
      <pubDate>Sat, 24 Oct 2026 18:00:00 +0000</pubDate>
    </item>
  </channel>
</rss>