BOLA : le bug numéro un et son test
Pourquoi l'autorisation au niveau de l'objet échappe aux revues et aux scanners, et comment une matrice de tests à deux tenants, branchée sur le routeur, la rend impossible à oublier.
Par Elias Varen7 min de lecture
GET /api/invoices/8f3c2a… répond 200. La session est valide, le token aussi, le rôle est le bon. La facture, elle, appartient à un autre client. Personne n'a contourné quoi que ce soit ; le contrôleur a vérifié que l'appelant était connecté, puis il a chargé l'objet par son identifiant. C'est tout le bug.
BOLA, pour Broken Object Level Authorization, n'est pas une faille au sens où on l'entend d'habitude. Il n'y a pas d'entrée malformée, pas d'injection, pas de mémoire corrompue, seulement une question que le code n'a jamais posée, celle de savoir si cet objet-là est à celui qui le demande. Sur une plateforme multi-tenant, c'est la seule question qui compte, et rien ne vous force à la poser.
Pourquoi les revues et les scanners le ratent
L'authentification et le contrôle de rôle se branchent une fois, en amont, sur toutes les routes. L'autorisation d'objet se décide après le chargement, dans chaque handler, parce qu'il faut connaître l'objet pour savoir à qui il appartient. Ce qui se fait une fois se fait bien ; ce qui se fait cent fois s'oublie une fois. Le contrôle existe souvent sur GET et manque sur PATCH, DELETE ou sur l'action annexe (/invoices/{id}/send, /invoices/{id}/pdf), parce que les routes secondaires sont écrites plus tard, par quelqu'un d'autre, en copiant la moitié du handler d'origine.
La requête fautive est en outre indiscernable d'une requête légitime, avec le même verbe, la même forme, le même code de réponse. Un scanner ne sait pas que la facture 8f3c n'est pas à l'appelant, puisqu'il lui faudrait connaître votre modèle de propriété, et le pare-feu applicatif ne voit rien non plus. Seul un test qui sait qui possède quoi peut trancher.
Reste une fausse parade, l'identifiant imprévisible. Un UUID aléatoire empêche l'énumération sans empêcher l'accès. Les identifiants voyagent, dans les URL partagées, les exports, les mails de notification, les logs d'un prestataire, les réponses d'autres endpoints qui listent des objets liés. Un identifiant n'est pas un secret, et le traiter comme tel revient à dire que quiconque a vu passer un lien possède la ressource.
Rendre le chargement nu impossible à écrire
Compter sur la vigilance de chacun ne suffit pas. Il faut retirer du code la possibilité de charger un objet sans dire pour qui. Chez nous, aucun repository exposé aux handlers HTTP n'a de méthode find($id), et l'appelant doit fournir le périmètre.
final class InvoiceRepository
{
public function __construct(private Connection $db) {}
public function getFor(TenantId $tenant, string $invoiceId): Invoice
{
$row = $this->db->fetchAssociative(
'SELECT * FROM invoice WHERE id = :id AND tenant_id = :tenant',
['id' => $invoiceId, 'tenant' => $tenant->value],
);
return $row === false
? throw new InvoiceNotFound($invoiceId)
: Invoice::fromRow($row);
}
}Le périmètre fait partie de la requête elle-même, au lieu d'un if ajouté après coup. Un objet d'un autre tenant devient introuvable, et la réponse est un 404. Je refuse le 403 dans ce cas, parce qu'il confirme que l'identifiant existe et transforme l'endpoint en oracle d'existence.
Le TenantId ne vient jamais du corps de la requête ni d'un header que le client contrôle. Il sort de la session résolue côté serveur. Dès que le tenant est lu dans un paramètre, le bug s'est déplacé d'un cran sans être corrigé.
Cette discipline couvre l'isolation entre tenants. Elle ne couvre pas l'autorisation à l'intérieur d'un tenant (un commercial qui lit le dossier d'un collègue), qui dépend du modèle de droits que je compare dans RBAC, ABAC, ReBAC : le coût de chacun. Un second filet au niveau de la base a ses propres angles morts, détaillés dans Row-level security PostgreSQL : garde-fou ou piège.
La matrice : un test qui connaît le propriétaire
Une convention ne tient que si un test échoue quand on la viole. Le test systématique repose sur un jeu de données fixe et sur une table.
Le jeu de données comprend deux tenants, A et B, chacun avec un administrateur et un utilisateur ordinaire, et un exemplaire de chaque type d'objet dans chaque tenant. L'acteur hostile du test est toujours l'administrateur de l'autre tenant. C'est le pire cas, puisqu'il passe tous les contrôles de rôle et que seul le contrôle d'objet peut l'arrêter.
La table contient une ligne par route qui porte un identifiant d'objet, avec le type d'objet attendu.
#[DataProvider('routesWithObject')]
public function testOtherTenantAdminGetsNotFound(string $method, string $path, string $type): void
{
$target = $this->fixtures->objectOf($type, tenant: 'a');
$before = $this->fixtures->snapshot($target);
$client = $this->clientLoggedInAs('admin@tenant-b.test');
$client->request($method, strtr($path, ['{id}' => $target->id]));
self::assertSame(404, $client->getResponse()->getStatusCode());
self::assertSame($before, $this->fixtures->snapshot($target));
}La seconde assertion compte autant que la première. Un endpoint peut répondre 404 après avoir modifié l'objet, ou 500 après l'avoir supprimé, et comparer l'état avant et après attrape les écritures qui fuient derrière un code d'erreur.
Ce test ne vaut rien si la table est incomplète, et le caractère systématique se joue donc ailleurs, dans un second test qui compare la table au routeur.
public function testEveryRouteIsClassified(): void
{
$classified = array_keys(AccessMatrix::ROUTES);
$actual = array_keys($this->router->getRouteCollection()->all());
self::assertSame([], array_values(array_diff($actual, $classified)));
}Chaque route doit apparaître dans la matrice avec un statut parmi trois : publique, limitée au périmètre de la session sans identifiant d'objet, ou porteuse d'un identifiant. Ajouter une route sans la classer casse la CI. Le développeur ne peut plus oublier ; il peut seulement mentir, et un mensonge se voit en revue.
Ce que la matrice ne voit pas
La matrice teste l'identifiant dans le chemin. Le bug a d'autres entrées, par lesquelles il revient une fois la première ligne de défense en place.
Les références dans le corps. POST /quotes avec un customerId qui appartient au tenant A, envoyé par un utilisateur du tenant B. La route ne porte aucun identifiant dans son chemin, la matrice la classe « périmètre de session », et le devis créé affiche le nom et l'adresse d'un client étranger. Toute référence reçue doit être résolue par le même repository borné. J'ajoute une seconde famille de tests où, pour chaque commande qui accepte une référence, on l'envoie avec un objet de l'autre tenant en exigeant un rejet.
Les listes et les filtres. GET /invoices?customerId=… ne charge pas un objet, il filtre. Si le filtre remplace la clause de tenant au lieu de s'y ajouter, la liste fuit.
Les chemins sans HTTP. Un handler asynchrone qui reçoit un identifiant dans un message, un export planifié, une génération de PDF en tâche de fond n'ont aucune session. Le tenant doit voyager dans le message et être vérifié à l'arrivée, sinon le worker charge ce qu'on lui dit de charger.
Les read models. Une projection dénormalisée qui a perdu sa colonne de tenant en chemin n'est plus filtrable du tout ; le sujet mérite son propre traitement, dans L'autorisation dans les projections.
Le prix de la matrice, et le seul cas où s'en passer
La matrice coûte un jeu de données à deux tenants maintenu pour chaque nouveau type d'objet, une ligne par route, et un temps d'exécution qui grandit avec l'API. Comptez quelques dizaines de millisecondes par ligne avec un noyau applicatif réutilisé, donc quelques secondes pour deux cents routes. Elle coûte aussi de la friction, puisque la CI refuse une route non classée, y compris le vendredi soir.
Les signatures bornées coûtent davantage au début qu'à l'usage. Les traitements réellement transverses (support, facturation de la plateforme, projections) ont besoin d'un accès sans périmètre. Ils reçoivent un repository distinct, nommé pour ce qu'il est, injecté seulement dans ces contextes, car un accès transverse possible partout sera utilisé partout.
On peut s'en passer dans un seul cas, une application où aucun objet n'appartient à quelqu'un, c'est-à-dire un outil interne mono-équipe dont tout le contenu est visible de tous. Dès qu'un second client arrive, le premier objet créé est déjà en retard d'un test.
Pour juger où vous en êtes, un « oui » à la première question ou un « non » à l'une des suivantes suffit à savoir que le bug finira par sortir.
- Existe-t-il, dans le code exposé aux handlers, une méthode qui charge un objet par son seul identifiant ?
- Le tenant peut-il venir d'autre chose que de la session résolue côté serveur ?
- Une route ajoutée sans test d'accès fait-elle échouer la CI ?
- L'acteur hostile de vos tests a-t-il le rôle le plus élevé de l'autre tenant ?
- L'état après la requête refusée est-il vérifié, en plus de son code ?
- Les références dans les corps, les filtres de liste et les messages asynchrones ont-ils leurs propres cas ?
Le dossier complet : Autorisation, du contrôle d'accès au multi-tenant
À lire ensuite
Toute la rubrique SécuritéSécurité
Row-level security PostgreSQL : garde-fou ou piège
Ce que la row-level security filtre vraiment, les rôles et les objets qui passent au travers, ce qu'elle coûte au planificateur, et le critère pour en faire un second filet plutôt que votre seule isolation.
7 minLecture libre
Sécurité
RBAC, ABAC, ReBAC : le coût de chacun
Ce que chaque modèle d'autorisation coûte en modélisation, en requêtes de liste et en migration, et la question qui permet de choisir : savez-vous traduire votre règle en filtre SQL ?
7 minMembres
Sécurité
L'autorisation dans les projections
Où placer le contrôle d'accès quand la lecture passe par un read model : ce qui doit être projeté, ce qui doit rester lu à la source, et la fenêtre pendant laquelle une révocation n'est pas encore vraie.
7 minMembres