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.