Podatność typu insecure direct object reference (IDOR) występuje wszędzie tam, gdzie aplikacja przyjmuje od klienta identyfikator i zwraca lub zmienia odpowiadający mu rekord, nie sprawdzając, czy ten użytkownik w ogóle może go dotykać. W świecie API ta sama luka nazywa się broken object-level authorisation (BOLA) i otwiera listę OWASP API Security na 2023 rok. Zapobieganie jej wymaga kontroli autoryzacji przy każdym dostępie do obiektu, stosowanej w całej aplikacji i objętej testami.
Z czego składa się IDOR i gdzie się ukrywa
OWASP IDOR cheat sheet dzieli IDOR na trzy części: obiekt (konto, dokument, zgłoszenie, transakcja), odwołanie do niego (ID, UUID, numer konta, token lub slug) oraz brakującą kontrolę na poziomie obiektu. To trzeci element jest właściwą luką.
Odwołanie nie zawsze wygląda jak /users/123 w adresie URL. Może siedzieć w ukrytym polu formularza, takim jak user_id, w query stringu, w nagłówku żądania albo w ładunku JSON. Może też być nazwą pliku: jeśli działa GET /documents/annual-report.pdf, spróbuj podać nazwę innego istniejącego dokumentu.
Wpis OWASP API Security o BOLA ocenia łatwość wykorzystania jako łatwą, rozpowszechnienie jako szerokie, a wykrywalność jako łatwą. Serwery API zwykle nie śledzą stanu klienta, więc o tym, który obiekt załadować, decydują na podstawie ID przesłanych przez klienta. Przykładowe scenariusze z wpisu:
- Endpoint analityki sklepu w postaci
/shops/{shopName}/revenue_data.json, w połączeniu z drugim endpointem wypisującym wszystkie nazwy sklepów, wystawia dane sprzedażowe tysięcy sklepów na działanie prostego skryptu. - API pojazdów, które przyjmuje numer VIN, ale nigdy nie sprawdza, czy pojazd należy do zalogowanego użytkownika.
- Mutacja GraphQL
deleteReports, która usuwa dowolny dokument, którego ID otrzyma.
Ostatni przypadek to usuwanie, więc aktualizacje i usunięcia testuj równie starannie jak stronę wyświetlającą rekord.
Testuj na dwóch kontach
Utwórz użytkowników A i B, daj każdemu własne obiekty (dokumenty, zgłoszenia, faktury), uwierzytelnij się jako A i odtwarzaj żądania z identyfikatorami B.
- Utwórz dwa konta o różnych zakresach uprawnień.
- Utwórz obiekty należące do każdego z użytkowników.
- Zaloguj się jako użytkownik A i przechwyć każde żądanie zawierające odwołanie do obiektu.
- Podmień je na odwołania użytkownika B w ścieżce, query stringu, body i nagłówkach.
- Sprawdź, czy aplikacja za każdym razem odmawia dostępu.
Obejmij odczyt, tworzenie, aktualizację, usuwanie, eksport i akcje administracyjne. Nie pomijaj endpointu tylko dlatego, że jego identyfikatory wyglądają na losowe. Według cheat sheetu dostęp musi być odmawiany niezależnie od tego, czy identyfikator jest przewidywalny, czy nie do odgadnięcia.
Zrób z tego test regresji
Gdy ręczny przebieg działa, zamień go w kod. Poniżej szkic w pytest, zakładający fixtures dające zalogowaną sesję użytkownika A oraz ID dokumentu należącego do B. Każde żądanie musi być poza tym poprawne, z prawidłowo zbudowanym body, żeby kod 400 lub 415 z walidacji wejścia nie sprawił, że test przejdzie z niewłaściwego powodu:
import os
import pytest
BASE_URL = os.environ["APP_BASE_URL"] # from your test config
DENIED = 404 # your chosen response for foreign IDs
ENDPOINTS = [
("GET", "/documents/{id}", None),
("PUT", "/documents/{id}", {"title": "changed"}),
("DELETE", "/documents/{id}", None),
("GET", "/documents/{id}/export", None),
("POST", "/documents/{id}/comments", {"text": "hello"}),
]
@pytest.mark.parametrize("method,path,body", ENDPOINTS)
def test_a_cannot_touch_bs_document(method, path, body, session_a, b_document_id):
url = BASE_URL + path.format(id=b_document_id)
r = session_a.request(method, url, json=body)
assert r.status_code == DENIED
Wybierz świadomie między 403 a 404. Kod 403 dla cudzego ID potwierdza, że takie ID istnieje, co pomaga atakującemu w enumeracji. Niezależnie od wyboru asercja powinna dopuszczać tylko ten jeden kod. Generuj listę endpointów z tablicy routingu, aby nowe endpointy pojawiały się automatycznie, i blokuj wdrożenia, gdy zestaw testów nie przechodzi.
GraphQL wymaga dwóch dodatkowych przypadków. Odpytaj cudze ID na każdym poziomie drzewa, aby sprawdzić, czy zagnieżdżone resolvery powtarzają kontrolę rodzica. W operacjach wsadowych, jak tablica reportKeys powyżej, wyślij żądanie mieszające Twoje ID z cudzymi i upewnij się, że całe wywołanie kończy się błędem albo cudzy element jest pomijany.
Zawęź wyszukiwanie do użytkownika
Sprawdzaj autoryzację dla każdego obiektu, do którego użytkownik próbuje uzyskać dostęp, korzystając z mechanizmu, który daje Twój framework, tak aby kontrola była wbudowana w strukturę, a nie zależała od pamięci każdego dewelopera. Najprostszy wzorzec to wyszukiwanie obiektów w zbiorze dostępnym dla bieżącego użytkownika. Wersja dla Rails:
# vulnerable, searches all projects
@project = Project.find(params[:id])
# secure, searches projects related to the current user
@project = @current_user.projects.find(params[:id])
Handler Spring Boot z cheat sheetu wywołuje findById(id), więc zwraca dowolny dokument każdemu, kto potrafi odgadnąć lub zdobyć ID. Poniższy fragment to nasza własna ilustracja, a nie kod OWASP. Zakłada on zapytanie pochodne Spring Data na polu ownerId, a currentUser() oznacza Twoją funkcję pomocniczą czytającą zaufany kontekst uwierzytelnienia:
@GetMapping("/documents/{id}")
public Document getDocument(@PathVariable Long id) {
return documentRepository
.findByIdAndOwnerId(id, currentUser().getId())
.orElseThrow();
}
Tam, gdzie to możliwe, nie ujawniaj identyfikatorów, a użytkownika wyprowadzaj z sesji. W przepływach wieloetapowych trzymaj identyfikatory w sesji, aby klient nie mógł ich zmanipulować między krokami.
Losowe identyfikatory pomagają, ale tylko jako dodatkowa warstwa obrony. Dodaj kolumnę z losowym ciągiem i używaj jej w adresach URL albo stosuj UUID jako klucze główne. Nie szyfruj ID, żeby je ukryć - zrobienie tego bezpiecznie jest trudne.
Co sprawdzać w code review
Przeczytaj każdy handler, który przyjmuje ID od klienta, i znajdź miejsce, w którym następuje kontrola uprawnień. Najpierw zobacz, czy zapytanie działa na zbiorze zawężonym do użytkownika, czy wywołuje globalne find i sprawdza uprawnienia dopiero potem, o ile w ogóle. Następnie upewnij się, że kontrola istnieje w każdej funkcji docierającej do rekordu na podstawie danych od klienta, w tym przy aktualizacji, usuwaniu i eksporcie.
Uważaj na handler, który tylko porównuje ID użytkownika z sesji (na przykład wyciągnięte z JWT, które możesz sprawdzić naszym narzędziem do JWT) z parametrem ID. OWASP zaznacza, że to pokrywa jedynie niewielki podzbiór przypadków. Autoryzacja musi odzwierciedlać polityki i hierarchię użytkowników. W systemie wielodostępnym administrator w tenancie X może zażądać faktury należącej do tenanta Y. Użytkownik jest uwierzytelniony, rola to admin, a ID jest prawidłowe, więc zwykła kontrola roli może przejść, choć obiekt leży w niewłaściwym tenancie.
Przy triage rozróżniaj dwie podatności. W BOLA użytkownik z założenia może wywołać endpoint, a naruszeniem jest zmanipulowane ID. Jeśli użytkownik dociera do endpointu, do którego w ogóle nie powinien mieć dostępu, to jest to broken function-level authorisation, czyli inna poprawka.
Od czego zacząć
- Zrób inwentaryzację każdego endpointu, formularza i operacji GraphQL, które przyjmują identyfikator, łącznie z nazwami plików i ukrytymi polami. Zacznij od specyfikacji OpenAPI lub tablicy routingu, a potem dodaj to, co pojawia się tylko w historii proxy podczas klikania po aplikacji.
- Przeprowadź test na dwóch użytkownikach najpierw na dziesięciu najwrażliwszych obiektach, takich jak faktury, dane osobowe i akcje administracyjne, i zapisz każdą odpowiedź, która nie jest odmową.
- Zastąp globalne wyszukiwania wyszukiwaniami zawężonymi do użytkownika, korzystając z mechanizmu autoryzacji swojego frameworka.
- Wprowadź sparametryzowane testy między użytkownikami do pipeline'u i spraw, aby ich porażka blokowała wydanie.
- Dodaj pytania z code review do szablonu pull requesta. Aby uzyskać zewnętrzną weryfikację tego, co umknęło Twoim testom, możesz zamówić naszą usługę testów penetracyjnych.
