EN
Wstecz
Bezpieczeństwo

Rozbieżności normalizacji ścieżek między reverse proxy a backendem

15 min czytania

Iglica rozjazdu kolejowego przylegająca do szyny w miejscu, w którym tor rozdziela się na dwa kierunki

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:

Cechy normalizacji ścieżki i bajty, które je wyzwalają. Chroniony zasób to /admin/users
CechaPrzykładowe bajtyCo musi zrobić warstwa
CANON/admin/usersnic — kontrola poprawności
MERGE_SLASH/admin//usersscalić powtórzone ukośniki
LEAD_SLASH//admin/usersznormalizować wiodące ukośniki
DOT_SEG/admin/./usersusunąć segment kropkowy
DOTDOT_RAW/public/../admin/usersrozwinąć przejście katalogiem
DOTDOT_ENC/public/%2e%2e/admin/userszdekodować, potem rozwinąć
DEC_UNRESERVED/%61dmin/userszdekodować oktet niezarezerwowany
DEC_SLASH/admin%2fuserszdekodować %2f na separator
DBL_ENC_SLASH/admin%252fuserszdekodować dwie warstwy procentowe
STRIP_SEMI/admin;foo=bar/usersobciąć parametry ścieżki
CASE_FOLD/ADMIN/userszignorować wielkość liter
TRAIL_SLASH/admin/users/zignorować końcowy ukośnik
BACKSLASH/admin\userspotraktować 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.

Dyrektywy odróżniające wariant default od alttext
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:

Żądanie wysłane przez surowego socketa, bez normalizacji po stronie klientahttp
GET /admin%2fusers HTTP/1.1
Host: pathboundary.test
Connection: close

Envoy 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:

GET /admin%2fusers - o wyniku decyduje para, nie pojedyncza warstwa
ZłożenieStrefa proxyHandlerWerdykt
NGINX default → StarletteadminADMIN_USERSOK - fail-closed
Envoy default → StarlettenoneADMIN_USERSFINDING
HAProxy default → StarlettenoneADMIN_USERSFINDING
Envoy alt → StarletteadminADMIN_USERSOK - UNESCAPE_AND_FORWARD
Envoy default → Expressnonebrak trasyNO_RESOURCE
dowolne proxy → Spring-400 z TomcataPROXY_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ę:

Naruszenia granicy autoryzacji według rodziny mutacji i backendu, suma po sześciu konfiguracjach proxy
RodzinaStarletteExpressSpring/Tomcat
kodowany ukośnik /admin%2fusers900
kodowany oktet /%61dmin/users4124
średnik /admin;foo=bar/users006
zapis /admin%2fdelete-user300
../, // , backslash, podwójne kodowanie000

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.

Kandydaci zweryfikowani ponownie poza harnessem, surowym HTTP/1.1text
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=NONE

Wyniki

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:

Werdykty na instancję. FIND - naruszenie, NORES - brak trasy, PXY - żądanie nie dotarło do handlera, DIVG - rozbieżność parsowania
InstancjaFINDOKNORESPXYdivg
nginx-default → fastapi01349526
nginx-default → express2753524
nginx-default → spring110371914
nginx-alt → fastapi01349526
nginx-alt → express2753524
nginx-alt → spring110371914
envoy-default → fastapi6754028
envoy-default → express2758026
envoy-default → spring38371914
envoy-alt → fastapi0382817
envoy-alt → express2422215
envoy-alt → spring1392255
haproxy-default → fastapi6754028
haproxy-default → express2758026
haproxy-default → spring38371914
haproxy-alt → fastapi42241018
haproxy-alt → express22540016
haproxy-alt → spring12324195

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.

Odcisk PDP — czy proxy nadal kieruje mutację do strefy admin
ProxyCANONMERGELEADDOTSEGDOTDOTDOTDOTeDECunrDECslDBLencSEMICASETRAILBSL
nginx-defaultYYYYYYYYY
nginx-altYYYYYYYY
envoy-defaultYYYY
envoy-altYYYYYYYYYY
haproxy-defaultYYYY
haproxy-altYYYYYYY
  • Y — proxy kanonicalizuje tę cechę, mutacja trafia do strefy admin
  • brak — cecha przechodzi przez proxy bez zmian
Odcisk PEP — czy router aplikacji nadal rozwiązuje chroniony handler
BackendCANONMERGELEADDOTSEGDOTDOTDOTDOTeDECunrDECslDBLencSEMICASETRAILBSL
expressYYY
fastapiYYY
springYYY
  • 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ę.

Cztery cechy, sześć złożeń. Kolor tam, gdzie aplikacja rozpoznaje ścieżkę, której proxy nie objęło polityką
ZłożenieDEC_SLASHDEC_UNRESSTRIP_SEMICASE_FOLD
NGINX domyślny→ Starletteobjęteobjętebrak trasybrak trasy
Envoy domyślny→ Starletteobejścieobejściebrak trasybrak trasy
Envoy domyślny→ Spring/Tomcat400obejścieobejściebrak trasy
Envoy domyślny→ Expressbrak trasybrak trasybrak trasyobejście
Envoy utwardzony→ Starletteobjęteobjętebrak trasybrak trasy
Envoy utwardzony→ Expressobjęteobjętebrak trasyobejś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:

walidacja reguły kompozycjitext
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.

Łańcuch nginx (hopy dekodujące) → Envoy (autoryzator) → Starlette. Zweryfikowane dla N ∈ {0, 1, 2}
Hopy dekodujące%2f%252f%25252f
N = 0sam autoryzatorobejściebrak trasybrak trasy
N = 1edge dekodującyzamknięteobejściebrak trasy
N = 2edge + sidecarzamkniętezamknięteobejś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:

Idempotencja operacji normalizacji, mierzona przez jeden i dwa hopytext
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  -> brak

Dekodowanie 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:

mapa odpornościtext
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:

Cechy otwierane przez migrację z domyślnego ingress-nginx, przy niezmienionej polityce i aplikacji
Cel migracjiStarletteExpressSpring
Envoykonfiguracja domyślnaDEC_SLASH + DEC_UNRESbez zmianDEC_UNRES
HAProxykonfiguracja domyślnaDEC_SLASH + DEC_UNRESbez zmianDEC_UNRES
HAProxyz opcjami normalizacjiDEC_SLASHbez zmianbez zmian
Envoyz opcjami normalizacjibez zmianbez zmianbez zmian
NGINXz opcjami normalizacjibez zmianbez zmianbez 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.

Traefik — zachowanie routingu dla /admin%2fusers w dwóch wersjach
WersjaDopasowanie PathPrefixStrefaSkutek
3.1.7dekoduje %2f, trafia w /admin/adminfail-closed
3.7.9zostawia %2F, brak dopasowanianoneautoryzacja 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-unreserved ukoś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:

Minimalny zestaw sondbash
# --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%2fusers

Dwieś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.

Polecane teksty

Kontakt

Skontaktuj się