- Beveiliging
- OWASP
- Toegangscontrole
IDOR en gebrekkige toegangscontrole: een praktische gids
Hoe IDOR en gebrekkige toegangscontrole in REST, GraphQL en Next.js-route handlers sluipen, hoe je erop test en welke fixes in echte codebases standhouden.
· 7 min. leestijd · Lina Source LLC
Gebrekkige toegangscontrole is de klasse bugs die elke framework-upgrade overleeft. Je ORM escapet SQL, je template-engine escapet HTML, maar niets in de stack weet dat factuur 4812 van Alice is en niet van Bob. Die kennis zit in je eigen code, en zodra één handler vergeet haar toe te passen, kan elke ingelogde gebruiker de data van iemand anders lezen of wijzigen.
De meest voorkomende vorm is de insecure direct object reference, kortweg IDOR: de client stuurt een identifier mee, de server laadt het record met die identifier en niemand controleert of de aanroeper het mag zien. Om dit te misbruiken is geen speciale tooling nodig. Een browser, een tweede account en een aangepast getal in de URL zijn genoeg. Scanners die zoeken naar gevaarlijke functieaanroepen vangen het zelden, omdat de kwetsbare code niets gevaarlijks bevat: een volkomen normale databasequery mist simpelweg één voorwaarde.
De drie CWE’s die je tegenkomt
- CWE-639, authorization bypass through user-controlled key: de klassieke IDOR. Het record wordt geselecteerd via een ID die de aanvaller in handen heeft, en eigenaarschap wordt nooit gecontroleerd.
- CWE-862, missing authorization: de handler voert helemaal geen autorisatiecontrole uit. Vaak gaat het om een admin- of intern endpoint waarvan werd aangenomen dat het onbereikbaar was.
- CWE-285, improper authorization: er is wel een controle, maar die klopt niet. Ze controleert het verkeerde veld, controleert leesrechten bij een schrijfactie of vertrouwt een rol die de client meestuurt.
Het onderscheid doet ertoe wanneer je de bug oplost. Een ontbrekende controle betekent er een toevoegen; een onjuiste controle betekent dat het model van wie wat mag verkeerd is, en dezelfde fout komt waarschijnlijk ook elders voor. Vind je een van beide, zoek dan eerst naar verwante gevallen voordat je het ticket sluit. Bugs in toegangscontrole staan zelden op zichzelf; ze volgen de patronen die een team van handler naar handler kopieert.
Hoe het misgaat in een Next.js-route handler
Dit is het patroon in zijn meest voorkomende vorm. De handler authenticeert de gebruiker, wat als beveiliging voelt, en laadt daarna het record alleen op basis van de ID. De sessiecontrole beantwoordt wie er aanroept; niets beantwoordt of deze aanroeper deze factuur mag zien.
// app/api/invoices/[id]/route.ts (kwetsbaar)
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;
// Elke ingelogde gebruiker kan elke factuur lezen door de ID te wijzigen
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}De fix is om eigenaarschap onderdeel van de query zelf te maken, in plaats van een aparte stap die vergeten kan worden. Hoort het record niet bij de aanroeper, dan geeft de database niets terug en antwoordt de handler met 404.
// app/api/invoices/[id]/route.ts (opgelost)
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);
}Dat je 404 teruggeeft in plaats van 403 is een bewuste keuze. Een 403 bevestigt dat het record bestaat, waardoor een aanvaller geldige ID’s kan opsommen, ook als hij ze niet kan lezen. Hetzelfde geldt voor timing en foutmeldingen: het antwoord voor andermans record moet niet te onderscheiden zijn van het antwoord voor een record dat nooit heeft bestaan.
REST: de endpoints die mensen vergeten
Teams beveiligen meestal de voor de hand liggende GET op ID. De bugs verstoppen zich in de andere HTTP-methoden en aan de randen van de API:
- PATCH- en DELETE-handlers die van de GET-handler zijn gekopieerd voordat de eigenaarscontrole werd toegevoegd.
- Geneste routes zoals /projects/:projectId/tasks/:taskId, waarbij het project wel wordt gecontroleerd, maar de taak alleen op taskId wordt geladen en dus bij een ander project kan horen.
- Bulk-endpoints die een array van ID’s accepteren en alleen de eerste controleren.
- Bestandsdownloads en exportjobs, die vaak via een aparte service lopen met eigen, zwakkere controles.
- Update-payloads die ownerId, organizationId of role uit de request body accepteren en rechtstreeks naar de database schrijven (mass assignment).
GraphQL maakt het aanvalsoppervlak groter
In GraphQL is hetzelfde object via veel paden bereikbaar. Een controle op queryniveau voor invoice(id) helpt niet als dezelfde factuur ook bereikbaar is via customer { invoices }, een node(id)-lookup of het returntype van een mutation. Elke resolver die een object teruggeeft is een ingang. Batchinglagen zoals DataLoader voegen nog een valkuil toe: een loader die alleen op ID is gesleuteld, geeft zonder morren records terug aan elke viewer, en als de cache tussen requests wordt gedeeld, kan hij de data van de ene gebruiker aan een later request serveren.
De betrouwbare aanpak is autoriseren in de datalaag die de resolvers aanroepen, niet in de resolvers zelf. Als elk pad naar een factuur door één functie loopt die de viewer meekrijgt en de query afbakent, kan een nieuw veld of een nieuwe relatie daar niet omheen. Controleer ook de input van mutations: een veld als ownerId in een input type is een uitnodiging om records aan iemand anders toe te wijzen. En onthoud ten slotte dat introspection en foutmeldingen je schema prijsgeven, dus ga ervan uit dat aanvallers elk veld en elke relatie kennen die je blootstelt.
Dezelfde bug in Python
In FastAPI met SQLAlchemy ziet het er precies zo uit. De kwetsbare versie roept db.get(Document, doc_id) aan; de opgeloste versie filtert in hetzelfde statement op eigenaar.
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),
):
# Kwetsbaar: 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 docFixes die standhouden
Baken elke query af op eigenaar of tenant
Zet de gebruikers- of organisatie-ID in de WHERE-clausule van elke lees- en schrijfactie. Zo wordt autorisatie een eigenschap van de query, en dat is in een review goed te zien. Voor multi-tenant-apps kan row-level security in Postgres de tenantgrens als tweede laag afdwingen, zodat een vergeten filter niets oplevert in plaats van de rijen van een andere klant. Dezelfde afbakening geldt voor schrijfacties. Een update hoort één statement te zijn dat op zowel ID als eigenaar filtert, bijvoorbeeld updateMany met beide voorwaarden gevolgd door een controle dat er precies één rij is gewijzigd, en niet een lees-, een controle- en een aparte schrijfstap die een race condition kunnen opleveren.
Centraliseer de beslissing
Verspreide if-statements groeien uit elkaar. Een kleine set helpers, één per resource, houdt de regel op één plek en zorgt ervoor dat een handler zonder helper-aanroep direct opvalt.
// 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 },
});
// Standaard weigeren: geen lidmaatschap en geen recht zien er hetzelfde uit
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Standaard weigeren
Onbekende rollen, ontbrekende lidmaatschappen en onverwachte acties moeten allemaal uitkomen bij een weigering. Vereis in frameworks met middleware authenticatie voor alles en markeer openbare routes expliciet, in plaats van andersom. Een nieuwe route hoort op slot te zitten totdat iemand anders beslist. Haal de rol, tenant of gebruikers-ID nooit uit de request body of uit een header die de client instelt; leid ze elke keer op de server af uit de geverifieerde sessie.
Willekeurige identifiers zoals UUID’s zijn het gebruiken waard, maar ze zijn geen fix. ID’s lekken via URL’s, logs, gedeelde links en referrer-headers. Beschouw ze alleen als onraadbaar in de zin dat ze het opsommen vertragen, nooit als de toegangscontrole zelf.
Hoe je erop test
IDOR-tests zijn eenvoudig en repetitief, en juist daarom de moeite waard om te automatiseren zodra je ze een keer met de hand hebt gedaan. Begin met een inventarisatie: maak een lijst van elke route, resolver en achtergrondjob die een identifier accepteert, inclusief ID’s die verstopt zitten in request bodies, querystrings en headers.
- Maak twee accounts aan, A en B, bij voorkeur in twee verschillende organisaties. Maak als A een record aan en noteer de ID.
- Speel elk request dat naar die ID verwijst opnieuw af met de sessie van B: GET, PATCH, DELETE, downloads, exports en elke GraphQL-query of -mutation die het record raakt.
- Verwacht bij allemaal een 404. Elke 200, en elke 403 die het bestaan bevestigt, is een bevinding.
- Maak van de handmatige controle een integratietest per resource, zodat een nieuwe handler zonder afgebakende query in CI faalt.
- Grep naar lookups die alleen op primaire sleutel zoeken, zoals findUnique({ where: { id } }) of db.get(Model, id), en verantwoord elk geval.
Codereview vangt wat tests missen, omdat de ontbrekende controle in de broncode zichtbaar is, ook als niemand een test voor die route heeft geschreven. CodeAuditAgent leest een openbare GitHub-repository of een geplakt codefragment en rapporteert hiaten in de toegangscontrole met de CWE, de geciteerde regel, een exploitscenario en een voorgestelde patch. Zo krijg je snel een tweede blik op al je handlers tegelijk.
Een korte checklist
- Elke query die een door de client aangeleverde ID gebruikt, filtert ook op de gebruiker of tenant van de aanroeper.
- Autorisatie zit in gedeelde helpers of in de datalaag, niet in gekopieerde if-statements.
- Onbekende gevallen worden geweigerd; openbare routes zijn de expliciete uitzondering.
- Schrijfacties worden net zo zorgvuldig gecontroleerd als leesacties, inclusief bulk- en geneste routes.
- Voor elke resource bestaan tests met twee accounts, en die draaien in CI.