GraphQL Security: Introspection, Query Depth, Batching e Injection na Prática Já leu

Introdução ao GraphQL Security GraphQL revolucionou a forma como consumimos APIs, oferecendo flexibilidade e eficiência incomparáveis ao REST. Porém, essa liberdade traz consigo responsabilidades de segurança que muitos desenvolvedores negligenciam. Diferentemente de APIs REST tradicionais, onde a estrutura e os endpoints são predefinidos, GraphQL permite que clientes construam queries dinâmicas. Isso cria superfícies de ataque únicas que precisam ser compreendidas e mitigadas. A segurança em GraphQL não é apenas sobre autenticação e autorização — tópicos bem conhecidos. Trata-se de proteger sua API contra explorações que exploram as características próprias do GraphQL: sua natureza reflexiva através da introspection, sua capacidade de executar queries complexas, o batching de requisições e vulnerabilidades de injeção. Neste artigo, abordaremos cada um desses vetores de ataque com profundidade, mostrando não apenas os riscos, mas principalmente como implementar defesas robustas. Introspection: O Espelho do GraphQL Por que Introspection é um Risco? Introspection é um recurso nativo do GraphQL que permite que clientes consultem o servidor para descobrir

Introdução ao GraphQL Security

GraphQL revolucionou a forma como consumimos APIs, oferecendo flexibilidade e eficiência incomparáveis ao REST. Porém, essa liberdade traz consigo responsabilidades de segurança que muitos desenvolvedores negligenciam. Diferentemente de APIs REST tradicionais, onde a estrutura e os endpoints são predefinidos, GraphQL permite que clientes construam queries dinâmicas. Isso cria superfícies de ataque únicas que precisam ser compreendidas e mitigadas.

A segurança em GraphQL não é apenas sobre autenticação e autorização — tópicos bem conhecidos. Trata-se de proteger sua API contra explorações que exploram as características próprias do GraphQL: sua natureza reflexiva através da introspection, sua capacidade de executar queries complexas, o batching de requisições e vulnerabilidades de injeção. Neste artigo, abordaremos cada um desses vetores de ataque com profundidade, mostrando não apenas os riscos, mas principalmente como implementar defesas robustas.

Introspection: O Espelho do GraphQL

Por que Introspection é um Risco?

Introspection é um recurso nativo do GraphQL que permite que clientes consultem o servidor para descobrir informações sobre o schema — tipos disponíveis, campos, argumentos, tipos de retorno, etc. É um mecanismo poderoso para desenvolvimento, permitindo que IDEs como GraphQL Playground e Apollo Studio gerem documentação automática e autocomplete. Contudo, em produção, expor o schema completo é como deixar o mapa da sua fortaleza à vista do inimigo.

Um atacante pode executar a query de introspection padrão e receber o schema completo em JSON estruturado. Conhecendo exatamente quais campos existem, seus tipos e argumentos esperados, o atacante pode explorar comportamentos inesperados, encontrar campos sensíveis não protegidos adequadamente ou entender a lógica de negócio para construir queries mais eficazes. Não é vulnerabilidade por si, mas reconnaissance facilitada.

Desabilitando Introspection em Produção

A solução mais direta é desabilitar introspection em ambientes de produção. A maioria dos servidores GraphQL oferece essa opção nativamente. Aqui está um exemplo usando Apollo Server em Node.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';

const typeDefs = `
  type Query {
    user(id: ID!): User
    secret: String
  }

  type User {
    id: ID!
    name: String!
    email: String!
  }
`;

const resolvers = {
  Query: {
    user: (_, { id }) => ({ id, name: 'João', email: 'joao@example.com' }),
    secret: () => 'Este é um segredo'
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  // Alternativa: usar variável de controle mais explícita
  // introspection: false
});

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 }
});

console.log(`Server rodando em ${url}`);

Definindo introspection: false, qualquer tentativa de executar __schema ou __type retornará um erro. Clientes legítimos podem usar documentação estática ou OpenAPI/Swagger junto com GraphQL.

Whitelist e Introspection Condicional

Em cenários onde você precisa permitir introspection apenas para clientes autenticados ou em modo desenvolvimento, implemente verificação baseada em contexto:

import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: ({ context }) => {
    // Permitir introspection apenas para usuários autenticados como admin
    return context.user?.role === 'admin' || process.env.NODE_ENV === 'development';
  }
});

Dessa forma, você mantém flexibilidade para desenvolvimento enquanto protege produção.

Query Depth: Controlando a Complexidade

O Problema das Queries Aninhadas

Uma das características mais poderosas do GraphQL é a capacidade de fazer queries profundamente aninhadas, solicitando dados relacionados em uma única requisição. Porém, sem controles, um atacante pode construir queries exponencialmente complexas que consumem recursos computacionais massivos, resultando em ataques de negação de serviço (DoS).

Considere este exemplo de uma query maliciosa:

query {
  user(id: 1) {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  comments {
                    # ... continua indefinidamente
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Sem proteção, isso pode tomar minutos para executar, congelando seu servidor. A chave é implementar query depth limiting — rejeitar queries que excedem uma profundidade máxima permitida.

Implementando Depth Limiting

Usando a biblioteca graphql-depth-limit:

import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: {
    didResolveOperation: async (context) => {
      const complexity = depthLimit(10)(
        context.document,
        {},
        (fieldName) => {
          // Retornar profundidade máxima para cada campo (opcional)
          return 10;
        }
      );
      if (complexity instanceof Error) {
        throw complexity;
      }
    }
  }
});

Agora, qualquer query com profundidade maior que 10 será rejeitada antes da execução. Ajuste o limite conforme sua aplicação necessita.

Query Complexity Analysis

Para proteção mais sofisticada, use análise de complexidade que não se baseia apenas em profundidade, mas em peso computacional real. A biblioteca graphql-query-complexity oferece isso:

import { ApolloServer } from '@apollo/server';
import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
import { parse } from 'graphql';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: {
    didResolveOperation: async ({ document, operationName, context }) => {
      const complexity = getComplexity({
        schema: server.schema,
        operationName,
        query: document,
        variables: context.variables,
        estimators: [simpleEstimator({ defaultComplexity: 1 })]
      });

      const MAX_COMPLEXITY = 1000;
      if (complexity > MAX_COMPLEXITY) {
        throw new Error(`Query complexidade de ${complexity} excede limite de ${MAX_COMPLEXITY}`);
      }

      context.complexity = complexity;
    }
  }
});

Neste modelo, cada campo tem um custo associado (padrão 1). Se um campo é particularmente custoso (ex: busca em banco de dados), atribua complexidade maior:

const typeDefs = `
  type Query {
    users: [User!]! # complexidade padrão = 1
    expensiveQuery: String # custo alto
  }

  type User {
    id: ID!
    posts: [Post!]! # custo padrão para lista
  }

  type Post {
    id: ID!
    comments: [Comment!]! # custo padrão para lista
  }
`;

const estimators = [
  simpleEstimator({ defaultComplexity: 1 }),
  (field) => {
    if (field.fieldName === 'expensiveQuery') return 100;
    if (field.fieldName === 'posts') return 5; // custoso por ser relação
    return undefined; // usar padrão
  }
];

Batching: O Risco das Requisições em Lote

Entendendo GraphQL Batching

GraphQL Batching permite que clientes enviem múltiplas operações em uma única requisição HTTP. Embora isso melhore a eficiência de rede, pode ser explorado para contornar proteções de rate limiting baseadas em requisições HTTP ou para criar ataques coordenados.

Uma requisição em lote típica se parece assim:

[
  {
    "query": "query { user(id: 1) { id name } }"
  },
  {
    "query": "query { user(id: 2) { id name } }"
  },
  {
    "query": "query { user(id: 3) { id name } }"
  }
]

Embora pareça inocente, um atacante pode enviar centenas dessas requisições em um único pacote HTTP, contornando proteções que contam requisições. Além disso, batching intencional de queries caras pode ser tão prejudicial quanto uma única query complexa.

Controlando Batching

A proteção envolve duas estratégias: limitar o número de operações por batch e aplicar rate limiting baseado em operações, não apenas requisições HTTP.

import { ApolloServer } from '@apollo/server';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: {
    willSendResponse: async (context) => {
      const body = context.response.body;

      // Se for array, é batching
      if (Array.isArray(body)) {
        const MAX_BATCH_SIZE = 10;

        if (body.length > MAX_BATCH_SIZE) {
          throw new Error(
            `Batches não podem conter mais de ${MAX_BATCH_SIZE} operações`
          );
        }
      }
    }
  }
});

Rate Limiting por Operação

Implemente rate limiting que conta não apenas requisições HTTP, mas operações GraphQL:

import rateLimit from 'express-rate-limit';

// Rate limiting baseado em operações, não requisições
const operationRateLimiter = (req, res, next) => {
  const clientId = req.ip;
  const now = Date.now();
  const window = 60000; // 1 minuto

  if (!global.operationCounts) {
    global.operationCounts = {};
  }

  if (!global.operationCounts[clientId]) {
    global.operationCounts[clientId] = { timestamp: now, count: 0 };
  }

  const userData = global.operationCounts[clientId];

  if (now - userData.timestamp > window) {
    userData.timestamp = now;
    userData.count = 0;
  }

  const body = req.body;
  const operationCount = Array.isArray(body) ? body.length : 1;

  userData.count += operationCount;

  const MAX_OPERATIONS_PER_MINUTE = 100;
  if (userData.count > MAX_OPERATIONS_PER_MINUTE) {
    return res.status(429).json({
      error: 'Rate limit excedido: muitas operações GraphQL'
    });
  }

  next();
};

app.use('/graphql', operationRateLimiter);

Isso garante que mesmo com batching, um cliente malicioso não pode contornar proteções de rate limiting.

Injection Attacks: Protegendo contra Explorações de Input

Tipos de Injection em GraphQL

Injection attacks em GraphQL incluem: query injection (quando argumentos não sanitizados são usados em queries internas), SQL injection (quando queries GraphQL geram SQL vulnerável) e command injection (raramente, quando inputs são passados para sistema operacional). A raiz do problema é sempre a mesma: não validar e sanitizar inputs do usuário.

Diferentemente de REST, onde é fácil perceber que /api/users?name=João precisa de validação, GraphQL permite argumentos complexos e aninhados que são frequentemente negligenciados:

mutation {
  createUser(input: {
    name: "João",
    email: "joao@example.com",
    metadata: "{ constructor: {prototype: {isAdmin: true}} }"
  }) {
    id
  }
}

Validação Robusta de Argumentos

A defesa principal é validar todo input antes de usá-lo. Use bibliotecas como zod ou joi:

import { z } from 'zod';

const UserInputSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(),
  metadata: z.record(z.string()).optional()
});

const resolvers = {
  Mutation: {
    createUser: async (_, { input }) => {
      // Validação automática - lança erro se inválido
      const validatedInput = UserInputSchema.parse(input);

      // Agora é seguro usar validatedInput
      const user = await db.users.create(validatedInput);
      return user;
    }
  }
};

Isso rejeita requests com dados inválidos antes da lógica de negócio ser executada.

Prevenindo SQL Injection

Mesmo com GraphQL, se você gera SQL dinamicamente, está vulnerável. A proteção é usar prepared statements:

import pg from 'pg';

const resolvers = {
  Query: {
    userByEmail: async (_, { email }) => {
      // ❌ NUNCA faça isso:
      // const result = await db.query(`SELECT * FROM users WHERE email = '${email}'`);

      // ✅ Use prepared statements:
      const result = await db.query(
        'SELECT * FROM users WHERE email = $1',
        [email]
      );

      return result.rows[0];
    }
  }
};

O driver do banco de dados (pg para PostgreSQL) separa código SQL de dados, tornando injection impossível.

Proteção contra Prototype Pollution

Em JavaScript, inputs de usuário podem explorar protótipos de objetos. Isso é especialmente perigoso se você usa Object.assign ou spread operators com dados de usuário:

// ❌ Vulnerável a prototype pollution
const resolvers = {
  Mutation: {
    updateUser: async (_, { id, updates }) => {
      const user = await db.users.findById(id);
      Object.assign(user, updates); // Perigoso!
      return user;
    }
  }
};

// ✅ Seguro - usar whitelist
const ALLOWED_FIELDS = ['name', 'email', 'bio'];

const resolvers = {
  Mutation: {
    updateUser: async (_, { id, updates }) => {
      const user = await db.users.findById(id);

      const safeUpdates = {};
      for (const key of ALLOWED_FIELDS) {
        if (key in updates) {
          safeUpdates[key] = updates[key];
        }
      }

      Object.assign(user, safeUpdates);
      await user.save();
      return user;
    }
  }
};

Whitelist explícito previne que atacantes contaminem propriedades do protótipo ou campos sensíveis.

Escape de Strings em Contexts Especiais

Se seus resolvers geram saída em contextos especiais (HTML, XML, JavaScript), escape apropriadamente:

import DOMPurify from 'isomorphic-dompurify';

const resolvers = {
  Query: {
    post: async (_, { id }) => {
      const post = await db.posts.findById(id);
      return {
        ...post,
        // Sanitizar conteúdo antes de retornar para contexto HTML
        content: DOMPurify.sanitize(post.content)
      };
    }
  }
};

Conclusão

Durante este artigo, exploramos quatro vetores de segurança críticos em GraphQL que frequentemente são negligenciados. Primeiro, compreendemos que introspection não é apenas um recurso de desenvolvimento — é uma porta aberta para reconnaissance de atacantes, facilmente mitigada desabilitando-a em produção com uma linha de código. Segundo, aprendemos que queries complexas e aninhadas são tão perigosas quanto grandes volumes de requisições, exigindo proteção em múltiplas camadas: depth limiting para superficial, complexity analysis para comportamento real, e rate limiting operacional para contornar batching. Finalmente, injection attacks em GraphQL seguem os mesmos princípios de sempre: validar, sanitizar e nunca confiar em input de usuários, seja através de schemas de validação, prepared statements ou whitelists explícitas de campos permitidos.

A segurança em GraphQL é um processo contínuo. Configure monitoramento de operações suspeitas, teste suas proteções regularmente com ferramentas de fuzzing, e mantenha dependências atualizadas. GraphQL é seguro quando implementado com diligência.

Referências


Artigos relacionados