Vulnerabilidades de BOLA en APIs de GraphQL: la amenaza silenciosa
Vulnerabilidades de BOLA en APIs de GraphQL: la amenaza silenciosa
La autorización rota a nivel de objeto (BOLA, Broken Object Level Authorization) sigue siendo la vulnerabilidad más crítica que afecta a las aplicaciones web modernas. Cuando migramos a arquitecturas basadas en grafos, la superficie de ataque no solo cambia, sino que se expande de formas impredecibles. Este artículo explora las vulnerabilidades de BOLA en APIs de GraphQL, analizando exactamente por qué las mentalidades de autorización tradicionales centradas en REST fallan catastróficamente cuando se aplican a grafos. Desglosaremos el problema, analizaremos por qué es difícil de resolver, propondremos una arquitectura resiliente, proporcionaremos una implementación concreta y discutiremos los obstáculos con los que inevitablemente se encontrará.
Si está tratando a GraphQL como un endpoint REST sofisticado, su API ya es vulnerable. Es hora de corregir su capa de autorización.
El problema: la autorización a nivel de objeto está rota
en una arquitectura REST tradicional, la autorización es relativamente sencilla porque la topología del endpoint se mapea limpiamente con la topología de los datos. Usted solicita GET /api/users/123/financials. El enrutador intercepta la solicitud, ejecuta un middleware que verifica si el usuario autenticado tiene permiso para acceder a las finanzas del usuario 123 y permite o rechaza la solicitud. Los límites son fijos, están bien definidos y se pueden inspeccionar fácilmente.
GraphQL destruye esta simplicidad.
Con GraphQL, el cliente dicta la estructura de la respuesta. El endpoint es siempre /graphql y el cuerpo de la solicitud contiene una consulta (query) que puede atravesar múltiples entidades, relaciones y niveles de profundidad. Una sola consulta puede acceder al perfil de un usuario, a sus amigos, a las publicaciones de esos amigos y a los comentarios de esas publicaciones.
query BOLAExploit {
user(id: "current-user-id") {
name
organization {
id
members(first: 100) {
id
email
salary # Campo vulnerable
}
}
}
}
En el ejemplo anterior, el atacante comienza en un objeto que controla (user), navega hacia un objeto compartido (organization) y luego solicita un campo sensible (salary) de todos los miembros de esa organización. Si la lógica de autorización solo verifica si el usuario pertenece a la organización pero no verifica si el usuario tiene permitido leer el campo salary de otros miembros, existe una vulnerabilidad de BOLA.
El problema principal es que los resolvers de GraphQL se ejecutan de manera independiente. El resolver de salary sabe que debe devolver un float para un usuario específico, pero a menudo carece del contexto de quién está haciendo la pregunta y de cómo llegó a ese nodo. Esta pérdida de contexto es el caldo de cultivo ideal para BOLA.
Por qué las vulnerabilidades de BOLA en APIs de GraphQL son difíciles de detectar
Las vulnerabilidades de BOLA en APIs de GraphQL son notoriamente difíciles de identificar mediante escaneo automatizado o revisión de código convencional. A continuación se explica por qué:
1. Profundidad y recorrido del grafo
Los atacantes no se limitan a consultar los nodos raíz; recorren el grafo para eludir las comprobaciones del nivel raíz. Una consulta raíz para users(id: "123") puede estar protegida, pero ¿qué pasa con post(id: "456") { author { id email } }? Si el resolver de author no realiza exactamente las mismas comprobaciones de autorización que la consulta de user de la raíz, los datos quedan expuestos. El grafo está interconectado, lo que significa que existen docenas de rutas para llegar al mismo nodo de datos. Asegurar una sola ruta es inútil si las demás permanecen abiertas.
2. Fragmentación del contexto
En muchos frameworks (como Apollo Server o Yoga), los resolvers son funciones puras que reciben un parent, args, context e info. Los desarrolladores colocan con frecuencia la lógica de autorización en la configuración del context (por ejemplo, extrayendo el ID de usuario de un JWT), pero no pasan suficiente contexto de negocio a los resolvers a nivel de campo. Para cuando la ejecución llega a un campo profundamente anidado, el resolver solo dispone del objeto padre y del ID de usuario. Carece de las complejas reglas de negocio necesarias para tomar una decisión de autorización precisa.
3. El comportamiento por defecto de "Fallo Abierto" (Fail-Open)
Los frameworks de GraphQL, por defecto, ejecutarán cualquier resolver si el esquema lo permite. A menos que escriba código explícitamente para denegar el acceso, la solicitud se permite. Este paradigma de "fallo abierto" es intrínsecamente peligroso. Los sistemas seguros deben configurarse para un "fallo cerrado" (fail-closed). Si falta una política de autorización, la solicitud debe ser denegada.
4. Sobrecarga de datos (Over-fetching) y enmascaramiento
En REST, un atacante solicita un endpoint específico, haciendo que su intención sea obvia en los logs. En GraphQL, un atacante puede enterrar un exploit de BOLA en lo más profundo de una consulta de apariencia legítima que recupera docenas de campos seguros. El payload malicioso queda enmascarado por el ruido, lo que hace que los sistemas de detección de intrusos (IDS) y los firewalls de aplicaciones web (WAF) sean completamente ciegos ante el ataque.
Soluciones arquitectónicas para la autorización en GraphQL
Para eliminar BOLA, debemos alejarnos de las comprobaciones de autorización ad-hoc dispersas en nuestros resolvers. Necesitamos una arquitectura de autorización centralizada, determinista y de fallo cerrado (fail-closed).
El enfoque del motor de políticas (Policy Engine)
La arquitectura más resiliente desacopla por completo la lógica de autorización de los resolvers de GraphQL. Los resolvers no deben saber cómo evaluar los permisos; solo deben saber a quién preguntar. Esto significa introducir un motor de políticas (Policy Engine) dedicado.
El Policy Engine actúa como la única fuente de verdad para todas las decisiones de autorización. Toma tres entradas:
- El Actor: El usuario autenticado (por ejemplo, ID de usuario, roles, atributos).
- La Acción: La operación que se realiza (por ejemplo,
read,update,delete). - El Recurso: El objeto específico al que se accede (por ejemplo, ID del documento, ID de la organización).
Cuando un resolver necesita devolver datos, consulta al Policy Engine: "¿Puede el Actor X realizar la Acción Y sobre el Recurso Z?"
Control de acceso basado en atributos (ABAC)
El control de acceso basado en roles (RBAC) es insuficiente para prevenir BOLA. BOLA se centra fundamentalmente en el acceso a nivel de objeto. Saber que un usuario es un "Admin" es inútil si está intentando acceder a un documento en un tenant diferente.
Debemos utilizar el control de acceso basado en atributos (ABAC). ABAC evalúa las políticas basándose en los atributos del actor, del recurso y del entorno.
Por ejemplo, una política podría dictar: "Un usuario puede leer un documento SI el organizationId del usuario coincide con el organizationId del documento Y el documento está marcado como published."
Directivas de esquema (Schema Directives)
Para aplicar las políticas de forma limpia, podemos utilizar directivas de esquema de GraphQL. Las directivas nos permiten adjuntar metadatos declarativos a nuestro esquema. Podemos definir una directiva @auth que especifique los permisos requeridos para un campo u objeto.
directive @auth(action: String!) on FIELD_DEFINITION | OBJECT
type User @auth(action: "read:user") {
id: ID!
email: String! @auth(action: "read:user_email")
salary: Float @auth(action: "read:user_salary")
}
Este enfoque declarativo garantiza que las reglas de autorización sean visibles, estén documentadas y se apliquen consistentemente antes de que el resolver se ejecute.
Implementación: escribiendo resolvers seguros
Construyamos una implementación segura utilizando Node.js, Apollo Server v4 y un motor de políticas ABAC personalizado.
Paso 1: El motor de políticas
Primero, definimos un motor de políticas simple que evalúa reglas basándose en el usuario y el recurso.
// policyEngine.ts
type User = { id: string; role: string; organizationId: string };
type Resource = { __typename: string; id: string; organizationId?: string; ownerId?: string };
export class PolicyEngine {
can(user: User, action: string, resource: Resource): boolean {
if (user.role === 'SUPERADMIN') return true;
switch (resource.__typename) {
case 'Document':
return this.checkDocumentAccess(user, action, resource);
case 'User':
return this.checkUserAccess(user, action, resource);
default:
return false; // Fallo cerrado (fail-closed)
}
}
private checkDocumentAccess(user: User, action: string, doc: Resource): boolean {
if (action === 'read') {
return user.organizationId === doc.organizationId;
}
if (action === 'update' || action === 'delete') {
return user.id === doc.ownerId;
}
return false;
}
private checkUserAccess(user: User, action: string, targetUser: Resource): boolean {
if (action === 'read:salary') {
// Los usuarios solo pueden leer su propio salario, a menos que sean de HR
return user.id === targetUser.id || user.role === 'HR';
}
if (action === 'read') {
return user.organizationId === targetUser.organizationId;
}
return false;
}
}
Paso 2: Inicialización del contexto
Instanciamos el motor de políticas y lo inyectamos en el contexto de GraphQL junto al usuario autenticado.
// context.ts
import { PolicyEngine } from './policyEngine';
export interface GraphQLContext {
user: User;
policyEngine: PolicyEngine;
}
export const context = async ({ req }): Promise<GraphQLContext> => {
const user = authenticateRequest(req); // por ejemplo, verificar JWT
return {
user,
policyEngine: new PolicyEngine(),
};
};
Paso 3: Resolvers seguros
Ahora, dentro de nuestros resolvers, utilizamos el motor de políticas para aplicar la autorización a nivel de objeto antes de devolver los datos.
// resolvers.ts
export const resolvers = {
Query: {
document: async (_, { id }, context: GraphQLContext) => {
const doc = await database.getDocument(id);
if (!doc) return null;
// Comprobación de autorización a nivel de objeto
if (!context.policyEngine.can(context.user, 'read', { __typename: 'Document', ...doc })) {
throw new Error('Unauthorized');
}
return doc;
},
},
User: {
salary: (parent, _, context: GraphQLContext) => {
// parent es el objeto User que se está resolviendo
if (!context.policyEngine.can(context.user, 'read:salary', { __typename: 'User', ...parent })) {
throw new Error('Unauthorized to view salary');
}
return parent.salary;
}
}
};
Al estructurar la lógica de autorización en un motor de políticas dedicado y comprobar el acceso explícitamente después de recuperar el objeto pero antes de devolverlo, eliminamos la vulnerabilidad de BOLA.
Errores comunes y casos límite
Incluso con una arquitectura sólida, existen errores que debe evitar.
1. El problema de la autorización N+1
Si consulta una lista de 100 documentos y su resolver comprueba la autorización de cada documento de forma individual, podría desencadenar 100 consultas a la base de datos solo para recuperar los atributos necesarios para el motor de políticas. Este es el problema N+1 aplicado a la autorización.
Solución: Utilice DataLoaders para procesar por lotes (batching) la recuperación de metadatos de autorización. Aún mejor, traslade los filtros de autorización a la capa de la base de datos. En lugar de recuperar 100 documentos y filtrarlos en Node.js, modifique la consulta SQL para devolver únicamente los documentos que el usuario tiene permitido ver: SELECT * FROM documents WHERE organization_id = ?.
2. Filtración de datos a través de errores
Cuando un usuario intenta acceder a un recurso al que no debería, devolver un error como Unauthorized to access document 123 confirma que el documento 123 existe. Esto constituye una filtración de información.
Solución: Si un atacante intenta acceder a un objeto para el cual no tiene visibilidad, la API debe devolver un error genérico Not Found (no encontrado) o null, exactamente igual que si el objeto no existiera. Solo devuelva Unauthorized si el usuario sabe que el objeto existe pero carece de la autorización específica para realizar la acción.
3. Ignorar las Mutaciones
Los desarrolladores a menudo protegen rigurosamente sus consultas (Queries) pero descuidan las mutaciones (Mutations). Una vulnerabilidad de BOLA en una mutación updateUser es posiblemente más peligrosa que una en una consulta user.
Solución: Las mutaciones requieren las mismas comprobaciones ABAC, o incluso más estrictas. Verifique que el usuario tiene permiso para modificar el objeto específico antes de ejecutar la escritura en la base de datos.
4. Asunciones del estado en el lado del cliente
Nunca confíe en que el cliente proporcione el contexto de autorización. Si su mutación acepta input: { id: "123", organizationId: "456", newData: "..." }, no puede asumir que el usuario pertenece realmente a la organización con ID "456". Debe recuperar el objeto de la base de datos y verificar su estado real contra el token autenticado del usuario.
El resultado: una capa de GraphQL reforzada
Resolver las vulnerabilidades de BOLA en APIs de GraphQL no es una característica que se pueda añadir al final de un sprint. Requiere un cambio fundamental en cómo diseña su aplicación. Al abandonar las comprobaciones ad-hoc en los resolvers en favor de un Policy Engine centralizado, adoptar ABAC y forzar un paradigma de fallo cerrado, transforma su API de GraphQL de una superficie de ataque masiva en una interfaz robusta y predecible.
Deje de confiar en la seguridad por oscuridad. Deje de confiar en el cliente. Asuma que cada consulta es hostil, evalúe la ejecución de cada nodo contra políticas estrictas y construya una API que se defienda por sí misma. El costo de implementar una autorización robusta a nivel de objeto es alto, pero el costo de una brecha por BOLA es catastrófico. Escriba resolvers seguros.
Servicio de Seven Labs
Pruebas de Penetración VAPT y Ciberseguridad
