Broken Access Control - IDOR
Cómo detectar y prevenir vulnerabilidades IDOR mediante controles de autorización en el servidor.
1. El problema
1.1. ¿Qué es IDOR?
IDOR (Insecure Direct Object Reference) es una vulnerabilidad que permite a un usuario acceder, modificar o eliminar recursos sobre los que no tiene autorización con solo manipular un identificador que envía a la aplicación. Es una forma concreta de Broken Access Control, la categoría de OWASP Top 10, y en el mundo de las APIs se conoce también como BOLA (Broken Object Level Authorization).
Imaginemos una plataforma en la que un usuario autenticado consulta sus facturas desde una vista que llama a este endpoint:
GET /api/invoices/1001
El servidor devuelve su factura. Todo correcto, hasta que el usuario cambia el identificador:
GET /api/invoices/1002
Si la factura 1002 pertenece a otra persona y el servidor la devuelve igualmente, tenemos un IDOR. Ocurre porque el servidor no comprueba los permisos sobre el recurso solicitado.
Y aquí está lo que más confunde: estar autenticado no es lo mismo que estar autorizado. Una aplicación puede tener una autenticación impecable con JWT, OAuth 2.0 o sesiones seguras y ser igualmente vulnerable.
1.2. En código
Vamos a construir un ejemplo sencillo con FastAPI. Tenemos una base de datos simulada con dos facturas:
from fastapi import FastAPI, HTTPException
app = FastAPI()
invoices = {
1001: {
"id": 1001,
"owner_id": 1,
"amount": 150
},
1002: {
"id": 1002,
"owner_id": 2,
"amount": 900
}
}
# Suponemos que un middleware o una dependencia
# anterior ya ha autenticado al usuario.
# El problema es que aquí no comprobamos
# si puede acceder a esta factura.
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int):
invoice = invoices.get(invoice_id) # <-- ojo cuidao, solo atendemos al id
if invoice is None:
raise HTTPException(
status_code=404,
detail="Invoice not found"
)
return invoice
El problema está en esta línea:
invoice = invoices.get(invoice_id)
Solo miramos el identificador. Nadie pregunta de quién es la factura.
1.3. Consecuencias
Las consecuencias dependen de cada endpoint, pero lo habitual es una escalada horizontal de privilegios: un usuario accede a recursos de otro usuario del mismo nivel. A partir de ahí, las liadas pueden ser de todo tipo: leer información privada, modificar direcciones, perfiles o documentos ajenos, borrar lo que no le pertenece. Y como los identificadores suelen ser secuenciales, un atacante puede recorrerlos uno a uno y descargarse la tabla entera.
La otra posibilidad es la escalada vertical: ejecutar acciones reservadas a un rol superior, como que una cuenta normal invoque una operación de administración.
1.4. Cómo detectarlo
Lo primero es localizar los endpoints que reciben identificadores de recursos:
GET /api/users/42
GET /api/orders/875
PUT /api/documents/31
DELETE /api/invoices/1002
POST /api/projects/8/members
Ojo, que el identificador no siempre va en la ruta: también puede viajar en la query string, en el cuerpo de la petición o en una cabecera.
Después, sustituye ese identificador por el de un recurso que pertenezca a otro usuario y mira qué pasa. En un entorno controlado, la prueba más fiable es crear dos usuarios con sus recursos, autenticarse como uno y pedir los del otro, tanto en lectura como en modificación y borrado. Herramientas como Burp Suite (con extensiones como Autorize) ayudan a repetir las peticiones de una cuenta con las credenciales de otra.
Una lista inicial de comprobaciones:
- Con la cuenta A, localizar los identificadores de sus recursos.
- Con la cuenta B, intentar acceder a esos mismos recursos.
- Repetir la prueba con cada operación disponible:
GET,POST,PUT,PATCHyDELETE, según corresponda. - Probar rutas administrativas directamente, con una cuenta de privilegios bajos.
- Confirmar que el servidor deniega la operación y que no filtra datos en la respuesta ni en los registros accesibles al usuario.
1.5. Errores frecuentes
Los más habituales son confiar en el frontend, que puede modificarse o saltarse por completo; comprobar solo que existe una sesión, cuando estar autenticado no da permiso sobre todos los recursos; y proteger únicamente la lectura, olvidando que las rutas de actualización y borrado pueden tener el mismo fallo.
Otros son más sutiles. Aceptar el propietario desde la petición: un owner_id enviado por el usuario también se puede manipular, y la identidad debe salir del contexto autenticado del servidor. Creer que un UUID te salva: que un identificador sea difícil de adivinar no significa que el acceso esté autorizado, solo que cuesta más descubrirlo (y los UUID pueden acabar en logs, correos o enlaces compartidos). Y fiarse de los roles sin mirar el recurso: dos usuarios con el mismo rol pueden tener derecho a conjuntos de datos distintos.
2. La solución
La autorización se comprueba en el servidor, cada vez que se accede a un recurso. No basta con ocultar el enlace en la interfaz ni con confiar en que nadie cambie el número de la URL.
Para completar el ejemplo, añadimos una dependencia que representa al usuario autenticado. En una aplicación real validaría la sesión o el token y sacaría de ahí la identidad; nunca aceptaría un user_id enviado libremente por el cliente como prueba de quién es.
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
invoices = {
1001: {"id": 1001, "owner_id": 1, "amount": 150},
1002: {"id": 1002, "owner_id": 2, "amount": 900},
}
class User:
def __init__(self, user_id: int):
self.id = user_id
def get_current_user() -> User:
# Ejemplo didáctico: en producción se validaría una sesión o un token.
return User(user_id=1)
@app.get("/api/invoices/{invoice_id}")
def get_invoice(
invoice_id: int,
user: User = Depends(get_current_user),
):
invoice = invoices.get(invoice_id)
if invoice is None:
raise HTTPException(status_code=404, detail="Invoice not found")
if invoice["owner_id"] != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return invoice
Ahora, si el usuario 1 pide /api/invoices/1001, recibe su factura. Si cambia a /api/invoices/1002, el servidor la encuentra, pero deniega el acceso porque pertenece a otra persona.
Cuando hay base de datos, la opción habitual es meter la identidad del usuario en la propia consulta (esquema ilustrativo, sin los imports ni la sesión):
from sqlalchemy import select
stmt = select(Invoice).where(
Invoice.id == invoice_id,
Invoice.owner_id == current_user.id
)
invoice = db.execute(stmt).scalar_one_or_none()
La consulta pide una factura que tenga el identificador solicitado y pertenezca al usuario autenticado, así que la aplicación nunca llega a leer facturas ajenas. Si no hay resultado, devolvemos el mismo 404 que si la factura no existiera. Eso evita confirmar que existe una factura ajena, algo que con la comprobación explícita del primer ejemplo (403) tendrías que decidir tú según la política de la aplicación. En sistemas con permisos más complejos, la consulta debe incorporar también esas reglas.
Dos consejos prácticos. El primero: centraliza la autorización (una dependencia, una política, una capa de servicio) en lugar de repetir un if en cada endpoint, porque el endpoint al que se le olvide será tu IDOR. El segundo: escribe un test que pida el recurso de otro usuario y compruebe que falla, así la corrección no se rompe en una refactorización:
from fastapi.testclient import TestClient
client = TestClient(app)
def test_user_cannot_read_other_users_invoice():
app.dependency_overrides[get_current_user] = lambda: User(user_id=2)
response = client.get("/api/invoices/1001")
assert response.status_code == 403
app.dependency_overrides.clear()
La clave de IDOR: conocer el identificador de un recurso no significa tener permiso para acceder a él. El servidor debe comprobar la autorización en cada operación, diga lo que diga el frontend.
Para saber más
OWASP. Insecure Direct Object Reference Prevention Cheat Sheet. Leer.
PortSwigger. Testing for IDORs. Leer.