Ir al contenido
CodeAuditAgent
Todos los artículos

IDOR y control de acceso roto: una guía práctica

Cómo el IDOR y el control de acceso roto se cuelan en REST, GraphQL y los route handlers de Next.js, cómo detectarlos y las correcciones que resisten.

· 7 min de lectura · Lina Source LLC

El control de acceso roto es la clase de bug que sobrevive a cada actualización de framework. Tu ORM escapa el SQL, tu motor de plantillas escapa el HTML, pero nada en el stack sabe que la factura 4812 es de Alice y no de Bob. Ese conocimiento vive en tu código y, cuando un handler se olvida de aplicarlo, cualquier usuario autenticado puede leer o modificar los datos de otra persona.

La forma más habitual es la referencia directa insegura a objetos, o IDOR: el cliente envía un identificador, el servidor carga el registro con ese identificador y nadie comprueba si quien llama tiene permiso para verlo. Explotarlo no requiere herramientas especiales. Bastan un navegador, una segunda cuenta y un número cambiado en la URL. Los escáneres que buscan llamadas a funciones peligrosas rara vez lo detectan, porque el código vulnerable no contiene nada peligroso: a una consulta a la base de datos perfectamente normal simplemente le falta una condición.

Los tres CWE que vas a ver

  • CWE-639, bypass de autorización mediante una clave controlada por el usuario: el IDOR clásico. El registro se selecciona por un ID que controla el atacante y nunca se comprueba la propiedad.
  • CWE-862, falta de autorización: el handler no realiza ninguna comprobación de autorización. Suele ser un endpoint de administración o interno que se daba por inalcanzable.
  • CWE-285, autorización incorrecta: la comprobación existe, pero está mal. Comprueba el campo equivocado, comprueba permiso de lectura en una escritura o confía en un rol enviado por el cliente.

La distinción importa a la hora de corregir el bug. Una comprobación ausente se resuelve añadiéndola; una comprobación incorrecta significa que el modelo de quién puede hacer qué está mal, y ese mismo error probablemente se repite en otros sitios. Cuando encuentres cualquiera de los dos casos, busca a sus hermanos antes de cerrar el ticket. Los bugs de control de acceso rara vez son casos aislados; siguen los patrones que un equipo copia de un handler a otro.

Cómo ocurre en un route handler de Next.js

Este es el patrón en su forma más común. El handler autentica al usuario, lo que da sensación de seguridad, y después carga el registro solo por su ID. La comprobación de sesión responde a quién está llamando; nada responde a si quien llama puede ver esta factura.

// app/api/invoices/[id]/route.ts  (vulnerable)
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;
  // Cualquier usuario autenticado puede leer cualquier factura cambiando el ID
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

La corrección consiste en hacer que la propiedad forme parte de la propia consulta, en lugar de ser un paso aparte que se puede olvidar. Si el registro no pertenece a quien llama, la base de datos no devuelve nada y el handler responde 404.

// app/api/invoices/[id]/route.ts  (corregido)
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);
}

Devolver 404 en lugar de 403 es deliberado. Un 403 confirma que el registro existe, lo que permite a un atacante enumerar ID válidos aunque no pueda leerlos. Lo mismo se aplica a los tiempos de respuesta y a los mensajes de error: la respuesta para el registro de otra persona debería ser indistinguible de la respuesta para un registro que nunca existió.

REST: los endpoints que se olvidan

Los equipos suelen proteger el GET por ID evidente. Los bugs se esconden en los demás verbos y en los bordes de la API:

  • Handlers PATCH y DELETE copiados del handler GET antes de que se añadiera la comprobación de propiedad.
  • Rutas anidadas como /projects/:projectId/tasks/:taskId, donde se comprueba el proyecto pero la tarea se carga solo por taskId y puede pertenecer a otro proyecto.
  • Endpoints masivos que aceptan un array de ID y solo comprueban el primero.
  • Descargas de archivos y trabajos de exportación, que a menudo pasan por un servicio aparte con sus propias comprobaciones, más débiles.
  • Payloads de actualización que aceptan ownerId, organizationId o role desde el cuerpo de la petición y los escriben directamente en la base de datos (mass assignment).

GraphQL amplía la superficie

En GraphQL, el mismo objeto se puede alcanzar por muchos caminos. Una comprobación a nivel de query en invoice(id) no sirve de nada si la misma factura también es accesible a través de customer { invoices }, de una búsqueda node(id) o del tipo de retorno de una mutación. Cada resolver que devuelve un objeto es un punto de entrada. Las capas de batching como DataLoader añaden otra trampa: un loader indexado solo por ID devolverá encantado registros para cualquier viewer, y su caché puede servir los datos de un usuario a una petición posterior si se comparte entre peticiones.

El enfoque fiable es autorizar en la capa de datos a la que llaman los resolvers, no en los resolvers mismos. Si todos los caminos hacia una factura pasan por una única función que recibe al viewer y acota la consulta, añadir un campo o una relación nueva no puede saltársela. Comprueba también las entradas de las mutaciones: un campo como ownerId en un input type es una invitación a reasignar registros. Por último, recuerda que la introspección y los mensajes de error revelan tu esquema, así que da por hecho que los atacantes conocen cada campo y cada relación que expones.

El mismo bug en Python

La forma es idéntica en FastAPI con SQLAlchemy. La versión vulnerable llama a db.get(Document, doc_id); la versión corregida filtra por propietario en la misma sentencia.

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),
):
    # Vulnerable: 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 doc

Correcciones que resisten

Acota cada consulta por propietario o por tenant

Pon el ID de usuario o de organización en la cláusula WHERE de cada lectura y de cada escritura. Eso convierte la autorización en una propiedad de la consulta, que es fácil de ver en una revisión. En aplicaciones multi-tenant, la row-level security de Postgres puede imponer la frontera entre tenants como una segunda capa, de modo que un filtro olvidado no devuelva nada en lugar de las filas de otro cliente. El mismo acotado se aplica a las escrituras. Una actualización debería ser una sola sentencia filtrada por ID y por propietario a la vez, como un updateMany con ambas condiciones seguido de una comprobación de que cambió exactamente una fila, en lugar de una lectura, una comprobación y una escritura aparte que pueden dar lugar a una condición de carrera.

Centraliza la decisión

Los if dispersos acaban divergiendo. Un pequeño conjunto de helpers, uno por recurso, mantiene la regla en un solo sitio y hace que un handler sin una llamada al helper destaque.

// 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 },
  });
  // Denegar por defecto: no tener membresía y no tener permiso se ven igual
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

Deniega por defecto

Los roles desconocidos, las membresías ausentes y las acciones inesperadas deberían acabar todos en una denegación. En frameworks con middleware, exige autenticación para todo y marca las rutas públicas de forma explícita, en lugar de al revés. Una ruta nueva debería estar bloqueada hasta que alguien decida lo contrario. Nunca tomes el rol, el tenant o el ID de usuario del cuerpo de la petición ni de una cabecera fijada por el cliente; derívalos de la sesión verificada en el servidor cada vez.

Los identificadores aleatorios como los UUID merecen la pena, pero no son una corrección. Los ID se filtran por URLs, logs, enlaces compartidos y cabeceras referrer. Considéralos inadivinables solo en el sentido de que ralentizan la enumeración, nunca como la comprobación de acceso.

Cómo probarlo

Probar el IDOR es sencillo y repetitivo, y por eso merece la pena automatizarlo una vez lo has hecho a mano. Empieza por un inventario: lista cada ruta, resolver y trabajo en segundo plano que acepte un identificador, incluidos los ID escondidos en cuerpos de petición, query strings y cabeceras.

  • Crea dos cuentas, A y B, idealmente en dos organizaciones distintas. Crea un registro con A y apunta su ID.
  • Repite cada petición que haga referencia a ese ID con la sesión de B: GET, PATCH, DELETE, descargas, exportaciones y cualquier query o mutación de GraphQL que lo toque.
  • Espera un 404 en todas. Cualquier 200, y cualquier 403 que confirme la existencia, es un hallazgo.
  • Convierte la comprobación manual en un test de integración por recurso, para que un handler nuevo sin una consulta acotada falle en CI.
  • Busca con grep las consultas por clave primaria a secas, como findUnique({ where: { id } }) o db.get(Model, id), y justifica cada una.

La revisión de código detecta lo que se les escapa a los tests, porque la comprobación ausente se ve en el código fuente aunque nadie haya escrito un test para esa ruta. CodeAuditAgent lee un repositorio público de GitHub o un fragmento pegado y reporta los huecos de control de acceso con el CWE, la línea citada, un escenario de explotación y un parche propuesto, que es una forma rápida de dar una segunda pasada a todos los handlers a la vez.

Una checklist breve

  • Toda consulta que reciba un ID proporcionado por el cliente filtra también por el usuario o el tenant de quien llama.
  • La autorización vive en helpers compartidos o en la capa de datos, no en if copiados y pegados.
  • Los casos desconocidos deniegan; las rutas públicas son la excepción explícita.
  • Las operaciones de escritura se comprueban con el mismo cuidado que las lecturas, incluidas las rutas masivas y anidadas.
  • Existen tests con dos cuentas para cada recurso y se ejecutan en CI.