Broken Access Control od 2021 roku zajmuje pierwsze miejsce w OWASP Top 10. Nie dlatego, że jest skomplikowany. Dlatego, że jest błędem pominięcia — a pominięć nie widać ani w code review, ani w raporcie ze skanera. W zbiorze danych, na którym OWASP oparł tamtą edycję rankingu, ta jedna kategoria odpowiadała za ponad 318 tysięcy wystąpień: więcej niż jakakolwiek inna, włącznie z klasycznymi wstrzyknięciami.
Paradoks polega na tym, że praktycznie każdy zespół deweloperski zna to zestawienie. A mimo to kolejne aplikacje wpadają w ten sam schemat — od startupów po organizacje obsługujące wydarzenia oglądane przez miliardy ludzi.
Ten tekst tłumaczy, dlaczego tak się dzieje, jak rozpoznać cztery najczęstsze wzorce tej podatności i jak testować autoryzację, żeby faktycznie coś znaleźć.
Dwa pytania, które aplikacja musi zadać przy każdym żądaniu:
Kim jesteś? — to uwierzytelnianie (authentication). Token, sesja, MFA, SSO.
Co wolno ci zrobić z tym konkretnym zasobem? — to autoryzacja (authorization).
Pierwsze pytanie jest scentralizowane. Odpowiada na nie jeden komponent: dostawca tożsamości, middleware, biblioteka. Napisze się to raz i działa wszędzie.
Drugie pytanie jest rozproszone. Odpowiedź zależy od zasobu, roli, kontekstu, stanu obiektu i czasem od pory dnia. W większości aplikacji ta logika jest rozsiana po kontrolerach, serwisach i zapytaniach do bazy — po kilka linijek w każdym z setek miejsc.
I tu pojawia się mechanizm, który tłumaczy cały problem: żeby uwierzytelnianie zadziałało, ktoś musi napisać kod. Żeby autoryzacja zawiodła, wystarczy, że ktoś kodu nie napisze.
Brakująca linijka nie rzuca wyjątku. Nie wywala testów. Nie pojawia się w logach. Endpoint działa — po prostu działa dla wszystkich.
To pytanie zadaje sobie każdy, kto po raz pierwszy zobaczy raport z pentestu obok czystego wyniku automatycznego skanu.
Skaner potrafi wykryć SQL injection, bo istnieje obiektywny sygnał: wysyłasz ' OR 1=1--, dostajesz inną odpowiedź niż powinieneś. Sygnał jest w samej odpowiedzi.
Przy Broken Access Control takiego sygnału nie ma. Serwer zwraca poprawne HTTP 200 i poprawnie sformatowany JSON. Z punktu widzenia protokołu wszystko jest w porządku. Jedyne, co jest nie tak, to fakt, że te dane nie powinny trafić do tego użytkownika — a tej wiedzy skaner nie ma, bo nie zna modelu uprawnień twojej aplikacji.
Stąd praktyczna konsekwencja: autoryzację testuje się różnicowo, minimum dwiema tożsamościami. Bierzesz żądanie wykonane przez konto uprzywilejowane, powtarzasz je z tokenem konta niższego i porównujesz odpowiedzi. Jeden użytkownik w teście nie wykryje niczego, bo nie ma z czym porównać.
Najstarszy i najczęstszy. Identyfikator zasobu przychodzi od klienta, a backend go nie waliduje względem właściciela:
GET /api/invoices/10237 → twoja faktura
GET /api/invoices/10238 → cudza faktura
Wersja trudniejsza do wykrycia: identyfikator jest UUID-em, więc nie da się go zgadnąć iteracją. Zespół uznaje problem za rozwiązany. Nie jest — UUID to zaciemnienie, nie kontrola dostępu. Wystarczy, że identyfikator wycieknie w eksporcie CSV, mailu, logu albo w odpowiedzi innego endpointu.
Poprawka strukturalna: nie sprawdzaj uprawnień po pobraniu obiektu. Zawęź samo zapytanie: WHERE id = :id AND owner_id = :current_user. Wtedy brak uprawnień nie jest warunkiem, o którym można zapomnieć — jest niemożliwością na poziomie warstwy danych.
Frontend odczytuje rolę z tokenu, nie znajduje uprawnień i chowa interfejs. Użytkownik widzi komunikat o braku dostępu. Wygląda to na działającą kontrolę.
Tyle że wszystko, co dzieje się w przeglądarce, jest w pełni kontrolowane przez użytkownika. Ukryty przycisk to nadal istniejący endpoint, a odmowa wyrenderowana w Angularze czy Reakcie nie ma żadnego odpowiednika po stronie serwera.
To najbardziej pouczający wzorzec, bo konsekwencje bywają nieproporcjonalne do trywialności błędu. Modelowym przykładem jest Broken Access Control na platformach FIFA — konto założone przez publiczny formularz rejestracyjny, z tokenem jawnie oznaczonym jako pozbawiony ról, otrzymywało z backendu pełne odpowiedzi z produkcyjnego panelu zarządzania transmisjami mistrzostw świata. Front-end uczciwie wyświetlał „brak dostępu". Serwer po prostu o tym ograniczeniu nie wiedział.
Zasada, którą trzeba traktować jako aksjomat: autoryzacja po stronie klienta nie jest autoryzacją. UI decyduje o wygodzie. O dostępie decyduje wyłącznie serwer, przy każdym pojedynczym żądaniu.
Endpointy administracyjne, które nie są nigdzie zalinkowane, ale istnieją: /api/admin/users, /internal/export, /api/v1/config. Bezpieczeństwo przez nieujawnianie ścieżki działa do pierwszego wycieku bundla JS, pliku swagger.json albo starej wersji API, o której wszyscy zapomnieli.
Warianty, które łatwo przeoczyć:
Manipulacja metodą HTTP — GET /api/orders/12 jest chronione, PUT na tym samym zasobie nie.
Wersjonowanie — /api/v2/ ma poprawną autoryzację, bo pisano ją po incydencie. /api/v1/ nadal odpowiada.
Mass assignment — endpoint aktualizacji profilu przyjmuje dowolne pola z JSON-a, w tym "role": "admin". Autoryzacja na endpoincie jest, ale nie na poziomie pojedynczego pola.
Aplikacja poprawnie sprawdza role wewnątrz organizacji, ale nie sprawdza, czy zasób w ogóle należy do organizacji pytającego. Efekt: klient A czyta dane klienta B — nie dlatego, że ma zbyt wysoką rolę, tylko dlatego, że nikt nie zweryfikował przynależności.
Ten sam mechanizm działa na poziomie architektury tożsamości. Jeśli publiczny portal rejestracyjny i wewnętrzne systemy produkcyjne korzystają z jednego tenanta, to każde konto założone z ulicy jest już „w środku". Wtedy jedyną barierą zostaje autoryzacja aplikacyjna — czyli dokładnie ta warstwa, która w tej kategorii błędów zawodzi najczęściej.
Do wykonania z dwoma kontami: uprzywilejowanym (A) i minimalnym (B). Przechwytywanie ruchu w Burp, Caido lub dowolnym proxy.
Zaloguj A, wyklikaj pełny przepływ, zapisz wszystkie żądania.
Powtórz każde z nich, podmieniając wyłącznie token na token B. Oczekujesz 401/403 za każdym razem.
Powtórz z usuniętym nagłówkiem autoryzacji — endpointy publiczne bywają nimi przez przypadek.
Podmień identyfikatory zasobów na należące do innego konta. Sprawdź także identyfikatory zagnieżdżone w ciele żądania, nie tylko w URL-u.
Zmień metodę HTTP: GET → PUT, POST → DELETE na tych samych ścieżkach.
Sprawdź niższe wersje API i endpointy znalezione w bundlu JS oraz w dokumentacji OpenAPI.
Dorzuć do ciała żądania pola, których UI nie wysyła: role, is_admin, owner_id, tenant_id, status.
Przy przekierowaniu 302 przeczytaj ciało odpowiedzi. Częsty błąd: serwer dokleja dane do odpowiedzi i dopiero potem przekierowuje, a przeglądarka to ukrywa.
Odróżnij 403 od 404. Konsekwentne 404 na cudzych zasobach jest lepsze — nie potwierdza istnienia obiektu.
Sprawdź eksporty i raporty osobno. Bardzo często omijają warstwę autoryzacji, bo pisze je inny zespół i inna ścieżka kodu.
Łatanie pojedynczych endpointów nie zmienia prawdopodobieństwa kolejnego błędu. Zmieniają je cztery decyzje architektoniczne:
Deny by default. Domyślną odpowiedzią na nieznany endpoint i nieokreślone uprawnienie jest odmowa. Dostęp jest wyjątkiem wymagającym jawnej deklaracji, nie stanem wyjściowym. To jedyna zmiana, która przekształca błąd pominięcia w błąd widoczny od razu.
Jedna warstwa decyzyjna. Autoryzacja jako centralny komponent (middleware, policy engine, biblioteka reguł), a nie jako powtarzany if w każdym kontrolerze. Rozproszona logika gwarantuje, że prędzej czy później ktoś dopisze kontroler bez tego if-a.
Kontrola przy warstwie danych, nie tylko przy wejściu. Zapytania zawężone o właściciela lub tenanta. Wtedy nawet endpoint napisany niedbale nie zwróci cudzych rekordów.
Testy regresyjne autoryzacji w CI. Dla każdego chronionego endpointu test, który wykonuje żądanie kontem bez uprawnień i oczekuje odmowy. To kilka linijek na endpoint i jedyny mechanizm, który wychwyci regresję wprowadzoną za pół roku przez kogoś, kto nie zna kontekstu.
Dodatkowo: loguj odmowy. Seria 403 z jednego konta na przestrzeni minut to najczystszy sygnał rozpoznania, jaki dostaniesz — pod warunkiem, że ktokolwiek go zbiera.
Dług autoryzacyjny rośnie szybciej niż liniowo. Każda nowa rola mnoży liczbę par (rola × zasób), które ktoś musiałby zweryfikować. Przy trzech rolach model uprawnień da się trzymać w głowie. Przy dwunastu — nie da się, a nikt nie ogłasza momentu, w którym przestało się dać.
Dlatego organizacje nie wpadają w Broken Access Control przez jeden zły commit. Wpadają w niego przez naturalny wzrost: kolejne integracje, kolejne typy kont, kolejny portal dla partnerów podpięty do tego samego katalogu tożsamości. Każdy krok z osobna jest racjonalny. Suma nie jest.
Moment, w którym warto scentralizować autoryzację, wypada znacznie wcześniej niż moment, w którym staje się to boleśnie oczywiste.
Czym różni się Broken Access Control od IDOR? IDOR to jeden z podtypów Broken Access Control — dotyczy dostępu do konkretnego obiektu po manipulacji jego identyfikatorem. Broken Access Control to szersza kategoria obejmująca także brak kontroli na poziomie funkcji, obejście autoryzacji klienckiej i naruszenie granic wielodostępności.
Czy WAF chroni przed Broken Access Control? W praktyce nie. WAF rozpoznaje wzorce złośliwych ładunków. Żądanie nadużywające uprawnień jest składniowo całkowicie poprawne — różnica leży wyłącznie w tym, kto je wysłał.
Dlaczego ta kategoria jest pierwsza w OWASP Top 10? Bo łączy najwyższą częstość występowania z wysokim wpływem. Jest jednocześnie łatwa do popełnienia, trudna do automatycznego wykrycia i prowadzi bezpośrednio do ujawnienia lub modyfikacji danych.
Czy UUID zamiast identyfikatorów sekwencyjnych rozwiązuje problem? Nie. Utrudnia zgadywanie, ale nie stanowi kontroli dostępu. Każdy wyciek identyfikatora przywraca pełną podatność.