Apollo Server’da Rate Limiting ve Sorgu Karmaşıklığı Kontrolü

Production ortamında bir GraphQL API yayınladığınızda, ilk birkaç gün her şey güzel gider. Sonra biri API’nizi keşfeder ve aniden sunucunuz çöker ya da veritabanınız yanmaya başlar. Rate limiting ve sorgu karmaşıklığı kontrolü, Apollo Server’ı production’a taşımadan önce mutlaka uygulamanız gereken iki kritik güvenlik katmanıdır. Bu yazıda her ikisini de gerçek dünya senaryolarıyla ele alacağız.

Neden Rate Limiting ve Karmaşıklık Kontrolü Gerekli?

REST API’larda rate limiting nispeten basittir: her endpoint için istek sayısını sınırlarsınız. GraphQL’de işler biraz daha karmaşık çünkü tek bir endpoint var ve bu endpoint’e gönderilen sorgular inanılmaz derecede farklı yükler oluşturabilir.

Basit bir örnek düşünelim. Bir kullanıcı şöyle bir sorgu gönderebilir:

# Masum görünen ama tehlikeli bir sorgu
query {
  users {
    posts {
      comments {
        author {
          posts {
            comments {
              author {
                posts {
                  title
                }
              }
            }
          }
        }
      }
    }
  }
}

Bu “nested query” problemi, GraphQL’in en büyük güvenlik açıklarından biridir. Yukarıdaki sorgu teorik olarak veritabanınıza yüzlerce, hatta binlerce sorgu attırabilir. Rate limiting tek başına bunu engellemez çünkü saldırgan dakikada 10 istek bile göndersa sunucunuzu çökertebilir.

İşte bu yüzden iki farklı koruma mekanizmasına ihtiyacınız var:

  • Rate Limiting: Belirli bir zaman diliminde kaç istek gönderebileceğini sınırlar
  • Sorgu Karmaşıklığı Kontrolü: Tek bir sorgunun ne kadar “ağır” olabileceğini sınırlar
  • Sorgu Derinliği Kontrolü: İç içe sorguların kaç seviye derine inebileceğini sınırlar

Temel Apollo Server Kurulumu

Önce çalışan bir Apollo Server kurulumu yapalım. Bu yazıda Apollo Server 4 kullanacağız.

mkdir apollo-security-demo
cd apollo-security-demo
npm init -y
npm install @apollo/server graphql express @as-integrations/express
npm install graphql-depth-limit graphql-query-complexity
npm install express-rate-limit rate-limit-redis ioredis
npm install -D typescript @types/node @types/express ts-node

Temel server yapısını oluşturalım:

# src/index.ts

import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express';
import { buildSchema } from 'graphql';
import depthLimit from 'graphql-depth-limit';
import rateLimit from 'express-rate-limit';

const typeDefs = `
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    comments: [Comment!]!
  }

  type Comment {
    id: ID!
    text: String!
    author: User!
    post: Post!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts: [Post!]!
    post(id: ID!): Post
  }
`;

const resolvers = {
  Query: {
    users: () => [],
    user: (_: any, { id }: { id: string }) => null,
    posts: () => [],
    post: (_: any, { id }: { id: string }) => null,
  }
};

async function startServer() {
  const app = express();
  
  const server = new ApolloServer({
    typeDefs,
    resolvers,
    validationRules: [
      depthLimit(5) // Maksimum 5 seviye derinlik
    ]
  });

  await server.start();
  
  app.use(express.json());
  app.use('/graphql', expressMiddleware(server));
  
  app.listen(4000, () => {
    console.log('Server 4000 portunda çalışıyor');
  });
}

startServer();

Derinlik Limitini Anlamak ve Yapılandırmak

graphql-depth-limit paketi en basit ama en etkili korumaları sağlar. Yukarıda 5 seviye derinlik limiti koyduk, peki bu ne anlama geliyor?

# Bu sorgu 3 seviye derinlikte, geçer:
query {          # Seviye 0
  users {        # Seviye 1
    posts {      # Seviye 2
      title      # Seviye 3
    }
  }
}

# Bu sorgu 6 seviye derinlikte, reddedilir:
query {
  users {
    posts {
      comments {
        author {
          posts {
            title  # 6. seviye - limit aşıldı!
          }
        }
      }
    }
  }
}

Depth limit için özel hata mesajları tanımlayabilirsiniz:

# src/validation/depthLimit.ts

import depthLimit from 'graphql-depth-limit';

export const createDepthLimitRule = (maxDepth: number = 5) => {
  return depthLimit(maxDepth, { ignore: [] }, (depths) => {
    // Derin sorgu girişimlerini loglayabiliriz
    const maxFoundDepth = Math.max(...Object.values(depths));
    if (maxFoundDepth > maxDepth) {
      console.warn(`Derinlik limiti aşıldı. İstenen: ${maxFoundDepth}, Limit: ${maxDepth}`);
    }
  });
};

Sorgu Karmaşıklığı Kontrolü

Derinlik limiti iyi bir başlangıç ama yeterli değil. graphql-query-complexity paketi ile her field’a bir ağırlık değeri atayabilir ve toplam karmaşıklığı sınırlayabilirsiniz.

# src/validation/complexity.ts

import {
  fieldExtensionsEstimator,
  simpleEstimator,
  getComplexity,
  createComplexityRule,
} from 'graphql-query-complexity';
import { GraphQLSchema } from 'graphql';

export const createComplexityRule = (schema: GraphQLSchema, maxComplexity: number = 1000) => {
  return createComplexityRule({
    maximumComplexity: maxComplexity,
    variables: {},
    onComplete: (complexity: number) => {
      console.log(`Sorgu karmaşıklığı: ${complexity}/${maxComplexity}`);
    },
    estimators: [
      // Field bazlı özel karmaşıklık değerleri
      fieldExtensionsEstimator(),
      // Varsayılan: her field için 1 puan
      simpleEstimator({ defaultComplexity: 1 }),
    ],
  });
};

Şimdi schema’da field’lara özel karmaşıklık değerleri atayalım. Bunu extensions ile yapıyoruz:

# src/schema/typeDefs.ts

export const typeDefs = `
  type User {
    id: ID!
    name: String!
    email: String!
    # posts listesi pahalı bir operasyon, 10 puan
    posts: [Post!]! @complexity(value: 10, multipliers: ["limit"])
    # Tek bir field için 1 puan (varsayılan)
    createdAt: String!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    author: User!
    # Her yorum yüklemek veritabanı sorgusu demek
    comments(limit: Int = 10): [Comment!]! @complexity(value: 5, multipliers: ["limit"])
    viewCount: Int!
  }

  type Comment {
    id: ID!
    text: String!
    author: User!
    createdAt: String!
  }

  type Query {
    # Tüm kullanıcıları çekmek pahalı
    users(limit: Int = 20): [User!]! @complexity(value: 20, multipliers: ["limit"])
    user(id: ID!): User  # Tek kullanıcı ucuz
    posts(limit: Int = 20): [Post!]! @complexity(value: 15, multipliers: ["limit"])
    post(id: ID!): Post
  }
`;

Express Rate Limiting Entegrasyonu

IP bazlı rate limiting için express-rate-limit kullanacağız. Farklı senaryolar için farklı limitler tanımlamak önemli:

# src/middleware/rateLimiter.ts

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { createClient } from 'redis';

// Redis client oluştur (production için)
const redisClient = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379'
});

redisClient.connect().catch(console.error);

// Genel API rate limiti - tüm istekler için
export const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 dakika
  max: 100, // 15 dakikada 100 istek
  message: {
    errors: [{
      message: 'Çok fazla istek gönderdiniz. Lütfen 15 dakika bekleyin.',
      extensions: {
        code: 'RATE_LIMIT_EXCEEDED',
        retryAfter: 15 * 60
      }
    }]
  },
  standardHeaders: true,
  legacyHeaders: false,
  // Production'da Redis store kullan
  store: process.env.NODE_ENV === 'production' 
    ? new RedisStore({
        sendCommand: (...args: string[]) => redisClient.sendCommand(args),
      })
    : undefined,
  keyGenerator: (req) => {
    // Authenticated kullanıcılar için user ID'yi kullan
    const userId = (req as any).user?.id;
    return userId || req.ip || 'unknown';
  }
});

// Mutation'lar için daha sıkı limit
export const mutationLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 dakika
  max: 20, // Dakikada 20 mutation
  message: {
    errors: [{
      message: 'Çok fazla değişiklik isteği. Lütfen 1 dakika bekleyin.',
      extensions: { code: 'MUTATION_RATE_LIMIT_EXCEEDED' }
    }]
  }
});

// Introspection sorguları için limit (sadece development'ta açık tutun)
export const introspectionLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 saat
  max: process.env.NODE_ENV === 'production' ? 5 : 1000,
  message: {
    errors: [{
      message: 'Introspection limit aşıldı.',
      extensions: { code: 'INTROSPECTION_LIMIT_EXCEEDED' }
    }]
  }
});

GraphQL Middleware ile Akıllı Rate Limiting

IP bazlı rate limiting yeterli değil. Authenticated kullanıcılar için operasyon bazlı rate limiting de gerekli. Bunun için Apollo Server’ın plugin sistemini kullanabiliriz:

# src/plugins/rateLimitPlugin.ts

import { ApolloServerPlugin, GraphQLRequestContext } from '@apollo/server';

interface RateLimitStore {
  [key: string]: {
    count: number;
    resetTime: number;
  };
}

// Basit in-memory store (production'da Redis kullanın)
const store: RateLimitStore = {};

const LIMITS = {
  query: { max: 60, windowMs: 60 * 1000 },      // Dakikada 60 query
  mutation: { max: 20, windowMs: 60 * 1000 },    // Dakikada 20 mutation
  subscription: { max: 5, windowMs: 60 * 1000 }, // Dakikada 5 subscription
};

export const rateLimitPlugin: ApolloServerPlugin = {
  async requestDidStart(requestContext: GraphQLRequestContext<any>) {
    return {
      async didResolveOperation(context) {
        const userId = context.contextValue?.user?.id || 
                       context.contextValue?.req?.ip || 
                       'anonymous';
        
        const operationType = context.operation?.operation || 'query';
        const key = `${userId}:${operationType}`;
        const limit = LIMITS[operationType as keyof typeof LIMITS];
        
        if (!limit) return;
        
        const now = Date.now();
        
        if (!store[key] || store[key].resetTime < now) {
          store[key] = {
            count: 1,
            resetTime: now + limit.windowMs
          };
          return;
        }
        
        store[key].count++;
        
        if (store[key].count > limit.max) {
          const retryAfter = Math.ceil((store[key].resetTime - now) / 1000);
          throw new Error(
            `Rate limit aşıldı. ${retryAfter} saniye sonra tekrar deneyin. ` +
            `(${operationType} limiti: ${limit.max}/${limit.windowMs / 1000}s)`
          );
        }
      }
    };
  }
};

Her Şeyi Bir Araya Getirmek

Şimdi tüm bu parçaları production-ready bir konfigürasyonda birleştirelim:

# src/index.ts - Production-ready versiyon

import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express';
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { typeDefs } from './schema/typeDefs';
import { resolvers } from './schema/resolvers';
import { generalLimiter, mutationLimiter } from './middleware/rateLimiter';
import { rateLimitPlugin } from './plugins/rateLimitPlugin';

async function startServer() {
  const app = express();
  const schema = makeExecutableSchema({ typeDefs, resolvers });
  
  const server = new ApolloServer({
    schema,
    plugins: [rateLimitPlugin],
    validationRules: [
      // Maksimum 5 seviye iç içe sorgu
      depthLimit(5),
      // Maksimum 1000 karmaşıklık puanı
      createComplexityRule({
        maximumComplexity: 1000,
        schema,
        onComplete: (complexity) => {
          if (complexity > 800) {
            console.warn(`Yüksek karmaşıklıklı sorgu: ${complexity}`);
          }
        },
        estimators: [
          fieldExtensionsEstimator(),
          simpleEstimator({ defaultComplexity: 1 })
        ]
      })
    ],
    // Production'da introspection'ı kapat
    introspection: process.env.NODE_ENV !== 'production',
    
    formatError: (formattedError, error) => {
      // Production'da stack trace'i gizle
      if (process.env.NODE_ENV === 'production') {
        return {
          message: formattedError.message,
          extensions: {
            code: formattedError.extensions?.code || 'INTERNAL_SERVER_ERROR'
          }
        };
      }
      return formattedError;
    }
  });

  await server.start();
  
  app.use(express.json({ limit: '100kb' })); // Request body boyutunu sınırla
  
  // Rate limiting middleware'leri
  app.use('/graphql', generalLimiter);
  
  // Mutation'lar için ekstra kontrol
  app.use('/graphql', (req, res, next) => {
    const body = req.body;
    if (body?.query?.trim().startsWith('mutation')) {
      return mutationLimiter(req, res, next);
    }
    next();
  });
  
  app.use(
    '/graphql',
    expressMiddleware(server, {
      context: async ({ req }) => ({
        req,
        user: (req as any).user || null,
        requestId: crypto.randomUUID()
      })
    })
  );
  
  const PORT = process.env.PORT || 4000;
  app.listen(PORT, () => {
    console.log(`Server ${PORT} portunda çalışıyor`);
    console.log(`Ortam: ${process.env.NODE_ENV || 'development'}`);
  });
}

startServer().catch(console.error);

Gerçek Dünya Senaryosu: Farklı Kullanıcı Tipleri İçin Farklı Limitler

E-ticaret uygulamanızda anonymous kullanıcılar, kayıtlı kullanıcılar ve premium kullanıcılar için farklı limitler isteyebilirsiniz:

# src/utils/rateLimitTiers.ts

export interface UserTier {
  maxComplexity: number;
  maxDepth: number;
  requestsPerMinute: number;
  requestsPerHour: number;
}

export const TIERS: Record<string, UserTier> = {
  anonymous: {
    maxComplexity: 200,
    maxDepth: 3,
    requestsPerMinute: 10,
    requestsPerHour: 100
  },
  free: {
    maxComplexity: 500,
    maxDepth: 5,
    requestsPerMinute: 30,
    requestsPerHour: 500
  },
  premium: {
    maxComplexity: 1500,
    maxDepth: 7,
    requestsPerMinute: 100,
    requestsPerHour: 2000
  },
  internal: {
    maxComplexity: 10000,
    maxDepth: 15,
    requestsPerMinute: 1000,
    requestsPerHour: 50000
  }
};

export function getUserTier(user: any): UserTier {
  if (!user) return TIERS.anonymous;
  if (user.isInternal) return TIERS.internal;
  if (user.isPremium) return TIERS.premium;
  return TIERS.free;
}

Bu tier sistemini validation rules ile dinamik olarak bağlayabilirsiniz. Context’ten kullanıcı bilgisini çekip buna göre farklı karmaşıklık limitleri uygulayabilirsiniz. Key nokta şu: validation rules, request context’e erişemez, bu yüzden tier bazlı karmaşıklık kontrolünü plugin seviyesinde yapmanız gerekir.

Monitoring ve Alerting

Rate limiting ve karmaşıklık kontrolü koymanız yetmez, bunların ne zaman tetiklendiğini de takip etmeniz gerekir:

# src/plugins/monitoringPlugin.ts

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

interface QueryStats {
  operationName: string | null;
  complexity: number;
  depth: number;
  duration: number;
  userId: string;
  timestamp: Date;
  blocked: boolean;
  blockReason?: string;
}

// Bu verileri Prometheus, Datadog veya benzeri bir sisteme gönderin
async function recordQueryStats(stats: QueryStats) {
  // Örnek: console'a yaz, production'da metrics sistemine gönderin
  if (stats.blocked) {
    console.error(`[BLOCKED] ${stats.blockReason}`, {
      userId: stats.userId,
      operationName: stats.operationName,
      complexity: stats.complexity
    });
  } else if (stats.complexity > 800 || stats.duration > 5000) {
    console.warn(`[SLOW/COMPLEX] Operasyon: ${stats.operationName}`, {
      complexity: stats.complexity,
      durationMs: stats.duration,
      userId: stats.userId
    });
  }
}

export const monitoringPlugin: ApolloServerPlugin = {
  async requestDidStart() {
    const startTime = Date.now();
    
    return {
      async willSendResponse(context) {
        const duration = Date.now() - startTime;
        const hasErrors = (context.response.body as any)?.singleResult?.errors?.length > 0;
        
        await recordQueryStats({
          operationName: context.request.operationName || null,
          complexity: (context as any).complexity || 0,
          depth: (context as any).depth || 0,
          duration,
          userId: context.contextValue?.user?.id || 'anonymous',
          timestamp: new Date(),
          blocked: hasErrors && duration < 10, // Hızlı hatalar genelde validasyon hatası
        });
      }
    };
  }
};

Yaygın Hatalar ve Çözümleri

Production’a geçerken karşılaşacağınız tipik sorunlar:

  • Çok sıkı limitler: Meşru kullanıcıları da etkiler. A/B test yaparak doğru limiti bulun.
  • Sadece IP bazlı rate limiting: VPN veya shared IP’ler meşru kullanıcıları etkiler, kullanıcı ID’sini de dahil edin.
  • Depth limit çok düşük: Bazı frontend’ler derin sorgulara ihtiyaç duyabilir. 5-7 genellikle iyi bir değerdir.
  • In-memory rate limit store: Birden fazla sunucu instance’ı varsa her instance kendi sayacını tutar. Mutlaka Redis kullanın.
  • Introspection’ı production’da açık bırakmak: Schema bilginizi paylaşmış olursunuz. Güvenilir IP’lerle kısıtlayın.
  • Complexity değerlerini yanlış ayarlamak: Her field için 1 puan vermek pagination’ı hesaba katmaz. multipliers kullanın.

Sonuç

Apollo Server’da rate limiting ve sorgu karmaşıklığı kontrolü, bir GraphQL API’ını production’a taşımanın olmazsa olmaz adımlarıdır. Özet olarak şu katmanları mutlaka uygulayın:

  • Derinlik limiti ile sonsuz nested sorgulara karşı ilk savunma hattını kurun
  • Karmaşıklık limiti ile ağır ama derin olmayan sorguları da kontrol edin
  • Express rate limiting ile IP/kullanıcı bazlı istek sınırlaması yapın
  • Apollo plugin’leri ile operasyon türü bazlı akıllı limitler uygulayın
  • Redis store ile distributed sistemlerde tutarlı sayaçlar kullanın
  • Monitoring ile sistemin gerçekte nasıl davrandığını takip edin

Bu yapıyı kurduktan sonra production’da rahat uyuyabilirsiniz. Bir saldırı ya da dikkatsiz bir client, sunucunuzu değil sadece kendi rate limit sayacını tüketecektir.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir