Setup
Platforma B2B SaaS. Wielodzierżawcza (multi-tenant). React frontend, Node.js backend, JWT do autentykacji. Zacząłem zlecenie o 9 rano. O 11 przeglądałem panel admina jako superadmin.
Bez dostępu do kodu źródłowego. Czysty black-box.
Krok 1: Zrozumieć role
Aplikacja miała trzy widoczne role użytkowników (z rejestracji i odpowiedzi API):
Ale payload JWT opowiadał inną historię:
{
"sub": "user_abc123",
"tenant": "tenant_xyz",
"role": "member",
"iat": 1701432000,
"exp": 1701518400
}
Pole role było stringiem. Co się stanie, jeśli je zmienimy?
Krok 2: Przetestować eskalację roli
JWT było podpisane HS256. Bez sekretu nie mogłem tworzyć tokenów. Ale mogłem przetestować, co się stanie ze zmodyfikowanym tokenem (serwer by go odrzucił — czy na pewno?).
Przechwyciłem żądanie do /api/admin/users i całkowicie usunąłem nagłówek Authorization.
403 Forbidden. Zgodnie z oczekiwaniami.
Wysłałem je z poprawnym JWT użytkownika (role: member).
200 OK. Zwrócona pełna lista użytkowników admina.
Endpoint sprawdzał jakiekolwiek poprawne JWT — ale nie claim roli w jego wnętrzu. Jedyną rzeczą między zwykłym użytkownikiem a API admina było to, że frontend ukrywał linki nawigacyjne admina.
Krok 3: Wyliczyć wszystkie endpointy admina
Znając już wzorzec, sprawdziłem typowe trasy admina:
GET /api/admin/users → 200 (wszyscy użytkownicy ze wszystkich tenantów)
GET /api/admin/billing → 200 (dane subskrypcji, przychody)
POST /api/admin/users/invite → 200 (zapraszanie nowych adminów)
GET /api/admin/settings → 200 (konfiguracja SMTP, klucze API)
POST /api/admin/settings → 200 (możliwość zmiany SMTP do przechwytywania maili)
Każdy endpoint admina był chroniony autentykacją (wymagane poprawne JWT) — ale zerową autoryzacją (brak sprawdzenia roli lub uprawnień).
Przyczyna źródłowa
Zespół zaimplementował weryfikację JWT jako globalne middleware:
// middleware/auth.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).send('Unauthorized');
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next(); // <-- Każdy poprawny token przechodzi. Brak sprawdzenia roli.
} catch {
res.status(401).send('Invalid token');
}
};
A trasy admina używały tego samego middleware bez dodatkowych sprawdzeń:
// routes/admin.js
router.use(authMiddleware); // Sprawdza czy JWT istnieje i jest poprawny
router.get('/users', (req, res) => {
// Brak sprawdzenia, czy req.user.role === 'admin'
const users = await db.getAllUsers();
res.json(users);
});
Rozwiązanie
Dwie zmiany:
req.user.role względem wymaganej roli:const requireRole = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).send('Forbidden');
}
next();
};
router.use(authMiddleware);
router.use(requireRole('admin', 'superadmin'));
router.get('/users', (req, res) => { ... });
const users = await db.getUsersByTenant(req.user.tenant_id);
Wnioski
- Nie ufaj frontendowi w egzekwowaniu autoryzacji. Ukrywanie linków admina w UI to nie bezpieczeństwo. Atakujący nie używają Twojego UI — wysyłają żądania bezpośrednio.
- Autoryzacja jest per-endpoint, nie per-sesja. Poprawna sesja nie oznacza, że użytkownik ma dostęp do każdego endpointu.
- Zawsze zaczynaj test od roli z najniższymi uprawnieniami. Zawsze rozpoczynam test black-box jako „Viewer" — najsłabsza rola. Jeśli mogę eskalować z tego poziomu, masz problem.
Znalezienie i udokumentowanie tego błędu zajęło 2 godziny. Prawdziwy atakujący wykorzystałby go w około 10 minut. Naprawa zajęła zespołowi deweloperskiemu około 4 godzin. Koszt braku testu: naruszenie danych podlegające zgłoszeniu RODO u ponad 50 klientów biznesowych.