IDOR y broken object-level authorization: repro y mitigación
Qué es un IDOR, cómo se reproduce cambiando identificadores y cómo se arregla de raíz.
Construyo APIs y también las rompo para probar que aguantan. La vulnerabilidad que más veces encuentro en revisiones es la más aburrida de nombrar y la más cara de ignorar: broken object-level authorization (BOLA), conocida clásicamente como IDOR. Es el número 1 del OWASP API Security Top 10. Este writeup es genérico y de laboratorio — nada de targets reales.
Qué es
Un IDOR ocurre cuando un endpoint identifica un objeto por un ID que viene del cliente y verifica autenticación pero no autorización sobre ese objeto. Estás logueado, sí — pero nadie comprueba que el recurso sea tuyo.
Escenario de laboratorio
Una API expone facturas por ID incremental:
GET /api/invoices/1001
Authorization: Bearer <token-de-usuario-A>Devuelve la factura 1001. El usuario A cambia el ID a 1002 y recibe la factura de otra cuenta. La sesión es válida; la autorización, inexistente.
Reproducción
El patrón es enumerar el identificador. En un lab autorizado:
# Solo en entornos autorizados / CTF. Enumerar un ID numérico ya autenticado.
ffuf -w ids.txt \
-u https://lab.example/api/invoices/FUZZ \
-H "Authorization: Bearer $TOKEN" \
-mc 200 -fr '"error"'Cada 200 con cuerpo de factura ajena es un hallazgo. IDs secuenciales lo hacen trivial; y usar UUIDs no lo arregla — solo sube el costo de enumerar. Un UUID filtrado en una URL, un log o un Referer sigue siendo directamente accesible.
Causa raíz
El servidor confía en el ID del cliente como si fuera una prueba de propiedad. La consulta es "traé el objeto con este ID", cuando debería ser "traé el objeto con este ID que pertenezca a quien llama".
Mitigación de raíz
Scopear la consulta al dueño y denegar por defecto:
// Vulnerable: autenticado, pero sin autorización a nivel de objeto
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findById(req.params.id);
res.json(invoice); // devuelve cualquier factura
});
// Corregido: la consulta se limita al que llama
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: req.params.id, ownerId: req.user.id },
});
if (!invoice) return res.sendStatus(404); // 404, no 403 — no filtres existencia
res.json(invoice);
});Checklist de defensa:
- Autoriza sobre el objeto, no solo autentica la sesión.
- Scopea toda consulta por dueño/tenant en la capa de datos.
- 404, no 403, para no revelar qué IDs existen.
- No confíes en IDs del cliente como prueba de acceso; los UUIDs mitigan, no autorizan.
- Loguea accesos denegados para detectar enumeración.
La regla es simple y aplica a construir tanto como a auditar: la identidad responde quién sos; la autorización responde a qué tenés derecho. Nunca confundas una con la otra.