Kontrola dostępu oparta na ścieżce URL zakłada, że wszystkie warstwy w łańcuchu widzą ten sam adres. Jak się okazuje - nie widzą. Reverse proxy dopasowuje prefiks do własnej, znormalizowanej postaci ścieżki, a router aplikacji rozwiązuje handler z postaci, którą sam sobie zdekoduje - i te dwie postaci potrafią wskazywać różne zasoby.
Klasa problemu jest znana: CVE-2021-29492 w Envoyu to dokładnie ten wzorzec dla kodowanego ukośnika. Otwarte pozostaje pytanie: kiedy różnica w parsowaniu faktycznie przesuwa granicę autoryzacji i czy można to przewidzieć przed wdrożeniem, bez konieczności eksperymentalnego testowania każdego złożenia proxy i backendu.
Zbudowaliśmy środowisko testowe, które rejestrowało rzeczywistą decyzję proxy, niezależnie od treści logów. Wykonaliśmy w nim 1206 prób na 18 parach proxy–framework.
Dwa parsery, jedna ścieżka
W typowym wdrożeniu ścieżka jest rozwiązywana dwa razy. Proxy pełni rolę PDP (Policy Decision Point) - punktu decyzji: bierze surowe bajty request-targetu, stosuje własną normalizację i klasyfikuje wynik do strefy bezpieczeństwa. Aplikacja pełni rolę PEP (Policy Enforcement Point) - punktu wykonania: stosuje własną normalizację i rozwiązuje handler.
Porównujemy dwie rzeczy: strefę, do której proxy zakwalifikowało żądanie, oraz strefę zasobu, który ostatecznie obsłużyła aplikacja.
Żądanie jest bezpieczne wtedy, gdy aplikacja nie udostępnia zasobu wymagającego wyższych uprawnień niż te sprawdzone przez proxy. Naruszenie występuje więc wtedy, gdy proxy traktuje żądanie jako publiczne lub mniej uprzywilejowane, a aplikacja kieruje je do zasobu administracyjnego albo do danych innego najemcy.
Trzeba uwzględnić jeszcze jedną rzecz: ścieżka, na podstawie której proxy podejmuje decyzję, nie zawsze jest identyczna ze ścieżką przekazaną do aplikacji. Proxy może ją po drodze zdekodować, znormalizować albo pozostawić w surowej postaci. To rozróżnienie będzie szczególnie ważne przy analizie łańcuchów złożonych z kilku pośredników.
Sama różnica w interpretacji ścieżki nie oznacza jeszcze podatności. Proxy i aplikacja mogą odczytać ten sam adres inaczej, ale nadal zakwalifikować go do tej samej strefy dostępu. Problem pojawia się dopiero wtedy, gdy proxy obejmuje żądanie słabszą kontrolą, a aplikacja kieruje je do zasobu wymagającego wyższych uprawnień albo należącego do innego najemcy.
Cechy normalizacji
Zamiast myśleć o pojedynczych payloadach, wygodniej rozłożyć problem na cechy: rodziny bajtów, które są “moralnie” tą samą ścieżką pod warunkiem, że dana warstwa wykona konkretną operację. Trzynaście cech, których użyliśmy do odcisków palca obu warstw:
| Cecha | Przykładowe bajty | Co musi zrobić warstwa |
|---|---|---|
| CANON | /admin/users | nic — kontrola poprawności |
| MERGE_SLASH | /admin//users | scalić powtórzone ukośniki |
| LEAD_SLASH | //admin/users | znormalizować wiodące ukośniki |
| DOT_SEG | /admin/./users | usunąć segment kropkowy |
| DOTDOT_RAW | /public/../admin/users | rozwinąć przejście katalogiem |
| DOTDOT_ENC | /public/%2e%2e/admin/users | zdekodować, potem rozwinąć |
| DEC_UNRESERVED | /%61dmin/users | zdekodować oktet niezarezerwowany |
| DEC_SLASH | /admin%2fusers | zdekodować %2f na separator |
| DBL_ENC_SLASH | /admin%252fusers | zdekodować dwie warstwy procentowe |
| STRIP_SEMI | /admin;foo=bar/users | obciąć parametry ścieżki |
| CASE_FOLD | /ADMIN/users | zignorować wielkość liter |
| TRAIL_SLASH | /admin/users/ | zignorować końcowy ukośnik |
| BACKSLASH | /admin\users | potraktować backslash jak separator |
Cecha wykonana przez aplikację, a niewykonana przez proxy, to gotowy kandydat na obejście. Cecha wykonana przez obie warstwy albo przez żadną jest nieszkodliwa. Cały dalszy tekst jest rozwinięciem tej jednej obserwacji.
Środowisko i sposób pomiaru
Trzy reverse proxy - NGINX 1.27.5, Envoy 1.31.10, HAProxy 2.9.15 - i trzy
backendy: Starlette 0.37.2 na uvicornie 0.30.6, Express 4.19.2 na Node 20,
Spring Boot 3.3.2 na wbudowanym Tomcacie 10.1. Każde proxy w dwóch wariantach:
default, czyli obraz stock bez dyrektyw normalizacji, oraz alt, czyli opcje
normalizacji z dokumentacji danego dostawcy. Osiemnaście instancji, 67
request-targetów w 13 rodzinach mutacji, 1206 prób.
Polityka jest w każdym przypadku ta sama: pięć prefiksów mapowanych na strefy.
envoy / default
normalize_path: false
merge_slashes: false
path_with_escaped_slashes_action: KEEP_UNCHANGED
envoy / alt
normalize_path: true
merge_slashes: true
path_with_escaped_slashes_action: UNESCAPE_AND_FORWARD
haproxy / default
(bez dyrektyw normalizacji)
haproxy / alt
http-request normalize-uri path-merge-slashes
http-request normalize-uri path-strip-dotdot
http-request normalize-uri path-strip-dot
http-request normalize-uri percent-decode-unreserved
http-request normalize-uri percent-to-uppercase
nginx / default
merge_slashes on;
nginx / alt
merge_slashes off;Decyzja proxy jest mierzona, nie wnioskowana. Każda strefa - public,
api, admin, tenant-a, tenant-b, none - kieruje ruch na inny port
upstreamu tego samego backendu. Backend raportuje, który port odebrał żądanie,
więc można bezpośrednio ustalić, do której strefy proxy zakwalifikowało dane
żądanie. To istotne, bo access log proxy
potrafi pokazywać ścieżkę inną niż ta, na której zapadła decyzja routingu.
Instrumentacja jest trzypoziomowa. Każda odpowiedź zawiera L1: surowe bajty request-targetu tak, jak dotarły do backendu; L2: ścieżkę po dekodowaniu procentowym; L3: wzorzec trasy, który dopasował router. Dzięki L1 widać transformację, a dzięki L3 odróżniamy „parser HTTP odrzucił żądanie“ od „router trafił w uprzywilejowany handler“.
Klient jest surowym socketem HTTP/1.1 — request-target idzie bajt w bajt, jedno
połączenie na próbę, Connection: close. Żadna biblioteka URL nie poprawia
ścieżki po drodze.
Oracle przypisuje każdej próbie jeden z czterech werdyktów: FINDING, gdy
obsłużony zasób jest wrażliwszy niż strefa autoryzowana albo należy do obcego
najemcy; OK, gdy strefy się zgadzają; NO_RESOURCE, gdy router nie dopasował
żadnej trasy; PROXY_HANDLED, gdy żądanie nigdy nie dotarło do handlera.
Kodowany ukośnik
Najprostszy przypadek. Wysyłamy surowo:
GET /admin%2fusers HTTP/1.1
Host: pathboundary.test
Connection: closeEnvoy w konfiguracji domyślnej nie dekoduje %2f przed dopasowaniem prefiksu,
więc /admin%2fusers nie pasuje do /admin/ i trafia do strefy none.
Przekazuje ścieżkę dalej bez zmian. Starlette dekoduje %2f na separator, jego
router rozwiązuje /admin/users i zwraca 200.
Ta sama sekwencja bajtów na sześciu złożeniach daje pięć różnych stanów:
| Złożenie | Strefa proxy | Handler | Werdykt |
|---|---|---|---|
| NGINX default → Starlette | admin | ADMIN_USERS | OK - fail-closed |
| Envoy default → Starlette | none | ADMIN_USERS | FINDING |
| HAProxy default → Starlette | none | ADMIN_USERS | FINDING |
| Envoy alt → Starlette | admin | ADMIN_USERS | OK - UNESCAPE_AND_FORWARD |
| Envoy default → Express | none | brak trasy | NO_RESOURCE |
| dowolne proxy → Spring | - | 400 z Tomcata | PROXY_HANDLED |
NGINX jest bezpieczny nie dlatego, że ktoś go utwardził, tylko dlatego, że
dekoduje %2f przed dopasowaniem location - mutacja trafia do strefy admin
i polityka ją obejmuje. Express jest bezpieczny, bo jego router nie robi z
%2f separatora, więc żądanie nie trafia nigdzie. To ochrona przez konstrukcję
routera, nie przez politykę, i znika przy zmianie frameworka.
Dwa warianty tego samego prymitywu podnoszą stawkę. POST /admin%2fdelete-user
dociera anonimowo do handlera kasującego użytkownika. GET /tenant-b%2fdocuments
zwraca dokumenty tenanta B w kontekście, w którym proxy nie autoryzowało
żadnego tenanta - czyli przecina izolację najemców, nie tylko hierarchię
uprawnień.
Kodowane oktety, wielkość liter, parametry ścieżki
Kodowany ukośnik to jedna cecha z trzynastu. Rozkład znalezisk pokazuje, że każdy backend nadstawia inną powierzchnię:
DEC_UNRESERVED działa wszędzie tam, gdzie backend dekoduje oktety przed
routingiem: /%61dmin/users i /adm%69n/users przechodzą na Starlette i na
Springu. CASE_FOLD jest cechą wyłącznie Expressa, którego router dopasowuje
trasy bez rozróżniania wielkości liter - stąd /ADMIN/users i /Admin/Users.
STRIP_SEMI zasługuje na osobne zdanie, bo jest najbardziej niewygodny.
Tomcat obcina parametry ścieżki po średniku, więc /admin;foo=bar/users
rozwiązuje się do handlera /admin/users. Żadne z trzech
testowanych proxy nie usuwa parametrów ścieżki, w żadnym z
sześciu wariantów konfiguracji - obejście przechodzi także przez stos
utwardzony.
W drugą stronę: klasyczna lista sztuczek z poradników jest w tym stosie
nieaktualna. /public/../admin/users na Springu 3.3 z Tomcatem 10.1 kończy się
czterysta czwórką, a kodowane przejście katalogiem czterysetką. Zero znalezisk
w rodzinach dotdot, slashes, backslash i multi-encode na wszystkich
osiemnastu instancjach.
envoy-default-fastapi GET /admin%2fusers -> 200 pz=none handler=ADMIN_USERS
envoy-default-fastapi POST /admin%2fdelete-user -> 200 pz=none handler=ADMIN_DELETE
haproxy-default-fastapi GET /tenant-b%2fdocuments -> 200 pz=none handler=TENANT_B_DOCS
nginx-default-express GET /ADMIN/users -> 200 pz=none handler=ADMIN_USERS
nginx-default-spring GET /admin;foo=bar/users -> 200 pz=none handler=ADMIN_USERS
-- kontrole negatywne --
envoy-alt-fastapi GET /admin%2fusers -> 200 pz=admin handler=ADMIN_USERS
nginx-default-spring GET /public/../admin/users-> 404 pz=admin handler=NONEWyniki
- wysłanych żądań
- 1206
- naruszeń granicy autoryzacji
- 38
- rozbieżności parsowania bez skutku dla autoryzacji
- 300
- naruszeń kontroli na ścieżkach kanonicznych
- 0
Rozkład na instancje pokazuje, gdzie dokładnie siedzi powierzchnia fail-open:
| Instancja | FIND | OK | NORES | PXY | divg |
|---|---|---|---|---|---|
| nginx-default → fastapi | 0 | 13 | 49 | 5 | 26 |
| nginx-default → express | 2 | 7 | 53 | 5 | 24 |
| nginx-default → spring | 1 | 10 | 37 | 19 | 14 |
| nginx-alt → fastapi | 0 | 13 | 49 | 5 | 26 |
| nginx-alt → express | 2 | 7 | 53 | 5 | 24 |
| nginx-alt → spring | 1 | 10 | 37 | 19 | 14 |
| envoy-default → fastapi | 6 | 7 | 54 | 0 | 28 |
| envoy-default → express | 2 | 7 | 58 | 0 | 26 |
| envoy-default → spring | 3 | 8 | 37 | 19 | 14 |
| envoy-alt → fastapi | 0 | 38 | 28 | 1 | 7 |
| envoy-alt → express | 2 | 42 | 22 | 1 | 5 |
| envoy-alt → spring | 1 | 39 | 22 | 5 | 5 |
| haproxy-default → fastapi | 6 | 7 | 54 | 0 | 28 |
| haproxy-default → express | 2 | 7 | 58 | 0 | 26 |
| haproxy-default → spring | 3 | 8 | 37 | 19 | 14 |
| haproxy-alt → fastapi | 4 | 22 | 41 | 0 | 18 |
| haproxy-alt → express | 2 | 25 | 40 | 0 | 16 |
| haproxy-alt → spring | 1 | 23 | 24 | 19 | 5 |
Trzydzieści osiem znalezisk to 25 odrębnych przypadków (proxy, backend, rodzina, target). Zero naruszeń kontroli oznacza, że każda ścieżka kanoniczna trafiła
do własnej strefy na wszystkich osiemnastu instancjach — bez tego reszta liczb
byłaby bezwartościowa.
Warto zestawić wiersze envoy-default → fastapi i envoy-alt → fastapi:
sześć znalezisk kontra zero, przy 28 rozbieżnościach parsowania kontra siedem.
Jedna dyrektywa przesuwa całą instancję.
Model kompozycji
Interesujące jest pytanie, czy wynik złożenia da się przewidzieć z części, bez fuzzowania każdej pary z osobna.
Dla każdego komponentu mierzymy odcisk niezależnie. Dla proxy: czy po mutacji
nadal kieruje żądanie do strefy admin - czyli PDP_i. Dla frameworka: czy
jego router po mutacji nadal rozwiązuje chroniony handler, sprawdzane sondą
prosto do backendu z pominięciem proxy — czyli PEP_i.
Kody kolumn odpowiadają trzynastu cechom z tabeli wyżej, w tej samej kolejności.
| Proxy | CANON | MERGE | LEAD | DOTSEG | DOTDOT | DOTDOTe | DECunr | DECsl | DBLenc | SEMI | CASE | TRAIL | BSL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| nginx-default | Y | Y | Y | Y | Y | Y | Y | Y | – | – | – | Y | – |
| nginx-alt | Y | Y | – | Y | Y | Y | Y | Y | – | – | – | Y | – |
| envoy-default | Y | Y | – | Y | – | – | – | – | – | – | – | Y | – |
| envoy-alt | Y | Y | Y | Y | Y | Y | Y | Y | – | – | – | Y | Y |
| haproxy-default | Y | Y | – | Y | – | – | – | – | – | – | – | Y | – |
| haproxy-alt | Y | Y | Y | Y | Y | – | Y | – | – | – | – | Y | – |
- Y — proxy kanonicalizuje tę cechę, mutacja trafia do strefy admin
- brak — cecha przechodzi przez proxy bez zmian
| Backend | CANON | MERGE | LEAD | DOTSEG | DOTDOT | DOTDOTe | DECunr | DECsl | DBLenc | SEMI | CASE | TRAIL | BSL |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| express | Y | – | – | – | – | – | – | – | – | – | Y | Y | – |
| fastapi | Y | – | – | – | – | – | Y | Y | – | – | – | – | – |
| spring | Y | – | – | – | – | – | Y | – | – | Y | – | – | – |
- Y — router nadal rozwiązuje chroniony handler mimo mutacji
- brak — mutacja gubi trasę albo trafia gdzie indziej
Reguła jest prosta: naruszenie występuje wtedy, gdy aplikacja rozpoznaje daną cechę ścieżki, ale proxy jej nie rozpoznaje.
Innymi słowy, aplikacja normalizuje żądanie do chronionej ścieżki, natomiast proxy podejmuje decyzję o dostępie na podstawie jej nieznormalizowanej postaci.
Aby takie złożenie było bezpieczne, proxy musi obsługiwać co najmniej wszystkie sposoby normalizacji ścieżki obsługiwane przez aplikację.
| Złożenie | DEC_SLASH | DEC_UNRES | STRIP_SEMI | CASE_FOLD |
|---|---|---|---|---|
| NGINX domyślny→ Starlette | objęte | objęte | brak trasy | brak trasy |
| Envoy domyślny→ Starlette | obejście | obejście | brak trasy | brak trasy |
| Envoy domyślny→ Spring/Tomcat | 400 | obejście | obejście | brak trasy |
| Envoy domyślny→ Express | brak trasy | brak trasy | brak trasy | obejście |
| Envoy utwardzony→ Starlette | objęte | objęte | brak trasy | brak trasy |
| Envoy utwardzony→ Express | objęte | objęte | brak trasy | obejście |
- obejście — aplikacja dosięga zasobu, proxy go nie objęło polityką
- objęte — obie warstwy widzą tę samą ścieżkę
- brak trasy albo odrzucenie przez serwer aplikacji
Dla każdej pary proxy–backend i każdej cechy przewidzieliśmy wynik z dwóch osobno zmierzonych odcisków, a potem porównaliśmy z pomiarem złożenia:
trials evaluated : 224
confusion matrix : TP=19 TN=205 FP=0 FN=0
accuracy : 1.000 precision/recall : 1.000 / 1.000
NO mispredictions.Z reguły wypadają dwa zachowania, które trudno przewidzieć intuicją.
Paradoks utwardzania. CASE_FOLD jest komórką wyłącznie po stronie PEP.
Żadne proxy nie ma opcji składającej /ADMIN/users do /admin/users, więc
utwardzenie proxy nigdy nie zamyka tego obejścia — potwierdzone na nginx-alt,
envoy-alt i haproxy-alt, z których każdy nadal przepuszcza dwa znaleziska na
Expressie. Naprawa musi trafić do routera albo do polityki.
Pułapka zarezerwowanego ukośnika. haproxy-alt włącza
percent-decode-unreserved, co dodaje do jego wsparcia DEC_UNRESERVED, ale
nie DEC_SLASH - ukośnik jest znakiem zarezerwowanym w RFC 3986, więc opcja go
nie dotyka. Obejście przez kodowany ukośnik przeżywa utwardzenie: sześć
znalezisk spada do czterech zamiast do zera. Poprawka sprawia wrażenie właściwej,
ale nie usuwa problemu.
Model przyjmuje jedno założenie, które trzeba nazwać: że proxy przekazuje dalej ścieżkę spójną z tą, na której podjęło decyzję. W badanych konfiguracjach zachodzi ono zawsze, stąd zero false-positive’ów. Proxy, które routuje na ścieżce znormalizowanej, a przekazuje surową, złamałoby je - i właśnie dlatego harness zapisuje L1 przy każdej próbie.
Drabina głębokości dekodowania
Naturalny odruch po przeczytaniu poprzedniej sekcji brzmi: dołóżmy przed autoryzatorem warstwę, która wyczyści ścieżkę. Sprawdziliśmy, co to robi.
Rozważmy łańcuch typowy dla Kubernetesa — edge → ingress → autoryzator → aplikacja — i sklasyfikujmy każdy hop jako dekodujący, jeśli przy
przekazywaniu zdejmuje jedną warstwę kodowania procentowego. Autoryzator
zostawiamy w stanie domyślnym Envoya i HAProxy: dopasowuje prefiks bez
dekodowania %2f i przekazuje sekwencję dalej. Backend dekoduje raz.
Przy N hopach dekodujących przed autoryzatorem, ukośnik zakodowany M razy
otwiera granicę wtedy i tylko wtedy, gdy M = N + 1.
| Hopy dekodujące | %2f | %252f | %25252f |
|---|---|---|---|
| N = 0sam autoryzator | obejście | brak trasy | brak trasy |
| N = 1edge dekodujący | zamknięte | obejście | brak trasy |
| N = 2edge + sidecar | zamknięte | zamknięte | obejście |
- obejście — autoryzator przepuszcza, aplikacja obsługuje /admin/users
- zamknięte — ścieżka zdekodowana przed decyzją, polityka zadziała
- brak trasy — ukośnik wciąż zakodowany, aplikacja zwraca 404
Poniżej przekątnej ścieżka jest zdekodowana za wcześnie: autoryzator widzi
/admin/users, kwalifikuje do strefy admin i polityka działa. Powyżej ukośnik
dociera do backendu wciąż zakodowany i nie ma czego obsłużyć. Dokładnie na
przekątnej autoryzator ocenia postać, w której ukośnik jest jeszcze zakodowany,
a backend dekoduje go już po decyzji.
Konsekwencja operacyjna jest odwrotna do intencji: %252f jest bezpieczne
wobec samego autoryzatora - daje 404 - i staje się obejściem dopiero po
dostawieniu przed nim dekodującego brzegu. Każdy dodany normalizator dokłada
szczebel, zamiast usuwać drabinę.
Dlaczego akurat ukośnik
Przyczyna jest ogólniejsza niż ten konkretny przypadek. Drabinę tworzy operacja nieidempotentna, czyli taka, której ponowne zastosowanie jeszcze raz zmienia wynik. Sprawdziliśmy to, przepuszczając każdą operację kolejno przez jeden i dwa hopy dekodujące:
operacja wejście 1 hop 2 hopy idempotentna
percent-decode /admin%252fusers /admin%2fusers /admin/users NIE -> drabina
percent-decode /admin%25252fusers /admin%252fusers /admin%2fusers NIE -> drabina
merge-slash /admin///users /admin/users /admin/users TAK -> brak
dot-segment /admin/./users /admin/users /admin/users TAK -> brak
dot-dot /public/../admin/... /admin/users /admin/users TAK -> brakDekodowanie procentowe zdejmuje dokładnie jedną warstwę %25 na przebieg, więc
wynik zmienia się z każdym hopem. Scalanie ukośników, usuwanie ./ i
rozwijanie ../ osiągają punkt stały po pierwszym przebiegu. Stąd istnieje
drabina dla kodowanego ukośnika i nie istnieje dla przejścia katalogiem -
i stąd wniosek ogólny: drabinę zbuduje każdy nieidempotentnie kodowany
metaznak, o ile backend go rozwiąże.
Kiedy drabina nie działa
Do tej pory autoryzatorem był Envoy albo HAProxy. Żeby sprawdzić, czy warunek konieczny i wystarczający jest cechą tej konkretnej dwójki czy czymś ogólnym, dołożyliśmy czwarte proxy - Traefika w wersji 3.1.7, spoza głównego przebiegu 1206 prób - i postawiliśmy jeden dekodujący brzeg przed każdą z dziesięciu par autoryzator × backend:
autoryzator backend N=0 klucz N=1 klucz werdykt
nginx fastapi brak brak ODPORNY (dekoduje %2f -> routuje do admin)
nginx express brak brak ODPORNY
nginx spring brak brak ODPORNY
envoy fastapi 1 2 DRABINA (klucz przesunięty o +1)
envoy express brak brak ODPORNY (backend trzyma %2f zakodowane)
envoy spring brak brak ODPORNY (Tomcat zwraca 400)
haproxy fastapi 1 2 DRABINA
haproxy express brak brak ODPORNY
haproxy spring brak brak ODPORNY
traefik fastapi brak brak ODPORNY (Traefik 3.1.7 dekoduje %2f)Warunek konieczny i wystarczający: autoryzator dopasowuje ścieżkę bez
dekodowania %2f i przekazuje ją dalej, oraz backend dekoduje %2f na
separator. Złam którąkolwiek połowę i łańcuch jest odporny. Drabina jest aktywna
w dokładnie 2 z 10 zmierzonych par - przy czym dwie z odporności (Express,
Spring) wynikają z przypadkowej konstrukcji routera, nie z polityki.
Dokumentacja Envoya ostrzega przy UNESCAPE_AND_FORWARD, żeby nie włączać tej
opcji, gdy pośredniki prowadzą kontrolę dostępu opartą na ścieżce — zobacz opis parametru path_with_escaped_slashes_action. Ostrzeżenie dotyczy jednego hopa; drabina opisuje, co ta sama decyzja robi przy trzech.
Migracja z ingress-nginx
Kubernetes ogłosił wycofanie kontrolera Ingress NGINX: po marcu 2026 brak wydań i poprawek bezpieczeństwa, istniejące wdrożenia działają dalej. Poniżej NGINX z naszego stanowiska stoi za ingress-nginx — oba dzielą ten sam silnik i te same domyślne reguły dekodowania ścieżki, więc zmierzony odcisk PDP przenosi się wprost. Migracja jeden do jednego nie zmienia polityki ani aplikacji — zmienia wyłącznie PDP-support, czyli połowę warunku zawierania.
Regresja pojawia się wtedy, gdy aplikacja obsługuje daną formę normalizacji, stare proxy również ją uwzględniało, ale nowe proxy już tego nie robi. Takie przypadki można wskazać, porównując możliwości normalizacji starego i nowego proxy:
| Cel migracji | Starlette | Express | Spring |
|---|---|---|---|
| Envoykonfiguracja domyślna | DEC_SLASH + DEC_UNRES | bez zmian | DEC_UNRES |
| HAProxykonfiguracja domyślna | DEC_SLASH + DEC_UNRES | bez zmian | DEC_UNRES |
| HAProxyz opcjami normalizacji | DEC_SLASH | bez zmian | bez zmian |
| Envoyz opcjami normalizacji | bez zmian | bez zmian | bez zmian |
| NGINXz opcjami normalizacji | bez zmian | bez zmian | bez zmian |
- cecha, którą po migracji przestaje kanonicalizować proxy — obejście otwiera się samo
- bez zmian względem ingress-nginx
Siedem regresji. ingress-nginx dekoduje %2f i oktety niezarezerwowane przed
dopasowaniem prefiksu, więc te mutacje trafiały do strefy admin i podlegały
polityce. Domyślne Envoy i HAProxy tego nie robią. Ten sam plik polityki, ta
sama aplikacja, inny wynik. Włączenie normalizacji na docelowym proxy zdejmuje
wszystkie regresje w wariancie Envoya.
To nie jest kwestia wersji — z jednym wyjątkiem
Wyniki dotąd pochodzą z jednej ustalonej wersji każdego proxy. Osobno sprawdziliśmy więc, czy dźwignią jest konfiguracja czy wersja: zmierzyliśmy odcisk PDP na starszych i nowszych wydaniach każdego proxy, trzymając konfigurację domyślną bez zmian. Odciski NGINX-a 1.21, 1.23 i 1.27, Envoya 1.27, 1.29 i 1.31 oraz HAProxy 2.6 i 2.9 są identyczne. Domyślne zachowanie normalizacji nie zmieniło się w żadnej z tych linii, więc pinowanie wersji nie pilnuje tu niczego — dźwignią jest konfiguracja.
Wyjątek znaleźliśmy dopiero po powtórzeniu tego sprawdzenia na Traefiku.
| Wersja | Dopasowanie PathPrefix | Strefa | Skutek |
|---|---|---|---|
| 3.1.7 | dekoduje %2f, trafia w /admin/ | admin | fail-closed |
| 3.7.9 | zostawia %2F, brak dopasowania | none | autoryzacja po prefiksie omijana |
Między tymi wydaniami Traefik przestał dekodować %2f na potrzeby dopasowania
PathPrefix — obecna wersja zamienia sekwencję na wielkie litery i przekazuje
ją dalej, ale nie dekoduje przed decyzją o trasie. Traefik jest najczęściej
wybieranym celem migracji z ingress-nginx, więc to ma znaczenie praktyczne:
przenosząc się, sprawdź to na swojej wersji.
To wciąż nie odpowiada na osobne pytanie: czy cały wynik nie jest artefaktem
starych obrazów użytych w głównym przebiegu. Sprawdziliśmy to osobno, na
wydaniach z lipca 2026 — a więc najnowszych dostępnych w chwili pisania.
Powtórzyliśmy sam prymityw i drabinę na Envoyu 1.35.13, HAProxy 3.4.2 (wydanym
2026-07-03), Traefiku 3.7.9 i Starlette 1.3.1 na uvicornie 0.51.0: pojedynczy
%2f przechodzi na każdym z trzech proxy, a przekątna drabiny odtwarza się bez
zmian.
Zespół Kubernetesa sam zresztą ostrzega przed tą klasą niespodzianek w tekście o nieoczywistych zachowaniach ingress-nginx przed migracją, wskazując między innymi, że dopasowania regexowe są tam prefiksowe i nieczułe na wielkość liter, podczas gdy implementacje oparte na Envoyu robią pełne dopasowanie z rozróżnianiem wielkości liter.
Dlaczego to trafia w konfigurację generowaną
Granica przebiega tutaj: nie testowaliśmy generatorów kodu. Nie mamy danych o tym, co konkretny model wypisuje na konkretny prompt.
Zmierzyliśmy natomiast dwie rzeczy, które razem wystarczają do wniosku.
Po pierwsze, stan podatny jest stanem domyślnym. Każda konfiguracja z kolumny
default to minimalny plik robiący dokładnie to, o co się go prosi: listener,
kilka tras po prefiksie, upstream. Dokumentacja Envoya potwierdza, że
normalize_path i merge_slashes są domyślnie wyłączone, a domyślne zachowanie
dla kodowanych ukośników odpowiada KEEP_UNCHANGED.
Po drugie, poprawka jest rozproszona i nieoczywista:
- Envoy —
path_with_escaped_slashes_action: UNESCAPE_AND_REDIRECT, przy domyślnym „zostaw bez zmian“; - HAProxy — wymaga dekodowania znaku zarezerwowanego, a intuicyjne
percent-decode-unreservedukośnika nie obejmuje; - Traefik — brak stabilnego przełącznika, zachowanie zmieniło się między 3.1 a 3.7;
- NGINX — bezpieczny nie z konfiguracji, tylko dlatego, że dekoduje przy
dopasowaniu
location.
Reszta jest wnioskiem i tak ją traktujcie. Konfiguracja proxy należy do
najczęściej kopiowanych i generowanych artefaktów w infrastrukturze — z
poradników, z odpowiedzi modelu, z przykładu w dokumentacji. Wszystkie te
źródła optymalizują najkrótszy plik, który działa, a kryterium „działa“ jest
spełnione, bo /admin/users trafia tam, gdzie powinno. Bezpieczeństwo zależy od
linii, której w tym pliku nie ma, a której brak nie objawia się żadnym błędem —
także w przeglądzie kodu, bo żeby ją zauważyć, trzeba wiedzieć, jak dekoduje
ścieżki komponent, którego w tym diffie w ogóle nie ma.
Obrona
Kolejność ma znaczenie i wynika wprost z drabiny.
Jeżeli mutacja jest cechą wyłącznie routera — CASE_FOLD w Expressie,
STRIP_SEMI w Tomcacie — żadne ustawienie proxy jej nie zamknie. Wtedy zostaje
poprawka w routerze albo przeniesienie decyzji autoryzacyjnej do aplikacji.
Sondy, którymi sprawdzisz własny stos, to pięć żądań wysłanych przez pełną ścieżkę produkcyjną, bez pomijania hopów:
# --path-as-is powstrzymuje curla przed poprawianiem ścieżki przed wysłaniem
curl --path-as-is -i https://twoja-domena/admin%2fusers
curl --path-as-is -i https://twoja-domena/admin%252fusers
curl --path-as-is -i https://twoja-domena/ADMIN/users
curl --path-as-is -i https://twoja-domena/admin;foo=bar/users
curl --path-as-is -i https://twoja-domena/%61dmin/users
# ten sam zestaw prosto do aplikacji, z pominięciem proxy, daje drugą połowę
# odcisku: co router uznaje za tę samą trasę
curl --path-as-is -i http://127.0.0.1:8080/admin%2fusersDwieście z chronionego zasobu bez uwierzytelnienia zamyka sprawę. Czterysta cztery nie jest jeszcze dobrą wiadomością — może oznaczać, że router po prostu nie rozwiązuje tej mutacji, tak jak Express nie rozwiązuje kodowanego ukośnika. To ochrona, która zniknie przy zmianie frameworka albo jego wersji, więc warto odróżnić ją od ochrony wynikającej z polityki.
Na koniec ten sam zestaw jako test w CI, z asercją na strefę, do której trafiło żądanie. Pinowanie wersji tego nie zastąpi: odciski nie zmieniają się między wersjami, ale konfiguracja i owszem.
Zakres i czego nie sprawdziliśmy
HTTP/1.1, metody GET i POST, autoryzacja po prefiksie ścieżki, trzy proxy i trzy frameworki, konfiguracja domyślna i jedna utwardzona. Poza zakresem: HTTP/2 i HTTP/3, request smuggling, WAF-y, zatruwanie cache’a, ataki na nagłówek Host.
Trzynaście rodzin mutacji wyprowadziliśmy ręcznie, więc to nie jest pełna taksonomia i nie zastępuje jej liczba prób. Argumentem za kompletnością jest warunek zawierania, a nie rozmiar korpusu: jeżeli aplikacja nie canonicalizuje żadnej cechy spoza zbioru proxy, obejścia nie ma niezależnie od tego, ile wariantów ktoś wymyśli.
Drabinę zweryfikowaliśmy dla N ∈ {0, 1, 2}, a mapę odporności dla 10 par
autoryzator–backend. „Hop dekodujący“ ma przy tym konkretne znaczenie:
dekodowanie przy przekazywaniu, zrealizowane jako proxy_pass http://$up:port$uri
z resolver. Zwykłe proxy_pass jest rozjazdem F — routuje po zdekodowanej,
przekazuje surową — i drabiny nie buduje.
Powiązane badania
HTTP Garden
i T-Reqs różnicowo fuzzują parsery HTTP i znajdują błędy implementacyjne. My idziemy warstwę wyżej: jednostką badania jest decyzja autoryzacyjna nad trójką proxy + backend + polityka, a nie zgodność parserów.
Gotta cache ’em all
i „Cached and Confused“ analizują tę samą rodzinę rozbieżności ścieżek pod kątem cache deception; tutaj celem jest granica autoryzacji, model kompozycji i analiza migracyjna.
Prymityw jednohopowy jest znany i ma numery: CVE-2021-29492 oraz ISTIO-SECURITY-2021-005. Pięć lat później domyślna konfiguracja nadal stawia stos po gorszej stronie.



