- Sécurité
- OWASP
- Contrôle d’accès
IDOR et contrôle d’accès défaillant : guide pratique
Comment les IDOR et les failles de contrôle d’accès s’infiltrent dans REST, GraphQL et les route handlers Next.js, comment les tester et les corriger pour de bon.
· 7 min de lecture · Lina Source LLC
Le contrôle d’accès défaillant est la classe de bugs qui survit à toutes les mises à jour de framework. Votre ORM échappe le SQL, votre moteur de templates échappe le HTML, mais rien dans la stack ne sait que la facture 4812 appartient à Alice et non à Bob. Cette connaissance vit dans votre code, et dès qu’un handler oublie de l’appliquer, n’importe quel utilisateur connecté peut lire ou modifier les données d’un autre.
La forme la plus courante est la référence directe non sécurisée à un objet, ou IDOR : le client envoie un identifiant, le serveur charge l’enregistrement correspondant, et personne ne vérifie que l’appelant a le droit de le voir. L’exploiter ne demande aucun outil particulier. Un navigateur, un second compte et un nombre modifié dans l’URL suffisent. Les scanners qui recherchent des appels de fonctions dangereuses la détectent rarement, car le code vulnérable ne contient rien de dangereux : il manque simplement une condition à une requête de base de données parfaitement ordinaire.
Les trois CWE que vous rencontrerez
- CWE-639, contournement de l’autorisation via une clé contrôlée par l’utilisateur : l’IDOR classique. L’enregistrement est sélectionné par un identifiant que l’attaquant contrôle, et la propriété n’est jamais vérifiée.
- CWE-862, autorisation manquante : le handler n’effectue aucune vérification d’autorisation. Il s’agit souvent d’un endpoint d’administration ou interne que l’on croyait inaccessible.
- CWE-285, autorisation incorrecte : une vérification existe, mais elle est fausse. Elle contrôle le mauvais champ, vérifie un droit de lecture pour une écriture ou fait confiance à un rôle envoyé par le client.
La distinction compte au moment de corriger. Une vérification manquante signifie qu’il faut en ajouter une ; une vérification incorrecte signifie que le modèle de « qui peut faire quoi » est faux, et que la même erreur se répète probablement ailleurs. Quand vous trouvez l’un ou l’autre, cherchez ses semblables avant de fermer le ticket. Les bugs de contrôle d’accès sont rarement isolés : ils suivent les motifs qu’une équipe recopie d’un handler à l’autre.
Comment cela se produit dans un route handler Next.js
Voici le motif sous sa forme la plus courante. Le handler authentifie l’utilisateur, ce qui donne une impression de sécurité, puis charge l’enregistrement à partir de son seul identifiant. La vérification de session répond à la question « qui appelle ? » ; rien ne répond à la question « cet appelant a-t-il le droit de voir cette facture ? ».
// app/api/invoices/[id]/route.ts (vulnerable)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
// Any signed-in user can read any invoice by changing the ID
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}Le correctif consiste à intégrer la propriété dans la requête elle-même, et non dans une étape séparée que l’on peut oublier. Si l’enregistrement n’appartient pas à l’appelant, la base de données ne renvoie rien et le handler répond 404.
// app/api/invoices/[id]/route.ts (fixed)
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const invoice = await db.invoice.findFirst({
where: { id, userId: session.user.id },
});
if (!invoice) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(invoice);
}Renvoyer 404 plutôt que 403 est délibéré. Un 403 confirme que l’enregistrement existe, ce qui permet à un attaquant d’énumérer les identifiants valides même s’il ne peut pas les lire. Il en va de même pour les temps de réponse et les messages d’erreur : la réponse pour l’enregistrement de quelqu’un d’autre doit être impossible à distinguer de celle d’un enregistrement qui n’a jamais existé.
REST : les endpoints que l’on oublie
Les équipes protègent généralement le GET par identifiant, le plus évident. Les bugs se cachent dans les autres verbes et aux marges de l’API :
- Les handlers PATCH et DELETE copiés depuis le handler GET avant l’ajout de la vérification de propriété.
- Les routes imbriquées comme /projects/:projectId/tasks/:taskId, où le projet est vérifié mais la tâche est chargée par son seul taskId et peut appartenir à un autre projet.
- Les endpoints de traitement par lot qui acceptent un tableau d’identifiants et ne vérifient que le premier.
- Les téléchargements de fichiers et les tâches d’export, qui passent souvent par un service séparé doté de ses propres vérifications, plus faibles.
- Les charges utiles de mise à jour qui acceptent ownerId, organizationId ou role dans le corps de la requête et les écrivent directement en base (mass assignment).
GraphQL élargit la surface d’attaque
En GraphQL, un même objet peut être atteint par de nombreux chemins. Une vérification au niveau de la requête invoice(id) ne sert à rien si la même facture est aussi accessible via customer { invoices }, une recherche node(id) ou le type de retour d’une mutation. Chaque resolver qui renvoie un objet est un point d’entrée. Les couches de batching comme DataLoader ajoutent un autre piège : un loader indexé uniquement par identifiant renverra volontiers des enregistrements à n’importe quel utilisateur, et son cache peut servir les données d’un utilisateur à une requête ultérieure s’il est partagé entre les requêtes.
L’approche fiable consiste à autoriser dans la couche de données appelée par les resolvers, et non dans les resolvers eux-mêmes. Si tous les chemins vers une facture passent par une seule fonction qui reçoit l’utilisateur courant et restreint la requête, l’ajout d’un nouveau champ ou d’une nouvelle relation ne peut pas la contourner. Vérifiez aussi les entrées des mutations : un champ comme ownerId dans un input type est une invitation à réattribuer des enregistrements. Enfin, n’oubliez pas que l’introspection et les messages d’erreur révèlent votre schéma : partez du principe que les attaquants connaissent chaque champ et chaque relation que vous exposez.
Le même bug en Python
La structure est identique avec FastAPI et SQLAlchemy. La version vulnérable appelle db.get(Document, doc_id) ; la version corrigée filtre par propriétaire dans la même instruction.
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/documents/{doc_id}")
def get_document(
doc_id: int,
user: User = Depends(current_user),
db: Session = Depends(get_db),
):
# Vulnerable: doc = db.get(Document, doc_id)
doc = db.scalar(
select(Document).where(
Document.id == doc_id,
Document.owner_id == user.id,
)
)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
return docDes correctifs qui tiennent
Restreindre chaque requête au propriétaire ou au tenant
Placez l’identifiant de l’utilisateur ou de l’organisation dans la clause WHERE de chaque lecture et de chaque écriture. L’autorisation devient alors une propriété de la requête, facile à repérer en revue. Pour les applications multi-tenant, la sécurité au niveau des lignes (row-level security) de Postgres peut imposer la frontière entre tenants comme seconde couche : un filtre oublié ne renvoie alors rien au lieu des lignes d’un autre client. La même restriction s’applique aux écritures. Une mise à jour doit être une instruction unique filtrée à la fois par identifiant et par propriétaire, par exemple un updateMany avec les deux conditions suivi d’une vérification qu’exactement une ligne a changé, plutôt qu’une lecture, une vérification et une écriture séparée exposées à une situation de concurrence.
Centraliser la décision
Des instructions if dispersées finissent par diverger. Un petit ensemble de helpers, un par ressource, garde la règle en un seul endroit et fait ressortir tout handler qui n’appelle pas de helper.
// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";
const policy: Record<Role, ReadonlySet<Action>> = {
owner: new Set<Action>(["read", "update", "delete"]),
member: new Set<Action>(["read", "update"]),
viewer: new Set<Action>(["read"]),
};
export class NotFoundError extends Error {}
export async function requireProject(
userId: string,
projectId: string,
action: Action
) {
const membership = await db.membership.findFirst({
where: { userId, projectId },
include: { project: true },
});
// Deny by default: no membership or no permission looks the same
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Refuser par défaut
Les rôles inconnus, les appartenances manquantes et les actions inattendues doivent tous aboutir à un refus. Dans les frameworks dotés de middleware, exigez l’authentification partout et marquez explicitement les routes publiques, plutôt que l’inverse. Une nouvelle route doit rester verrouillée tant que personne n’en a décidé autrement. Ne prenez jamais le rôle, le tenant ou l’identifiant utilisateur dans le corps de la requête ou dans un en-tête défini par le client ; dérivez-les à chaque fois de la session vérifiée, côté serveur.
Les identifiants aléatoires comme les UUID valent la peine d’être utilisés, mais ils ne constituent pas un correctif. Les identifiants fuient via les URL, les logs, les liens partagés et les en-têtes Referer. Considérez-les comme impossibles à deviner uniquement au sens où ils ralentissent l’énumération, jamais comme la vérification d’accès elle-même.
Comment le tester
Les tests d’IDOR sont simples et répétitifs, c’est pourquoi il vaut la peine de les automatiser une fois que vous les avez faits à la main. Partez d’un inventaire : listez chaque route, resolver et tâche d’arrière-plan qui accepte un identifiant, y compris les identifiants cachés dans les corps de requête, les query strings et les en-têtes.
- Créez deux comptes, A et B, idéalement dans deux organisations distinctes. Créez un enregistrement avec A et notez son identifiant.
- Rejouez chaque requête qui fait référence à cet identifiant avec la session de B : GET, PATCH, DELETE, téléchargements, exports et toute requête ou mutation GraphQL qui le touche.
- Attendez-vous à un 404 pour chacune. Tout 200, et tout 403 qui confirme l’existence, est une vulnérabilité.
- Transformez la vérification manuelle en test d’intégration par ressource, afin qu’un nouveau handler sans requête restreinte échoue en CI.
- Recherchez avec grep les accès par clé primaire seule, comme findUnique({ where: { id } }) ou db.get(Model, id), et justifiez chacun d’eux.
La revue de code détecte ce que les tests manquent, car la vérification absente est visible dans le source même quand personne n’a écrit de test pour cette route. CodeAuditAgent lit un dépôt GitHub public ou un extrait de code collé et signale les lacunes de contrôle d’accès avec la CWE, la ligne citée, un scénario d’exploitation et un correctif proposé : un moyen rapide d’obtenir une seconde lecture de tous vos handlers à la fois.
Une courte checklist
- Chaque requête qui reçoit un identifiant fourni par le client filtre aussi par l’utilisateur ou le tenant de l’appelant.
- L’autorisation vit dans des helpers partagés ou dans la couche de données, pas dans des instructions if copiées-collées.
- Les cas inconnus sont refusés ; les routes publiques sont l’exception explicite.
- Les opérations d’écriture sont vérifiées aussi soigneusement que les lectures, y compris les routes par lot et imbriquées.
- Des tests à deux comptes existent pour chaque ressource et s’exécutent en CI.