MOVGRANTO

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

Nagłówek X-Forwarded-For z dwoma wpisami: pierwszy wpisał klient, drugi dopisało proxy i to on jest adresem klienta.
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.

Klient wysyła żądanie przez proxy Railway do Django. Nagłówek X-Forwarded-For ma dwa wpisy: pierwszy wpisał klient, drugi dopisało proxy. Stary kod brał pierwszy wpis, nowy liczy od końca

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.

Łukasz Głowacz

O autorze

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer w MOVGRANTO. Buduje systemy od architektury po produkcję: backend, web, mobile, płatności i CI/CD. Autor BusiKM, SaaS do zarządzania flotą busów.

LinkedInGitHub

BusiKM

Zarządzanie flotą busów 2,5-3,5 t: zlecenia, mapa floty, czas pracy kierowców i koszty w jednym miejscu. 14 dni za darmo.

Sprawdź BusiKM ↗

Budujesz podobny system?

Dołączamy do zespołów jako inżynierowie full-stack albo budujemy produkt od zera. Porozmawiajmy o Twoim projekcie.

Umów rozmowę →

Newsletter

Co tydzień jedna prawdziwa decyzja architektoniczna z budowy produkcyjnego SaaS.