IDOR e controlli di accesso violati: guida pratica
Come IDOR e controlli di accesso violati si insinuano in REST, GraphQL e nei route handler di Next.js, come testarli e le correzioni che reggono davvero.
· 7 min di lettura · Lina Source LLC
Il controllo degli accessi violato è la classe di bug che sopravvive a ogni aggiornamento di framework. Il tuo ORM applica l'escaping all'SQL, il tuo template engine applica l'escaping all'HTML, ma nessun componente dello stack sa che la fattura 4812 appartiene ad Alice e non a Bob. Quella conoscenza vive nel tuo codice e, quando un handler dimentica di applicarla, qualsiasi utente autenticato può leggere o modificare i dati di qualcun altro.
La forma più comune è il riferimento diretto e insicuro a un oggetto, ovvero l'IDOR: il client invia un identificatore, il server carica il record con quell'identificatore e nessuno verifica se chi chiama è autorizzato a vederlo. Per sfruttarlo non servono strumenti particolari. Bastano un browser, un secondo account e un numero cambiato nell'URL. Gli scanner che cercano chiamate a funzioni pericolose lo individuano di rado, perché il codice vulnerabile non contiene nulla di pericoloso: a una lettura del database del tutto ordinaria manca semplicemente una condizione.
I tre CWE che incontrerai
- CWE-639, bypass dell'autorizzazione tramite chiave controllata dall'utente: l'IDOR classico. Il record viene selezionato tramite un ID controllato dall'attaccante e la proprietà non viene mai verificata.
- CWE-862, autorizzazione mancante: l'handler non esegue alcun controllo di autorizzazione. Spesso si tratta di un endpoint di amministrazione o interno che si dava per irraggiungibile.
- CWE-285, autorizzazione impropria: un controllo esiste, ma è sbagliato. Verifica il campo sbagliato, verifica il permesso di lettura su una scrittura oppure si fida di un ruolo inviato dal client.
La distinzione conta quando correggi il bug. Un controllo mancante va aggiunto; un controllo improprio significa che il modello di chi può fare cosa è sbagliato, e probabilmente lo stesso errore si ripete altrove. Quando trovi uno dei due, cerca i suoi simili prima di chiudere il ticket. I bug di controllo degli accessi sono raramente casi isolati: seguono gli schemi che un team copia da un handler all'altro.
Come accade in un route handler di Next.js
Ecco lo schema nella sua forma più comune. L'handler autentica l'utente, il che dà una sensazione di sicurezza, e poi carica il record solo tramite il suo ID. Il controllo della sessione risponde alla domanda su chi sta chiamando; nulla risponde alla domanda se chi chiama possa vedere questa fattura.
// app/api/invoices/[id]/route.ts (vulnerabile)
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;
// Qualsiasi utente autenticato può leggere qualsiasi fattura cambiando l'ID
const invoice = await db.invoice.findUnique({ where: { id } });
return NextResponse.json(invoice);
}La correzione consiste nel rendere la proprietà parte della query stessa, non un passaggio separato che si può dimenticare. Se il record non appartiene a chi chiama, il database non restituisce nulla e l'handler risponde 404.
// app/api/invoices/[id]/route.ts (corretto)
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);
}Restituire 404 invece di 403 è una scelta deliberata. Un 403 conferma che il record esiste, il che permette a un attaccante di enumerare ID validi anche quando non può leggerli. Lo stesso vale per i tempi di risposta e per i messaggi di errore: la risposta per il record di qualcun altro deve essere indistinguibile dalla risposta per un record che non è mai esistito.
REST: gli endpoint che si dimenticano
Di solito i team proteggono l'ovvia GET tramite ID. I bug si nascondono negli altri verbi e ai margini dell'API:
- Handler PATCH e DELETE copiati dall'handler GET prima che venisse aggiunto il controllo di proprietà.
- Route annidate come /projects/:projectId/tasks/:taskId, in cui il progetto viene verificato ma il task viene caricato solo tramite taskId e può appartenere a un progetto diverso.
- Endpoint bulk che accettano un array di ID e ne verificano soltanto il primo.
- Download di file e job di export, che spesso passano per un servizio separato con controlli propri e più deboli.
- Payload di aggiornamento che accettano ownerId, organizationId o role dal corpo della richiesta e li scrivono direttamente nel database (mass assignment).
GraphQL amplia la superficie
In GraphQL lo stesso oggetto è raggiungibile attraverso molti percorsi. Un controllo a livello di query su invoice(id) non serve a nulla se la stessa fattura è raggiungibile anche tramite customer { invoices }, una lettura node(id) o il tipo di ritorno di una mutation. Ogni resolver che restituisce un oggetto è un punto di ingresso. I livelli di batching come DataLoader aggiungono un'altra trappola: un loader con chiave basata solo sull'ID restituisce volentieri record a qualsiasi viewer, e la sua cache può servire i dati di un utente a una richiesta successiva se è condivisa tra le richieste.
L'approccio affidabile è autorizzare nel data layer che i resolver richiamano, non nei resolver stessi. Se ogni percorso verso una fattura passa da un'unica funzione che riceve il viewer e delimita la query, l'aggiunta di un nuovo campo o di una nuova relazione non può aggirarla. Verifica anche gli input delle mutation: un campo come ownerId in un input type è un invito a riassegnare i record. Infine, ricorda che l'introspezione e i messaggi di errore rivelano il tuo schema, quindi dai per scontato che gli attaccanti conoscano ogni campo e ogni relazione che esponi.
Lo stesso bug in Python
La forma è identica in FastAPI con SQLAlchemy. La versione vulnerabile chiama db.get(Document, doc_id); la versione corretta filtra per proprietario nella stessa istruzione.
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),
):
# Vulnerabile: 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 docCorrezioni che reggono
Delimita ogni query per proprietario o tenant
Metti l'ID dell'utente o dell'organizzazione nella clausola WHERE di ogni lettura e di ogni scrittura. Così l'autorizzazione diventa una proprietà della query, facile da vedere in revisione. Per le applicazioni multi-tenant, la row-level security di Postgres può imporre il confine tra tenant come secondo livello, in modo che un filtro dimenticato restituisca nulla anziché le righe di un altro cliente. La stessa delimitazione vale per le scritture. Un aggiornamento dovrebbe essere una singola istruzione filtrata sia per ID sia per proprietario, per esempio updateMany con entrambe le condizioni seguito da una verifica che sia cambiata esattamente una riga, anziché una lettura, un controllo e una scrittura separata che possono andare in race condition.
Centralizza la decisione
Istruzioni if sparse nel codice finiscono per divergere. Un piccolo insieme di helper, uno per risorsa, tiene la regola in un unico punto e fa risaltare un handler privo della chiamata all'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 },
});
// Nega per default: nessuna membership o nessun permesso sono indistinguibili
if (!membership || !policy[membership.role as Role]?.has(action)) {
throw new NotFoundError();
}
return membership.project;
}Nega per impostazione predefinita
Ruoli sconosciuti, appartenenze mancanti e azioni inattese devono ricadere tutti in un rifiuto. Nei framework con middleware, richiedi l'autenticazione per tutto e contrassegna esplicitamente le route pubbliche, anziché il contrario. Una nuova route dovrebbe restare bloccata finché qualcuno non decide altrimenti. Non prendere mai il ruolo, il tenant o l'ID utente dal corpo della richiesta o da un header impostato dal client; ricavali ogni volta dalla sessione verificata sul server.
Gli identificatori casuali come gli UUID sono utili, ma non sono una correzione. Gli ID trapelano tramite URL, log, link condivisi e header referrer. Considerali non indovinabili solo nel senso che rallentano l'enumerazione, mai come il controllo di accesso.
Come testarlo
Il test degli IDOR è semplice e ripetitivo, e proprio per questo vale la pena automatizzarlo dopo averlo fatto a mano una volta. Parti da un inventario: elenca ogni route, resolver e job in background che accetta un identificatore, compresi gli ID nascosti nei corpi delle richieste, nelle query string e negli header.
- Crea due account, A e B, idealmente in due organizzazioni separate. Crea un record come A e annota il suo ID.
- Ripeti con la sessione di B ogni richiesta che fa riferimento a quell'ID: GET, PATCH, DELETE, download, export e qualsiasi query o mutation GraphQL che lo tocchi.
- Attenditi 404 per tutte. Qualsiasi 200, e qualsiasi 403 che confermi l'esistenza, è un problema da segnalare.
- Trasforma la verifica manuale in un test di integrazione per risorsa, così un nuovo handler privo di query delimitata fallisce in CI.
- Cerca con grep le letture basate solo sulla chiave primaria, come findUnique({ where: { id } }) o db.get(Model, id), e motiva ciascuna.
La code review individua ciò che sfugge ai test, perché il controllo mancante è visibile nel sorgente anche quando nessuno ha scritto un test per quella route. CodeAuditAgent legge un repository GitHub pubblico o uno snippet incollato e segnala le lacune nel controllo degli accessi con il CWE, la riga citata, uno scenario di exploit e una patch proposta: un modo rapido per ottenere una seconda passata su tutti gli handler in una volta sola.
Una breve checklist
- Ogni query che riceve un ID fornito dal client filtra anche per l'utente o il tenant di chi chiama.
- L'autorizzazione vive in helper condivisi o nel data layer, non in istruzioni if copiate e incollate.
- I casi sconosciuti vengono negati; le route pubbliche sono l'eccezione esplicita.
- Le operazioni di scrittura sono verificate con la stessa cura delle letture, comprese le route bulk e annidate.
- Esistono test con due account per ogni risorsa e girano in CI.