A01 Broken Access Control
La autenticación dice quién eres; el control de acceso dice qué puedes hacer. Cuando el servidor olvida verificar en cada solicitud, cambiar un id en una URL es todo lo que necesita un atacante.
Qué es
El control de acceso aplica la política para que los usuarios no actúen fuera de sus permisos previstos. Se rompe cuando el servidor confía en algo que controla el cliente: un id en la URL, un campo oculto, un claim de rol, una cookie. El resultado es leer datos de otras personas, modificarlos o llegar a funciones de administración.
El :2025 lo lista como A01. Las dos formas cotidianas son (insecure direct object reference, también llamado en seguridad de APIs): la solicitud nombra un objeto y el servidor lo devuelve sin preguntar si quien llama es su dueño; y la falta de verificaciones a nivel de función: existe un endpoint para administradores, pero nada impide que un usuario normal lo llame.
Cómo funciona el ataque
El atacante inicia sesión como usuario normal, abre la pestaña de red del navegador y encuentra una solicitud como GET /api/invoices/1041. Cambia 1041 por 1042 y lee la factura de otra persona. Si los ids son secuenciales, un script de diez líneas recorre toda la tabla.
- Cambia un id en el path, en el query string, en el cuerpo JSON o en un header, y observa si la respuesta cambia de dueño.
- Llama directamente a una ruta de admin o interna (/admin/users, DELETE /api/users/7) con una sesión normal.
- Envía campos adicionales que el formulario nunca muestra ("role": "admin"): el deja que el cliente defina lo que no debería.
- Ocultar el botón en la UI: la UI no es un control, el servidor sí.
Código vulnerable
Cada handler verifica que el usuario haya iniciado sesión y luego carga el objeto solo por id. Nada vincula la factura con quien llama.
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoice.findUnique({ where: { id: Number(req.params.id) } });
res.json(invoice); // cualquier usuario autenticado puede leer cualquier factura
});@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id) {
return repo.findById(id).orElseThrow(NotFoundException::new);
}La corrección
Pon la propiedad en la propia consulta, de modo que una fila que no es tuya simplemente no se encuentre. Responde igual para "no existe" y "no es tuya", para que la respuesta no confirme qué ids son válidos. La regla es la misma en cualquier lenguaje: autoriza en el servidor, en cada solicitud, denegando por defecto.
app.get("/api/invoices/:id", requireLogin, async (req, res) => {
const invoice = await db.invoice.findFirst({
where: { id: Number(req.params.id), ownerId: req.user.id },
});
if (!invoice) return res.status(404).end();
res.json(invoice);
});@app.get("/api/invoices/<int:invoice_id>")
@login_required
def get_invoice(invoice_id):
invoice = Invoice.query.filter_by(
id=invoice_id, owner_id=current_user.id
).first_or_404()
return jsonify(invoice.to_dict())@GetMapping("/api/invoices/{id}")
public Invoice get(@PathVariable long id, @AuthenticationPrincipal User user) {
return repo.findByIdAndOwnerId(id, user.getId())
.orElseThrow(NotFoundException::new);
}const requireRole = (role) => (req, res, next) =>
req.user?.roles?.includes(role) ? next() : res.status(403).end();
app.delete("/api/users/:id", requireLogin, requireRole("admin"), deleteUser);Demuestra la corrección
Una prueba de regresión usa dos usuarios. El usuario A crea una factura; el usuario B la solicita y debe recibir 404 (o 403), y una llamada anónima debe recibir 401. Ejecútala en el CI para que un refactor no elimine la verificación sin que nadie lo note. La misma forma de prueba funciona en cualquier lenguaje y para cualquier tipo de objeto.
def test_user_cannot_read_other_users_invoice(client, alice, bob):
invoice_id = client.post("/api/invoices", json={"total": 10}, auth=alice).json()["id"]
assert client.get(f"/api/invoices/{invoice_id}", auth=alice).status_code == 200
assert client.get(f"/api/invoices/{invoice_id}", auth=bob).status_code == 404
assert client.get(f"/api/invoices/{invoice_id}").status_code == 401test("bob cannot read alice's invoice", async () => {
const { body } = await request(app).post("/api/invoices").set(alice).send({ total: 10 });
await request(app).get(`/api/invoices/${body.id}`).set(bob).expect(404);
await request(app).get(`/api/invoices/${body.id}`).expect(401);
});Checklist
- : una ruta sin regla explícita está prohibida, no abierta.
- Aplica la propiedad en un solo lugar compartido (un helper de consultas, un policy engine), no copiando verificaciones en cada handler.
- No confíes en ids, roles ni precios que vienen del cliente. Deriva la identidad de la sesión.
- Usa ids impredecibles cuando sirva (UUIDs), pero nunca como único control: un id imposible de adivinar no es autorización.
- Registra los fallos de control de acceso y alerta ante ráfagas: alguien enumerando ids es visible.
- Prueba con dos cuentas en cada release. Los scanners automáticos rara vez entienden quién debería ver qué.
conexiones en el mapa
- A06 Insecure Designdefine las reglas deA01 Broken Access Control
- A01 Broken Access Controlnecesita una identidad confiable deA07 Authentication Failures
- A05 Injectionpuede eludirA01 Broken Access Control
- A09 Security Logging and Alerting Failuresdetecta el abuso deA01 Broken Access Control
actualizado el 14/10/2026 · fuentes para profundizar · reportar un error
en otros dominios
- Pruebas de seguridad de web y APIoffseccómo encontrar lo que el otro enseña a corregir
- IAM - identidad y accesocloudsecla misma regla de mínimo privilegio, en la nube
Desbloquea
pon a prueba lo que entendiste
Cargando preguntas…