Skip to content

How-to : Isolation de tenant et prévention IDOR cross-tenant

FT179 — isolationlog

Prévenir les fuites de données cross-tenant dans les APIs multi-tenant — requêtes SQL scopées, identité basée sur les en-têtes, et prévention d'injection dans le body.


La menace : IDOR cross-tenant

Dans un système multi-tenant, chaque ressource appartient à un tenant. Un attaquant qui contrôle un compte tenant sonde les IDs d'autres tenants :

GET /notes/42          X-Tenant-Id: 2   ← l'attaquant est le tenant 2
                                         la note 42 appartient au tenant 1

Si le serveur retourne la note, l'attaquant a lu les données d'un autre tenant — une Insecure Direct Object Reference (IDOR) à la frontière tenant.


Le pattern d'isolation

1. Scoper toutes les lectures au niveau SQL

Ne jamais interroger par ID seul. Toujours ajouter AND tenant_id = ? :

php
// ❌ INCORRECT — ID seul, lisible cross-tenant
'SELECT * FROM notes WHERE id = ?'

// ✅ CORRECT — ID + tenant imposé en SQL
'SELECT * FROM notes WHERE id = ? AND tenant_id = ?'

Cela retourne null pour l'accès cross-tenant, qui devient un 404. L'attaquant n'apprend rien sur la note 42 — pas même si elle existe.

2. Les requêtes de liste sont toujours scopées

php
// ❌ INCORRECT — pourrait être augmenté par injection ?tenant_id=...
'SELECT * FROM notes ORDER BY id DESC LIMIT ?'

// ✅ CORRECT — WHERE tenant_id = ? n'est jamais optionnel
'SELECT * FROM notes WHERE tenant_id = ? ORDER BY id DESC LIMIT ?'

3. DELETE utilise le même pattern

sql
DELETE FROM notes WHERE id = ? AND tenant_id = ?

rowCount() retourne 0 si la note n'appartient pas au tenant → 404.


Identité de tenant basée sur les en-têtes

Utiliser les en-têtes X-Tenant-Id + X-User-Id pour les endpoints scopés au tenant. Valider les deux avec V::userId() (ctype_digit + garde de débordement + > 0) :

php
private function resolveTenantUser(ServerRequestInterface $request): array
{
    $tenantId = V::userId($request->getHeaderLine('X-Tenant-Id'));
    $userId   = V::userId($request->getHeaderLine('X-User-Id'));

    return [$tenantId, $userId];
}

V::userId() rejette :

  • La chaîne vide (ctype_digit('') === false)
  • Zéro (id <= 0)
  • Les négatifs ('-' échoue ctype_digit)
  • Les chaînes flottantes ('1.5' échoue ctype_digit)
  • Le débordement de 20+ chiffres (garde strlen > 18)
  • Les tentatives d'injection SQL ('1 OR 1=1' échoue ctype_digit)

Prévention de l'injection dans le body

Les attaquants peuvent inclure tenant_id dans le body POST pour tenter d'assigner une ressource à un tenant différent :

json
POST /notes
X-Tenant-Id: 1
{ "content": "Injection", "tenant_id": 99 }

Ne jamais lire tenant_id depuis le body. Toujours utiliser l'en-tête validé par le serveur :

php
// ATK-04 : body['tenant_id'] n'est jamais lu — toujours utiliser $tenantId depuis l'en-tête
$note = $this->notes->create($tenantId, $userId, $content, date('c'));
//                            ^^^^^^^^^
//                            depuis V::userId(X-Tenant-Id), pas depuis $body

Vérification d'existence du tenant à l'écriture

Avant de créer une ressource, vérifier que le tenant existe :

php
if (!$this->tenants->exists($tenantId)) {
    return $this->responseFactory->create(['error' => 'Tenant not found.'], 422);
}

Sans cette vérification, des notes seraient créées pour des IDs de tenant fantômes qui n'existent pas dans la table tenants, rompant l'intégrité référentielle.


Liste de vérification d'attaques (ATK-01 à ATK-12)

#TestAttente
ATK-01Pas d'en-têtes auth401
ATK-02GET cross-tenant (IDOR)404 — la note existe mais pas pour ce tenant
ATK-03X-Tenant-Id : "1", 1.5, +1, 1 OR 1=1401 — V::userId rejette
ATK-04Body POST contient tenant_id: 99201 — tenant_id du body ignoré
ATK-05DELETE cross-tenant404 — note non supprimée
ATK-06X-Tenant-Id : 0, -1401
ATK-07X-Tenant-Id : débordement 20 chiffres401
ATK-08Création de tenant sans X-Admin-Key401
ATK-09Mauvais X-Admin-Key401
ATK-10Note pour un ID de tenant inexistant422
ATK-11Liste : T1 voit uniquement les notes T1, pas T2Imposé par SQL WHERE tenant_id
ATK-12?limit=-1, ?limit=10.5, limit 20 chiffres422 — gardes V::queryInt

Stratégie de réponse : 404 et non 403

Quand un IDOR cross-tenant est détecté, retourner 404 — pas 403 Forbidden.

  • 403 fait fuiter l'existence : "la ressource existe mais vous ne pouvez pas y accéder"
  • 404 ne révèle rien : "pas de telle ressource pour ce tenant"

Cela prévient les attaques d'énumération de tenant.

Publié sous licence MIT.