La consulta GraphQL que agotó el pool de bases de datos
Un documento GraphQL anidado agotó una base de datos en producción. Por qué fallaron los límites de tasa, los tiempos de espera de HTTP y DataLoader — y los cuatro límites que finalmente lo contuvieron.
Un único POST de GraphQL dejó la API fuera de servicio durante casi una hora: y no fue por mala intención
Los problemas comenzaron justo después de las ocho en una noche de fin de semana.
La CPU de Postgres estaba al 100% de su capacidad. El pool ya no tenía conexiones libres. Los clientes experimentaron tiempos de espera excesivos en la pasarela de comunicación. La API pública estuvo completamente inaccesible durante la tarde del domingo, que es prácticamente el momento de mayor uso para ese producto.
El volumen de tráfico parecía normal, incluso un poco bajo para ese día, por lo que era poco probable que hubiera un aumento repentino.
Nada se había publicado desde mediados de semana, así que también era poco probable que hubiera habido un despliegue defectuoso.
Después de aproximadamente quince minutos de investigación, se encontró la causa, y a primera vista parecía absurda.
Solo una llamada HTTP. Una POST /graphql ya había invertido aproximadamente un minuto y medio en operaciones en la base de datos y seguía activa. Esa única operación ya había consumido más tiempo en la base de datos que las dos horas anteriores de tráfico normal.
A continuación se describe la estructura del documento, por qué los controles existentes no lo detectaron, y cuatro escenarios límite que se dieron unos días después. Al final se presenta el peor caso: cuán poco esfuerzo necesitaría un atacante malintencionado para aprovechar la misma vulnerabilidad.
La consulta
Estructuralmente precisa (aunque acortada), la estructura del documento se asemejaba a:
query {
organizations {
members {
user {
organizations {
members {
user {
organizations {
members {
user { id, email }
}
}
}
}
}
}
}
}
}
Había siete niveles de anidamiento en un ciclo: las organizaciones albergan miembros, los miembros apuntan a personas, y estas personas pertenecen nuevamente a organizaciones.
Esos bordes son reales y bidireccionales, por lo que modelarlos de esa manera es adecuado. Los resolvers funcionaron correctamente. Cada llamada SQL discreta se realizó sin problemas y de forma rápida por separado.
El problema surgió debido al crecimiento combinatorio.
Las cuentas típicas pertenecen a aproximadamente tres organizaciones. Las organizaciones típicas cuentan con unos cincuenta miembros. Al expandir esta estructura se obtienen los siguientes valores aproximados:
- profundidad 1 → ~3 organizaciones
- profundidad 2 → ~150 membresías
- profundidad 3 → ~150 usuarios
- profundidad 4 → ~450 organizaciones
- profundidad 5 → ~22.5k membresías
- profundidad 6 → ~22.5k usuarios
- profundidad 7 → ~67.5k organizaciones
Al final hay casi setenta mil elementos, cada uno generando más solicitudes para obtener información de membresías. La expansión seguía en aumento cuando los operadores detuvieron el proceso.
Se trataba de un documento de texto breve. No había vulnerabilidades de autenticación ni cadenas que pudieran ser inyectadas. Nada que un escáner pudiera detectar. El esquema simplemente seguía el grafo que publicaba.
Accidental, no malicioso
El origen es importante para la lección.
La llamada se realizó a través de una sesión autenticada de un empleado. Un ingeniero móvil estaba explorando el esquema en Apollo Studio mientras definía las necesidades de datos de la interfaz de usuario, abriendo campos anidados para ver qué existía.
Pulsaron ejecutar, vieron que la interfaz se detenía, culparon a la red y cerraron la pestaña del navegador.
Cerrar una pestaña no interrumpe los procesos en el servidor. El socket desapareció; la ejecución continuó; la base de datos siguió procesando decenas de miles de ramales sin que nadie esperara la respuesta.
Solo se enteraron de la interrupción a través del hilo de incidentes del lunes. No ocurrió nada hostil: utilizaron el explorador que la empresa les proporcionó, con el esquema que la empresa había entregado.
Por qué los sistemas de protección existentes no funcionaron
Había controles disponibles. Ninguno se correspondía con este modo de fallo, y ese desajuste es precisamente el problema.
Límites de solicitudes por IP. Estos límites contabilizan las llamadas HTTP por minuto. Una llamada es una sola llamada. El limitador la permitió correctamente.
Plazos de HTTP en el edge. Se activó un tiempo de espera de treinta segundos en el balanceador; el cliente recibió un error 504. El backend SQL siguió funcionando porque cerrar el socket no cancela las operaciones en ejecución. Se engañó a los clientes; el servidor siguió consumiendo recursos.
DataLoader. Los equipos suelen considerar el procesamiento por lotes como una medida de seguridad.
Dentro de un instante, DataLoader elimina las cargas duplicadas de entidades y soluciona realmente el problema del patrón N+1. En la capa de membresía más profunda, miles de búsquedas se consolidan en unas pocas instrucciones WHERE id IN (...).
Combinar algo más de veinte mil identificadores en una sola consulta no libera esas filas. Los viajes de ida y vuelta disminuyen; la cardinalidad no lo hace, y siguen existiendo niveles más profundos. El agrupamiento es un ajuste para mejorar la eficiencia, no un límite máximo. La eficiencia se ha confundido con un límite.
Inicio de sesión e identidad. El usuario que realizó la solicitud estaba conectado. La identidad responde a *quién*, nunca a *cuán costoso*.
Permisos por campo. Cada campo seleccionado estaba permitido para ese usuario. La autorización tuvo éxito. El problema radicaba en la cantidad de recorridos legítimos por el grafo, no en datos prohibidos.
Cuán feo se ve el mismo vacío bajo ataque
Después de la recuperación, se dedicó una tarde a modelar el uso hostil de esa misma vulnerabilidad. Ese modelo es la razón por la cual existe este texto.
Los alias permiten que un documento repita un campo con argumentos diferentes:
mutation {
a1: login(email: "target@company.com", password: "000001") { token }
a2: login(email: "target@company.com", password: "000002") { token }
a3: login(email: "target@company.com", password: "000003") { token }
# ... two thousand more
}
Sigue siendo una sola solicitud HTTP. Los límites de tasa se aplican a una sola solicitud. Un contador de bloqueo por inicio de sesión que se activaba después de cinco intentos fallidos estaba dentro del resolvedor y contabilizó correctamente miles de intentos.
Así, ese ataque de envío masivo de contraseñas habría sido detenido por casualidad: el contador estaba justo donde realmente ocurrían las operaciones.
El patrón general permaneció sin cambios. Cualquier resolvedor costoso podía ser usado cientos de veces dentro de una sola solicitud, algo que los límites de tasa ignoran: búsquedas, generación de informes, llamadas a terceros. Años de mecanismos de limitación basados en REST se enfrentaron a una API que no se comportaba como REST.
También se mantuvo activa la introspección en producción. Cualquiera podía obtener el gráfico completo de tipos, incluyendo todas las conexiones, y crear documentos con costos máximos sin necesidad de adivinar.
Nadie lo hizo. La suerte no constituye un control efectivo.
Cuatro límites implementados posteriormente
Tras unos días de trabajo técnico, se añadieron los siguientes elementos, ordenados por su impacto.
1. Límite de profundidad
Lo primero y más sencillo: rechazar los documentos anidados más allá de un techo fijo.
import depthLimit from 'graphql-depth-limit';
const server = new ApolloServer({
schema,
validationRules: [depthLimit(7)]
});
Se analizó el tráfico real de los clientes. Las operaciones más profundas y legítimas se detuvieron en cinco niveles. El techo se elevó a siete, lo que permite crecimiento y evita los casos problemáticos antes de que comiencen los resolvers.
Las reglas de validación inspeccionan el documento analizado antes de su ejecución, por lo que rechazarlo es prácticamente gratuito.
2. Análisis del costo de las consultas
La profundidad no es suficiente. Un documento superficial que solicita diez mil elementos de lista sigue siendo enorme.
La evaluación de costos pondera los campos, multiplica los valores por los argumentos de la lista y rechaza aquellos cuyo total supera el presupuesto establecido.
const server = new ApolloServer({
schema,
plugins: [
createComplexityPlugin({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 })
]
})
]
});
Conectar las partes es fácil; elegir las ponderaciones es el trabajo real. Los valores escalares cuestan uno. Las listas cuestan first veces el costo de cada elemento hijo. Los resolvers que acceden a terceros reciben ponderaciones manuales, como cincuenta.
Dos días de ajustes basados en registros de producción permitieron obtener cifras más o menos correctas. Ser más o menos correcto fue suficiente.
3. Límites de alias y número de nodos
Límite de aliases por operación y número total de nodos AST.
Cincuenta aliases más un techo máximo de nodos cubrían las necesidades reales; ningún cliente serio llegó a ese límite. El uso excesivo de aliases provoca errores de validación en lugar de resolver problemas a gran escala.
Las soluciones preempaquetadas son útiles. GraphQL Armor integra controles de profundidad, costo, aliases, directivas e introspección. Los equipos que empiezan desde cero deben instalarlo y ajustarlo antes de intentar crear cada componente por separado.
4. Tiempo de espera para consultas en la base de datos
La última línea de defensa, y también la solución más rápida:
ALTER ROLE api_user SET statement_timeout = '10s';
Las sentencias bajo el rol de la aplicación caducan después de diez segundos. No se trata del socket HTTP, sino del propio SQL. Solo eso habría reducido el tiempo de interrupción de unos 94 segundos de trabajo en la base de datos a diez, sin necesidad de conocimientos especializados en GraphQL.
La introspección en producción se desactivó mediante la configuración esa misma semana; era una práctica básica que se había omitido desde el principio.
Lecciones para una versión anterior del mismo equipo
Tres recordatorios.
Controlar el costo, no solo la cantidad de solicitudes. Los conteos por minuto en HTTP son una costumbre típica de REST. GraphQL puede ocultar trabajos arbitrarios en una sola solicitud POST. Si el medidor solo registra solicitudes, no existe un verdadero medidor del costo.
Los plazos límite deben detener el trabajo. Un tiempo de espera de treinta segundos en HTTP parecía protector, pero solo ocultaba los daños para quienes realizaban las llamadas mientras los servidores backend seguían funcionando. Establezca tiempos de espera donde se ejecuta el trabajo; en Postgres, utilice statement_timeout en el rol correspondiente.
DataLoader no limita el tamaño. El agrupamiento elimina el efecto N+1 y hace que las consultas más complejas sean más económicas por viaje de ida y vuelta. No las hace más pequeñas. La eficiencia y los límites máximos son problemas separados; debe implementarse ambos.
Lista de verificación para GraphQL en producción
Verifique estos puntos pronto. La mayoría toma solo unos minutos.
- ¿La introspección está desactivada en producción? De lo contrario, todo el esquema queda público.
- ¿Está establecido un límite de profundidad? Mida las consultas más profundas que se realizan; fije el límite ligeramente por encima de esos valores.
- ¿Está establecido un presupuesto de costos? La profundidad por sí sola no tiene en cuenta las listas extensas.
- ¿Está establecido un límite para los alias? A menudo se pasa por alto; evita patrones de generación excesiva de consultas.
- Rol en la base de datos: ¿está
statement_timeoutconfigurado? Indica los minutos de ejecución; abarca toda esta categoría de consultas.
Ese equipo comenzó con uno de los seis. Ahora utilizan los seis; cuatro llegaron en una tarde.
La regla
Tengan presente esta distinción:
Los puntos de extremo REST limitan el trabajo por diseño. GraphQL permite a los clientes establecer esos límites. A menos que el servidor restablezca un límite explícito, dicho límite no se ha movido: se ha eliminado.
Las defensas anteriores suponían que los servidores decidían cuán costosa podía ser una llamada. GraphQL deja esa decisión en manos de quien escribe el documento: algo poderoso, y una razón común para adoptarlo. La responsabilidad debe recuperarse en el código; el framework no lo hará.
Casi una hora de tiempo de inactividad comenzó cuando un colega presionó “ejecutar” en un estudio. Esa es la versión amigable. La versión hostil solo requería una cuenta y un breve pensamiento; nunca ocurrió simplemente porque nadie lo intentó.
Confirme primero la configuración de introspección. Tarda segundos, y muchos equipos ya conocen la respuesta.