Volver al blog
GraphQLNode.jsNext.jsSeguridad de APIApolloLimitación de velocidadAutorizaciónOWASP Top 10

Seguridad de GraphQL para Node.js y Next.js: ataques, defensas y listas de verificación de producción

Introducción

GraphQL es un pilar del stack moderno de Node.js. Las startups lo adoran porque un solo endpoint sustituye a una docena de rutas REST, los clientes obtienen exactamente lo que necesitan y herramientas como Apollo Client y TanStack Query hacen que la integración con el frontend sea fluida. Combínalo con los Server Components de Next.js y tienes una potente capa de datos full-stack.

Pero la flexibilidad de GraphQL es también su mayor reto de seguridad. Una API REST tiene una superficie fija: conoces cada endpoint y cada forma de payload. La superficie de GraphQL es infinita: un atacante puede construir consultas arbitrarias, anidar resolvers 20 niveles de profundidad, solicitar el mismo campo bajo 500 alias y enumerar todos los tipos de tu esquema — todo a través de un único POST /graphql.

En esta guía aprenderás:

  • Los seis vectores de ataque GraphQL más críticos en aplicaciones Node.js/Next.js
  • Defensas de producción con ejemplos de código (Apollo Server, Yoga, GraphQL Armor)
  • Cómo probar la seguridad de tu GraphQL con herramientas de código abierto
  • Una lista de verificación de despliegue para un GraphQL seguro en producción

1. Abuso de introspección y fugas de esquema

La introspección es una funcionalidad central de GraphQL: los clientes la usan para generar consultas con seguridad de tipos y para alimentar GraphiQL/GraphQL Playground. Pero en producción, una consulta de introspección expuesta revela todo tu esquema, incluidos los tipos internos, los campos obsoletos y los nombres de las mutaciones.

El ataque

graphql
# Cualquiera puede enviar esto a tu endpoint /graphql
query __schema {
  __schema {
    types {
      name
      fields {
        name
        type { name kind }
      }
    }
  }
}

Un atacante descubre que existen tus mutaciones resetPassword, impersonateUser o adminDeleteAccount, y después las ataca individualmente.

La defensa

Apollo Server 4:

typescript
// src/lib/apollo.ts
import { ApolloServer } from "@apollo/server";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== "production",
});

Yoga (GraphQL Yoga):

typescript
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";

export const yoga = createYoga({
  schema,
  graphqlEndpoint: "/api/graphql",
  graphiql: process.env.NODE_ENV !== "production",
});

Ruta de API de Next.js con introspección condicional:

typescript
// src/app/api/graphql/route.ts
import { createYoga } from "graphql-yoga";
import { schema } from "@/lib/schema";

const { GET, POST } = createYoga({
  schema,
  graphqlEndpoint: "/api/graphql",
  graphiql: process.env.NODE_ENV === "development",
  // También: desactiva __schema a nivel de resolver para defensa en profundidad
});

export { GET, POST };

Defensa en profundidad: establece introspection: false en producción en tu servidor GraphQL Y bloquéala también a nivel de CDN/WAF. Algunos balanceadores de carga (Cloudflare, AWS WAF) tienen reglas específicas de GraphQL que pueden rechazar consultas que contengan __schema o __type.


2. Ataques de profundidad y complejidad de consultas

El vector de denegación de servicio (DoS) número 1 de GraphQL. Una sola consulta puede obligar a tu servidor a resolver miles de llamadas anidadas a la base de datos.

El ataque: consulta profundamente anidada

graphql
query DeepNest {
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author { name }
            }
          }
        }
      }
    }
  }
}

Una consulta trivial como esta, con 4-5 niveles de profundidad, puede desencadenar cadenas exponenciales de resolvers. Con alias, empeora:

graphql
query Bomb {
  a1: users { posts { title } }
  a2: users { posts { title } }
  # ... repite 499 veces más
  a500: users { posts { title } }
}

La defensa: GraphQL Armor

GraphQL Armor es la librería de defensa más probada en batalla para Node.js. Protege contra profundidad, alias, coste y más.

bash
npm install graphql-armor
typescript
// src/lib/envelop.ts
import { EnvelopArmorPlugin } from "@graphql-armet";

const armor = EnvelopArmorPlugin({
  // Profundidad máxima de consulta: bloquea cualquier cosa más profunda de 6 niveles
  maxDepth: {
    n: 6,
    onAccept: [],
    onReject: [],
  },
  // Máximo de alias: bloquea consultas con más de 15 alias de campo
  maxAliases: {
    n: 15,
  },
  // Máximo de tokens: bloquea consultas con más de 1000 tokens léxicos
  maxTokens: {
    n: 1000,
  },
});

// Aplica a Apollo Server
const server = new ApolloServer({
  schema,
  plugins: [armor],
});

Con Yoga (plugins integrados):

typescript
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";
import { useDepthLimit } from "@envelop/depth-limit";
import { useAliases } from "@envelop/aliases";

export const yoga = createYoga({
  schema,
  plugins: [
    useDepthLimit({ maxDepth: 6 }),
    useAliases({ maxAliases: 15 }),
  ],
});

Limitación de velocidad basada en coste (avanzado)

Para un control fino, calcula el coste de cada consulta asignando pesos a los campos:

typescript
// src/lib/cost-limit.ts
import { createCostLimitRule } from "graphql-cost-analysis";

const costLimitRule = createCostLimitRule({
  maxCost: 1000,
  defaultCost: 1,
  // Las mutaciones cuestan más
  mutationCost: 5,
  // Los campos de lista cuestan por elemento
  listCost: 1,
});

Esto detecta ataques que quedan por debajo de los límites de profundidad pero golpean repetidamente resolvers de listas costosos.


3. Ataques de batching (enumeración de recursos)

GraphQL soporta el batching: enviar varias consultas o mutaciones en una sola petición. El @apollo/client de Apollo lo usa de forma legítima. Pero el batching puede convertirse en un arma para forzar IDs de usuario por fuerza bruta sin activar los límites de velocidad por petición.

El ataque

graphql
[
  { "query": "{ user(id: 1) { email } }" },
  { "query": "{ user(id: 2) { email } }" },
  { "query": "{ user(id: 3) { email } }" },
  # ... 100 IDs de usuario más
]

Una petición HTTP, 100 consultas. Sin una limitación de velocidad consciente del batching, cada "petición" parece inocente, pero el atacante enumera 5.000 usuarios en 50 peticiones.

La defensa

Desactiva el batching de peticiones a menos que lo necesites:

typescript
// Apollo Server 4 desactiva el batching por defecto
// Cuando SÍ lo necesitas, aplica una limitación de velocidad consciente del batching:

// src/lib/batch-limiter.ts
let batchCounter = 0;
const BATCH_HARD_LIMIT = 10;

export function checkBatchLimit(body: unknown): void {
  if (Array.isArray(body)) {
    batchCounter += body.length;
    if (body.length > BATCH_HARD_LIMIT) {
      throw new Error("Batch size exceeds limit");
    }
  }
}

// src/middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(req: NextRequest) {
  // Algo de limitación de batch en el borde de la red
  if (req.nextUrl.pathname === "/api/graphql" && req.method === "POST") {
    const response = NextResponse.next();
    response.headers.set("X-GraphQL-Batch-Limit", "10");
    return response;
  }
  return NextResponse.next();
}

Mejor: usa operaciones persistidas. El registro de consultas persistidas de Apollo (o los Trusted Documents de GraphQL Yoga) convierte GraphQL en una lista de permitidos: solo se ejecutan las consultas pre-registradas:

typescript
// next.config.ts — con consultas persistidas
const nextConfig = {
  // ...
};

// En el servidor, rechaza las consultas que no estén en la lista persistida
// Ver: Apollo Persisted Queries o Trusted Documents de Yoga

Con operaciones persistidas, los ataques de batching son imposibles porque cada consulta debe estar pre-aprobada.


4. Evasiones de autorización a nivel de resolver

La vulnerabilidad GraphQL más común en sistemas de producción: una comprobación de autorización a nivel de consulta, pero no a nivel de resolver.

El patrón que falla

typescript
// src/app/api/graphql/route.ts
// MAL: comprobación de autenticación solo en el manejador de ruta
export async function POST(req: NextRequest) {
  const user = await authenticate(req);
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // Ejecuta la consulta GraphQL completa
  const result = await yoga.process(req);
  return NextResponse.json(result);
}

Esto comprueba que el usuario ha iniciado sesión — pero no comprueba qué puede hacer. Un atacante puede consultar igualmente adminDashboard { revenue } porque la comprobación de autenticación está en la capa HTTP, no en la capa de resolvers.

La solución: autorización por resolver

typescript
// src/graphql/resolvers/user.ts
interface Context {
  user: { id: string; role: "admin" | "user" } | null;
}

export const resolvers = {
  Query: {
    users: async (_: unknown, __: unknown, ctx: Context) => {
      // Comprueba la autorización AQUÍ, en el resolver
      if (!ctx.user || ctx.user.role !== "admin") {
        throw new GraphQLError("Forbidden", {
          extensions: { code: "FORBIDDEN" },
        });
      }
      return db.user.findMany();
    },

    userProfile: async (_: unknown, args: { id: string }, ctx: Context) => {
      // Los usuarios solo pueden ver su propio perfil (salvo los admin)
      if (ctx.user?.id !== args.id && ctx.user?.role !== "admin") {
        throw new GraphQLError("Forbidden", {
          extensions: { code: "FORBIDDEN" },
        });
      }
      return db.user.findUnique({ where: { id: args.id } });
    },
  },

  Mutation: {
    deleteUser: async (_: unknown, args: { id: string }, ctx: Context) => {
      if (!ctx.user || ctx.user.role !== "admin") {
        throw new GraphQLError("Forbidden", {
          extensions: { code: "FORBIDDEN" },
        });
      }
      return db.user.delete({ where: { id: args.id } });
    },
  },
};

Autorización con middleware (graphql-shield)

Para esquemas más grandes, usa graphql-shield para declarar las reglas de autorización de forma declarativa:

bash
npm install graphql-shield
typescript
// src/lib/shield.ts
import { shield, rule, and, or } from "graphql-shield";
import { ForbiddenError } from "apollo-server-errors";

const isAuthenticated = rule()(async (_parent, _args, ctx) => {
  return ctx.user !== null;
});

const isAdmin = rule()(async (_parent, _args, ctx) => {
  return ctx.user?.role === "admin";
});

const isOwnProfile = rule()(async (_parent, args, ctx) => {
  return ctx.user?.id === args.id;
});

export const permissions = shield({
  Query: {
    users: isAdmin,
    userProfile: isAuthenticated,
  },
  Mutation: {
    deleteUser: isAdmin,
    updateProfile: isAuthenticated,
    // ... cada mutación necesita una regla
  },
});

Aplícalo a tu servidor:

typescript
import { applyMiddleware } from "graphql-middleware";
import { permissions } from "@/lib/shield";

const schemaWithAuth = applyMiddleware(schema, permissions);

Regla de oro: cada resolver debe comprobar la autorización. No existe una autenticación "global" para GraphQL: el cliente construye caminos de recorrido arbitrarios, y la autorización debe comprobarse en cada rama.


5. Inyección mediante variables de GraphQL

Las variables de GraphQL reducen el riesgo de inyección en comparación con REST, pero no lo eliminan — especialmente con bases de datos NoSQL como MongoDB.

El ataque: inyección NoSQL

graphql
query Login($email: String!, $password: String!) {
  login(email: $email, password: $password) { token }
}

# Variables:
{ "email": "admin@example.com", "password": { "$ne": "" } }

Si tu resolver pasa $password directamente a MongoDB sin comprobar tipos, el operador $ne se salta por completo la verificación de contraseña.

La defensa: variables con tipos estrictos

typescript
// src/graphql/resolvers/auth.ts — VULNERABLE
export const resolvers = {
  Mutation: {
    login: async (_: unknown, args: { email: string; password: unknown }) => {
      // PELIGRO: args.password podría ser un objeto
      const user = await db.users.findOne({
        email: args.email,
        password: args.password, // <-- pasa el objeto a MongoDB
      });
      return user ? { token: signToken(user) } : null;
    },
  },
};
typescript
// src/graphql/resolvers/auth.ts — SEGURO
export const resolvers = {
  Mutation: {
    login: async (_: unknown, args: { email: unknown; password: unknown }) => {
      // Valida los tipos antes de usarlos
      if (typeof args.email !== "string" || typeof args.password !== "string") {
        throw new GraphQLError("Bad request", {
          extensions: { code: "BAD_USER_INPUT" },
        });
      }

      const user = await db.users.findOne({
        email: args.email,
        password: hash(args.password), // <-- aplica hash antes de consultar
      });
      return user ? { token: signToken(user) } : null;
    },
  },
};

Para endpoints REST que hacen de proxy de variables GraphQL, usa Zod:

typescript
// src/lib/validate-variables.ts
import { z } from "zod";

const loginVariablesSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export function validateVariables<T>(schema: z.ZodType<T>, variables: unknown): T {
  return schema.parse(variables);
}

6. Divulgación de información en los errores

Los mensajes de error detallados de GraphQL son estupendos para depurar — y estupendos para que los atacantes tracen el mapa de tu infraestructura.

El ataque

json
// Fuga en la respuesta de error
{
  "errors": [
    {
      "message": "Cannot read properties of null (reading 'email')",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user", "email"],
      "extensions": {
        "stacktrace": [
          "TypeError: Cannot read properties of null (reading 'email')",
          "    at resolveUser (/app/src/resolvers/user.js:42:17)",
          "    at /app/node_modules/@graphql-tools/delegate/esm/delegateToSchema.js:98:20"
        ]
      }
    }
  ]
}

El stacktrace revela:

  • Rutas de archivo internas (/app/src/resolvers/)
  • Versiones de módulos de Node (@graphql-tools/delegate)
  • Arquitectura del servidor (rutas Linux)

La defensa: redactar los stack traces en producción

typescript
// src/lib/apollo.ts
import { ApolloServer } from "@apollo/server";

const server = new ApolloServer({
  schema,
  formatError: (formattedError) => {
    // En producción, elimina todas las extensiones excepto los códigos seguros
    if (process.env.NODE_ENV === "production") {
      return {
        message: formattedError.message,
        extensions: formattedError.extensions?.code
          ? { code: formattedError.extensions.code }
          : undefined,
      };
    }
    return formattedError;
  },
});

Con Yoga:

typescript
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";

export const yoga = createYoga({
  schema,
  maskedErrors: process.env.NODE_ENV === "production",
  // Mapa de errores personalizado para mensajes seguros en producción
  errorFormatter: (error) => {
    if (process.env.NODE_ENV === "production") {
      return {
        message: "Internal server error",
        extensions: { code: "INTERNAL_ERROR" },
      };
    }
    return error;
  },
});

7. CSRF mediante mutaciones GET

GraphQL soporta mutaciones mediante peticiones GET — y algunos clientes hacen esto para el caché. Un atacante puede incrustar una mutación en una etiqueta <img>:

html
<img src="https://yourapp.com/graphql?query=mutation{logout{success}}" />

Si tu endpoint GraphQL acepta mutaciones GET y las cookies se usan para la autenticación, visitar cualquier página con esa imagen cierra la sesión del usuario.

La defensa

Desactiva las mutaciones en peticiones GET:

typescript
// src/lib/yoga.ts
import { createYoga } from "graphql-yoga";

export const yoga = createYoga({
  schema,
  // Permite solo consultas (no mutaciones) en GET
  batching: false,
});

// O en tu manejador de ruta:
// src/app/api/graphql/route.ts
export async function GET(req: NextRequest) {
  // Rechaza mutaciones en GET
  const url = new URL(req.url);
  const query = url.searchParams.get("query") ?? "";
  if (query.includes("mutation")) {
    return NextResponse.json({ error: "Mutations not allowed on GET" }, { status: 405 });
  }
  return yoga.fetch(req);
}

Mejor: sirve GraphQL exclusivamente mediante POST y rechaza GET por completo:

typescript
// src/app/api/graphql/route.ts
export { POST } from "@/lib/yoga";
// ⚠️ Sin export de GET — Next.js devuelve 405 para métodos no exportados

Probar la seguridad de tu GraphQL

Tres herramientas que ejecutar antes de cada despliegue:

1. GraphQL Map (Escape)

bash
npx @escape.tech/graphql-map https://your-graphql-endpoint.com/api/graphql

Genera un mapa de todo tu esquema — exactamente lo que vería un atacante. Si la introspección está desactivada, devuelve información mínima. Ejecútalo en producción para verificar que la introspección está realmente apagada.

2. InQL (PortSwigger)

Extensión de Burp Suite para pruebas de seguridad GraphQL. Registra consultas, detecta ataques de batching y prueba los límites de profundidad. Instálala desde el App Store de Burp o usa la versión independiente.

3. graphql-query-analyzer

bash
npx graphql-query-analyzer --depth 10 --complexity 500 yourschema.graphql

Escanea tu esquema en busca de resolvers costosos y recomienda límites de coste.


Lista de verificación de despliegue en producción

Antes de desplegar tu API GraphQL:

  • [ ] Introspección desactivadaintrospection: false en producción
  • [ ] Límite de profundidad de consultas — maxDepth ≤ 8 (se recomienda 6)
  • [ ] Límite de alias — maxAliases ≤ 15
  • [ ] Análisis de coste de consultas — maxCost aplicado a todas las operaciones
  • [ ] Autenticación a nivel de resolver — cada resolver tiene su propia comprobación de autorización
  • [ ] Sin stack traces en los erroresformatError elimina las extensiones en producción
  • [ ] Mutaciones GET desactivadas — endpoint solo POST para mutaciones
  • [ ] Tamaño de batch limitado — máximo 10 operaciones por batch (o desactiva el batching)
  • [ ] Limitación de velocidad — ventana deslizante por IP y por token (token bucket o leaky bucket)
  • [ ] Validación de entradas — Zod o similar para los tipos de variables GraphQL en la entrada de los resolvers
  • [ ] Operaciones persistidas consideradas — para aplicaciones de alta seguridad, usa la lista de permitidos de documentos
  • [ ] Registro + monitorización — cada consulta rechazada se registra con IP, nombre de operación y motivo del rechazo
  • [ ] npx @escape.tech/graphql-map pasa — sin campos de esquema inesperados expuestos

Conclusión

El poder de GraphQL proviene de su flexibilidad — pero esa misma flexibilidad exige una mentalidad de seguridad fundamentalmente distinta a la de REST. No puedes asegurar una API GraphQL bloqueando endpoints; debes asegurar cada resolver, limitar la complejidad de las consultas, controlar la introspección y validar las variables en el límite.

Las conclusiones clave para tu API GraphQL de Next.js + Node.js:

  1. La introspección es una herramienta de depuración, no una funcionalidad de producción — desactívala.
  2. Cada resolver es un límite de seguridad — autoriza allí, no en el manejador de ruta.
  3. Los atacantes pueden construir consultas increíblemente costosas — los límites de profundidad, alias y coste son obligatorios.
  4. Las variables de GraphQL no están saneadas — comprueba sus tipos antes de que lleguen a tu base de datos.
  5. Los mensajes de error son informes de inteligencia — redacta los stack traces en producción.

Empieza con GraphQL Armor (o los plugins integrados de Yoga) para las protecciones DoS, añade graphql-shield para la autorización declarativa y ejecuta graphql-map antes de cada despliegue para detectar fugas de esquema. Tus clientes de API te lo agradecerán — y tus atacantes no tendrán nada que explotar.

¿Necesitas una revisión profesional de tu implementación de GraphQL? Programa una auditoría de seguridad — probaremos los límites de profundidad, las reglas de autorización, los vectores de enumeración y los límites de coste de consultas.

JS Security Audit — Auditorías de seguridad profesionales de React, Next.js y Node.js.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.