X-Forwarded-For: limit per IP, który ustawiał klient
Limity per IP w BusiKM brały adres z pierwszego wpisu X-Forwarded-For, który wpisuje klient. Jak liczyć adres od strony zaufanego proxy w Django.
Opublikowano: 4 min czytania
Autor: Łukasz Głowacz, Full-Stack Engineer · Read in English

Spis treści
Do 7 października wszystkie limity per IP w BusiKM liczyły adres z pierwszego wpisu nagłówka X-Forwarded-For. To wartość, którą ustawia klient, czyli dokładnie ten, kogo limit ma ograniczać. Wystarczyło w każdym żądaniu wysłać inny nagłówek, żeby za każdym razem dostać nowy licznik. Poprawka jest mała, ale dobrze pokazuje, że adres klienta za proxy to konfiguracja infrastruktury, a nie szczegół implementacji.
Jak rośnie X-Forwarded-For
Aplikacja za reverse proxy nie widzi adresu użytkownika w REMOTE_ADDR, tylko adres proxy. Dlatego proxy przekazuje oryginalny adres w nagłówku X-Forwarded-For. Ten nagłówek rośnie od lewej: jeśli klient wyśle go sam, jego wpis zostaje na początku, a każde kolejne proxy dopisuje na końcu adres, z którego dostało żądanie.

Stara funkcja w BusiKM wyglądała jak wiele przykładów w internecie:
def get_client_ip(request) -> str:
"""Extract client IP from request, handling reverse proxies."""
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
return request.META.get("REMOTE_ADDR", "")Za proxy pierwszy wpis to albo prawdziwy adres (gdy klient nic nie wysłał), albo cokolwiek, co klient sobie wpisał. Kod nie miał jak tego odróżnić.
Co dało się obejść
get_client_ip jest jedną funkcją, z której korzysta prawie każdy limit w backendzie. Test do poprawki wymienia cztery miejsca, które zależały od niej najbardziej:
- backstop logowania: 30 prób z jednego adresu na 15 minut,
- limit wysyłki maila z resetem hasła,
- limit dla anonimowych żądań w globalnym middleware,
- limit zgadywania kodu zaproszenia i kodu resetu, liczony dla pary e-mail i adres.
Nie wszystko było otwarte. Logowanie ma drugi, niezależny limit: 5 prób na jeden e-mail w 15 minut, i ten nie patrzy na adres. Atak na jedno konto dalej się zatrzymywał. Otwarte było to, przed czym chroni limit per IP: dużo kont z jednej maszyny i zgadywanie kodów, gdzie klucz zawierał adres, więc każdy podrobiony nagłówek dawał nowe pięć prób.
Liczyć od końca, nie od początku
Adresowi klienta można ufać tylko w miejscu, które dopisało proxy, któremu ufamy. Jeśli przed aplikacją stoi jedna taka warstwa, adresem klienta jest ostatni wpis. Jeśli dwie, przedostatni. Wszystko na lewo od tego miejsca pochodzi od klienta i nie ma żadnej wartości.
def get_client_ip(request) -> str:
# Count from the trusted side: with N trusted proxies, the client address
# is the entry added by the outermost one. Everything to the left of it
# was written by the client.
num_proxies = getattr(settings, "TRUSTED_PROXY_COUNT", 0)
remote_addr = request.META.get("REMOTE_ADDR", "")
if num_proxies <= 0:
return remote_addr # no proxy: the header is ignored completely
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()]
if len(hops) < num_proxies:
# Shorter than our proxies would make it, so they did not write it.
return remote_addr
return hops[-num_proxies]Liczba zaufanych warstw to ustawienie TRUSTED_PROXY_COUNT ze zmiennej środowiskowej. Domyślnie jest zero, czyli nagłówek jest ignorowany i liczy się tylko REMOTE_ADDR, którego klient nie kontroluje. Środowiska wystawione za proxy podnoszą ją do liczby swoich warstw. Wybrałem bezpieczny domyślny stan: jeśli ktoś uruchomi aplikację bez proxy i zapomni o ustawieniu, podrobiony nagłówek nic nie zmieni. Odwrotny błąd, czyli za proxy z zerem w ustawieniu, kończy się tym, że wszyscy użytkownicy mają adres proxy i dzielą jeden licznik. Taki błąd szybko wychodzi, bo limit zaczyna blokować wszystkich naraz. Dziury w limicie nie widać wcale.
Jest jeszcze jeden szczegół. DRF ma własne ustalanie adresu dla throttlingu, sterowane ustawieniem NUM_PROXIES. Bez tego ustawienia DRF używa całego nagłówka X-Forwarded-For jako identyfikatora, więc podrobiony nagłówek tak samo daje nowy licznik. Ustawiam NUM_PROXIES na tę samą wartość co TRUSTED_PROXY_COUNT, żeby limity DRF i moje liczyły ten sam adres. Logika nie jest identyczna: przy łańcuchu krótszym niż liczba proxy DRF bierze pierwszy wpis, a moja funkcja wraca do REMOTE_ADDR. Dlatego własne limity BusiKM idą przez get_client_ip.
Test, który sprawdza sedno, a nie implementację
Testów jednostkowych jest kilka: bez proxy nagłówek jest ignorowany, za jedną warstwą wygrywa ostatni wpis, za dwiema przedostatni, a zbyt krótki łańcuch wraca do REMOTE_ADDR. Najważniejszy jest jednak ten, który opisuje cel całej zmiany:
def test_spoofing_does_not_change_the_throttle_key(request_factory, settings):
"""A different header on every request must not give a new counter."""
settings.TRUSTED_PROXY_COUNT = 1
keys = {
get_client_ip(
request_factory.get(
"/",
REMOTE_ADDR=PROXY_ADDR,
HTTP_X_FORWARDED_FOR=f"203.0.113.{n}, {REAL_CLIENT}",
)
)
for n in range(1, 25)
}
assert keys == {REAL_CLIENT}24 żądania z różnymi podrobionymi wpisami mają dać jeden klucz. Ten test nie wie, jak liczona jest pozycja w łańcuchu, więc przetrwa każdą zmianę implementacji, która zachowa to, o co chodzi.
Ile warstw proxy stoi naprawdę
Kod zakłada liczbę warstw, ale tej liczby nie wyczyta się z kodu. To fakt o infrastrukturze, więc sprawdzam go na działającym środowisku, a nie w ustawieniach. Na stagingu API odpowiada bezpośrednio z Railway, bez żadnego pośrednika przed nim, a zmiennej TRUSTED_PROXY_COUNT w środowisku nie ma, więc obowiązuje jedna warstwa z ustawień. To się zgadza.
Ta liczba przestaje być prawdą w chwili, gdy ktoś postawi przed aplikacją kolejny pośrednik, na przykład CDN albo WAF działający jako proxy. Wtedy trzeba albo podnieść liczbę warstw, albo czytać nagłówek, który ten pośrednik ustawia z adresem klienta. Każda zmiana w tym, co stoi przed aplikacją, powinna więc kończyć się sprawdzeniem, jaki adres widzi Django.
W poprzednim poście pisałem o liście wyjątków, która rozjechała się między backendem a frontem. Tu jest podobny problem w innym miejscu: założenie o infrastrukturze siedzi w ustawieniach aplikacji, a infrastruktura zmienia się osobno. Jeśli masz podobny układ i zastanawiasz się, jak go pilnować, napisz do mnie.
