RBAC sigur la IDOR: de ce verificarea rolului nu e de ajuns
Rolul spune ce ai voie să faci, nu pe ce anume. În golul dintre ele trăiesc cele mai frecvente scurgeri de date dintr-un SaaS.
Rolul spune ce ai voie să faci, nu pe ce anume. În golul dintre ele trăiesc cele mai frecvente scurgeri de date dintr-un SaaS.
Aproape fiecare aplicație de business are undeva un endpoint de forma GET /facturi/8412. Și aproape fiecare are, la un moment dat, versiunea vulnerabilă a lui: cea care verifică dacă utilizatorul are rolul potrivit, dar nu și dacă factura 8412 e a lui.
Asta e IDOR — Insecure Direct Object Reference. Nu e o vulnerabilitate exotică. E cea mai comună scurgere de date reală dintr-un SaaS, pentru că apare din ceva ce pare deja rezolvat.
Pentru că rolul și obiectul răspund la întrebări diferite:
Un sistem care verifică doar prima e perfect coerent și complet nesigur. Managerul companiei A are rol de manager, deci trece verificarea — și primește factura companiei B, dacă schimbă cifra din URL.
Punctul critic: orice identificator care vine din exterior e o intrare necontrolată, exact ca un câmp de formular. Nu contează că UI-ul tău nu afișează niciodată acel ID.
Cea mai simplă formă e să nu interoghezi niciodată după cheie singură, ci după cheie plus perimetru:
var invoice = await db.Invoices
.Where(i => i.Id == id && i.TenantId == currentTenantId)
.FirstOrDefaultAsync();
if (invoice is null) return NotFound();
Două detalii care contează mai mult decât par:
Într-un model multi-tenant cu global query filters în EF Core, prima condiție vine automat — motiv în plus să le folosești.
Aici devine interesant. În produsele cu structură organizațională — un manager vede doar subordonații lui, un director vede toată ramura — perimetrul nu e o coloană, ci o mulțime calculată dintr-o ierarhie.
Abordarea care mi-a funcționat: calculezi mulțimea de utilizatori accesibili printr-o traversare în lățime (BFS) pornind de la utilizatorul curent, apoi filtrezi datele după apartenența la acea mulțime.
Ce trebuie să ai grijă:
Where(x => reachableIds.Contains(x.OwnerId)) se traduce în IN (@p0, @p1, …), iar SQL Server limitează o instrucțiune la 2100 de parametri. Un director peste 3000 de oameni nu primește o interogare lentă — primește o excepție, în producție, exact la conturile cele mai mari. Peste câteva sute de identificatori, fă join cu un tabel temporar sau un table-valued parameter, ori mută traversarea în SQL cu un CTE recursiv;Alternativa, pentru ierarhii mari, e materializarea relației (o tabelă de închidere tranzitivă). Costul se mută pe scriere; câștigul e o interogare simplă la citire. Dacă mergi pe drumul ăsta, tabela de închidere trebuie actualizată în aceeași tranzacție cu schimbarea de ierarhie care a provocat-o. O tabelă de închidere rămasă în urmă chiar și cu câteva secunde nu e un cache învechit — e o decizie de control al accesului luată pe date despre care știi că sunt greșite, în ambele sensuri.
Regula pe care mă bazez: dacă securitatea depinde de memoria dezvoltatorului, nu e securitate.
Ce funcționează:
| Mecanism | Ce acoperă |
|---|---|
| Global query filter (tenant) | Perimetrul de bază, automat, la orice interogare |
| Serviciu de autorizare pe resursă | „Are utilizatorul X drept asupra obiectului Y?“ — un singur loc |
Handler pe resursă (AuthorizationHandler<TRequirement, TResource>) |
Aceeași regulă, reutilizabilă, apelată după ce obiectul e încărcat |
| Test de acoperire | Verifică faptul că fiecare endpoint cu identificator are o verificare |
O precizare la rândurile doi și trei, pentru că aici se împiedică lumea în ASP.NET Core. Un atribut simplu [Authorize(Policy = "...")] rulează în middleware-ul de autorizare — înainte de acțiunea ta, deci înainte să existe resursa. Poate verifica revendicări și roluri; nu poate verifica „e comanda asta a utilizatorului“, pentru că încă nu există nicio comandă.
Autorizarea pe resursă e deliberat imperativă: încarci obiectul, apoi întrebi.
var order = await _db.Orders.FindAsync(id);
if (order is null) return NotFound();
var result = await _authz.AuthorizeAsync(User, order, Operations.Update);
if (!result.Succeeded) return NotFound();
Regula stă într-un AuthorizationHandler<OperationRequirement, Order> — un singur loc, testabil unitar, refolosit de fiecare endpoint care atinge o comandă. Atributul își păstrează rolul (filtrare grosieră: are utilizatorul ce căuta în modulul de comenzi), dar nu el oprește IDOR.
Testul de acoperire e cel pe care îl recomand cel mai insistent și îl văd cel mai rar. Un test care enumeră rutele și semnalează orice endpoint cu parametru de tip identificator fără verificare pe resursă prinde exact clasa de bug pe care oamenii o introduc când se grăbesc. Reține că trebuie să caute apelul AuthorizeAsync, nu doar un atribut — exact motivul pentru care cele două rânduri de mai sus sunt mecanisme diferite.
PUT și DELETE sunt mai periculoase decât GET și sunt mai des uitate.Testul minim, pe care îl scriu pentru fiecare resursă sensibilă:
Punctul 3 e cel care lipsește cel mai des. Multe echipe testează citirea și presupun că scrierea e acoperită de aceleași verificări — de multe ori nu e.
Dacă ai un produs multi-tenant și vrei o verificare pe modelul de autorizare înainte să crească costul unei greșeli, e o discuție pe care o port cu plăcere.
Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.
Programează o discuție