Toate articolele
SaaS multi-tenant · 6 min citire ·

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.

securitateRBACIDORmulti-tenant.NET

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.

De ce nu e de ajuns verificarea rolului?

Pentru că rolul și obiectul răspund la întrebări diferite:

  • Rolul: „are utilizatorul dreptul de a vizualiza facturi?“ — o întrebare despre capabilitate.
  • Apartenența: „are dreptul de a vizualiza această factură?“ — o întrebare despre perimetru.

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.

Cum arată verificarea de apartenență?

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:

  • Filtrul e în interogare, nu după. Dacă încarci întâi și verifici după, ai deja obiectul în memorie — și e ușor să scapi o cale unde verificarea lipsește.
  • Întorci 404, nu 403. Un 403 confirmă că resursa există. Pentru resurse din alt perimetru, „nu există“ e răspunsul corect din punct de vedere al scurgerii de informație.

Într-un model multi-tenant cu global query filters în EF Core, prima condiție vine automat — motiv în plus să le folosești.

Ce faci când perimetrul nu e doar tenant-ul?

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ă:

  • protecție la cicluri — o ierarhie prost administrată poate avea bucle; ține un set de noduri vizitate;
  • limită de adâncime, ca să nu transformi o eroare de date într-o buclă costisitoare;
  • calculează o singură dată per cerere și ține rezultatul în scope-ul cererii, nu la fiecare verificare;
  • nu trimite mulțimea înapoi ca parametri de interogare. Ăsta e eșecul care surprinde: 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;
  • invalidează la schimbarea ierarhiei, dacă păstrezi rezultatul între cereri.

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.

Cum eviți să repeți verificarea în fiecare controller?

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.

Ce nu e o soluție?

  • ID-uri greu de ghicit. Un GUID e util ca apărare în adâncime, dar nu e control de acces. Identificatorii ajung în log-uri, în URL-uri partajate, în rapoarte. Iar „greu de ghicit“ presupune mai mult decât verifică majoritatea echipelor: EF Core generează implicit GUID-uri secvențiale pentru SQL Server, pentru că cele aleatoare fragmentează indexul cluster. Secvențial înseamnă parțial predictibil — vezi unul, deduci vecinii. Dacă impredictibilitatea face parte din argumentul tău, generează valoarea deliberat cu un generator criptografic și acceptă costul pe index.
  • Ascunderea în interfață. Butonul lipsă nu protejează endpoint-ul.
  • Verificarea doar la citire. PUT și DELETE sunt mai periculoase decât GET și sunt mai des uitate.
  • Încrederea în referer sau în ordinea apelurilor. Clientul poate apela orice, în orice ordine.

Cum testezi?

Testul minim, pe care îl scriu pentru fiecare resursă sensibilă:

  1. două tenant-uri, fiecare cu o resursă;
  2. autentificat ca tenant A, cere resursa lui B → aștepți 404;
  3. autentificat ca tenant A, modifică resursa lui B → aștepți 404 sau 403, niciodată succes;
  4. utilizator fără rolul necesar, pe propria resursă → aștepți 403.

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.

Proiectul din spatele articolului Automatizare Marketplace eMAG Hub SaaS pentru selleri eMAG — comenzi, AWB, pricing automat și detecție de fraudă.

Ai un proiect similar?

Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.

Programează o discuție
Toate articolele