← All posts

Jede Per-IP-Kontrolle, die wir ausgeliefert haben, ließ sich mit einem Header aushebeln

Authagonal·July 11, 2026
securityrate-limitinginfraaks

Unser Account-Lockout-Zähler deckelte fehlgeschlagene Logins bei fünf. Unser Rate Limiter drosselte missbräuchliche Clients. Beide hingen an der IP-Adresse des Clients, was das Naheliegendste ist, woran man sie hängen kann. Beide ließen sich komplett umgehen, und die Umgehung war ein einziger HTTP-Header, den man selbst setzen konnte.

Und zwar deshalb: Wenn die App hinter einem Reverse Proxy läuft, und unsere läuft hinter nginx auf Kubernetes, kommt die TCP-Verbindung, die die App sieht, nicht vom Nutzer. Sie kommt vom Proxy. Jeder Request trifft mit derselben Socket-Peer-Adresse ein: dem Ingress. Die echte Client-IP muss also in einem Header mitgeführt werden, X-Forwarded-For, den der Proxy setzt und die App liest. ASP.NET Core hat genau dafür eine Middleware: Sie liest X-Forwarded-For und schreibt die Remote-IP der Verbindung entsprechend um, sodass der Rest des Codes, inklusive Rate Limiter, den echten Client sieht.

Das Problem ist, dass X-Forwarded-For nur ein Header ist. Jeder kann einen schicken. Wenn die App ihm bedingungslos vertraut, und unsere tat das, aus jeder beliebigen Quelle, dann ist der Wert vom Angreifer kontrolliert. Der Angriff ist also nicht clever, er ist eine Zeile:

curl -H "X-Forwarded-For: 10.0.0.1" https://.../login
curl -H "X-Forwarded-For: 10.0.0.2" https://.../login
curl -H "X-Forwarded-For: 10.0.0.3" https://.../login

Man ändert den Header bei jedem Request, und die App glaubt, jeder Versuch käme von einem brandneuen Client. Der Lockout-Zähler, der an dieser IP hängt, erreicht für keinen einzelnen Wert je die Fünf. Der Bucket des Rate Limiters ist jedes Mal frisch. Man kann den ganzen Tag ein Passwort bruteforcen, und jede Per-IP-Verteidigung, die wir hatten, zählt fröhlich bis eins. Eine Kontrolle, die an einem Wert hängt, den der Angreifer setzt, ist keine Kontrolle.

Der Fix klingt trivial: X-Forwarded-For nur vom eigenen Proxy vertrauen. Im Prinzip ist er trivial, in der Praxis fummelig, und die Fummelei wohnt in Kubernetes.

Man kann nicht einfach aufhören, den Header zu lesen. Tut man das, sieht jeder Request so aus, als käme er vom Ingress, dann teilen sich alle Clients eine IP, und die Per-IP-Kontrollen brechen in die andere Richtung: Ein einziger Angreifer löst den Lockout für alle aus. Man muss X-Forwarded-For lesen, ihm aber nur glauben, wenn der Request tatsächlich von einem Proxy kam, dem man vertraut.

ASP.NETs Forwarded-Headers-Middleware vertraut nach Quelle: einer Liste bekannter Proxy-IPs oder bekannter Proxy-Netze als CIDRs. Der naive Zug ist, die IP des Ingress-Pods zu pinnen. Auf AKS ist diese IP nicht stabil. Ingress-Pods werden umgeplant und neu skaliert, und ihre Adressen wechseln mit, also veraltet eine gepinnte IP und bricht entweder legitimen Traffic oder reißt, wenn man einen alten Fallback drinlässt, das Loch wieder auf. Stabil ist das Subnetz, aus dem die Pods ihre Adressen ziehen. Also vertraut man dem Node-Subnetz-CIDR, nicht irgendeiner einzelnen Adresse. Jeder Hop, der den Header legitim setzen darf, lebt in diesem Bereich, und nichts außerhalb davon wird geglaubt.

Dann ist da die Zählung. Die Middleware läuft die X-Forwarded-For-Liste von rechts ab und schält einen vertrauten Hop nach dem anderen, und was nach den vertrauten Hops übrig bleibt, gilt als der Client. Läuft man einen Hop zu weit, tritt man vom eigenen Proxy herunter und in den Teil des Headers, den der Angreifer geschrieben hat. Die Zahl der Hops, die man schält, muss also exakt der Zahl der Proxies entsprechen, die tatsächlich an den Header anhängen. In unserem Pfad ist das genau einer: nginx. Der Azure Load Balancer davor ist Layer 4. Er leitet TCP weiter, er parst kein HTTP, und er fügt keinen X-Forwarded-For-Eintrag hinzu. Das korrekte Forward-Limit ist also eins. Nicht der Default, nicht „ein paar, zur Sicherheit". Eins, weil ein Proxy diesen Header anfasst.

Dieses Paar, dem Node-Subnetz vertrauen und exakt einen Hop schälen, ist der ganze Fix. Die Client-IP, die die Middleware liefert, ist jetzt der Wert, den nginx vom echten Socket geschrieben hat, und die injizierten Einträge des Angreifers stehen links davon, ignoriert, weil wir aufhören zu laufen, bevor wir sie erreichen.

Der übertragbare Teil sind nicht die Zahlen, sondern dass es Zahlen über die eigene, konkrete Topologie sind. X-Forwarded-For ist keine Information, es ist Vertrauensdelegation: Ihn zu lesen heißt zu sagen „ich glaube dem, der das gesetzt hat". Dieser Glaube muss auf exakt die Hops im eigenen Request-Pfad gepinnt sein, nicht weiter und nicht enger. Vertraut man zu breit, ist es spoofbar. Vertraut man zu eng, kollabieren alle Clients in den Proxy. Und die Zählung ist an die Infrastruktur gekoppelt: An dem Tag, an dem man ein CDN vor alles setzt, hat man einen Hop hinzugefügt, und das Forward-Limit, das gestern korrekt war, ist jetzt um eins zu kurz. Der Fix ist keine magische Konstante. Er lautet: die Proxies im eigenen Pfad zählen, genau denen und nur denen vertrauen, und bei jeder Änderung des Pfads neu zählen.

Proxy-Hops korrekt zu zählen ist unglamourös, leicht falsch zu machen, und es entscheidet, ob die eigenen Rate Limits überhaupt irgendetwas bedeuten. Authagonal übernimmt diese Buchführung, damit der Lockout den Client zählt und nicht einen Header.