Kötüye Kullanımı Önleme: GraphQL Rate Limiting

GraphQL API’leri geliştirirken güvenlik tarafını çoğu zaman ikinci plana atıyoruz. “Önce çalışsın, sonra güvenliğini sağlarız” mantığı maalesef pratikte pek işe yaramıyor. Özellikle GraphQL’in esnek ve güçlü sorgu yapısı, kötü niyetli kullanıcılara REST API’lere kıyasla çok daha geniş bir saldırı yüzeyi sunuyor. Bu yazıda GraphQL’e özgü rate limiting yaklaşımlarını, gerçek dünya senaryolarıyla birlikte ele alacağız.

GraphQL Rate Limiting Neden Farklı?

REST API’lerde rate limiting oldukça basit: belirli bir endpoint’e dakikada X istek gelirse engelle. Ama GraphQL’de tek bir endpoint var ve bu endpoint üzerinden binlerce farklı karmaşıklıkta sorgu gelebilir. Basit bir { user { name } } sorgusunu, iç içe geçmiş 10 seviyeli bir sorguyla aynı kefeye koyamazsın.

Bir düşün: saldırgan şöyle bir sorgu gönderirse ne olur?

{
  users {
    friends {
      friends {
        friends {
          friends {
            posts {
              comments {
                author {
                  friends {
                    name
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}

Bu tek bir HTTP isteği. Ama arka tarafta veritabanını defalarca dövüyor, N+1 problemi yaratıyor ve sunucuyu dizlerine çökertebiliyor. İşte bu yüzden GraphQL için özel rate limiting stratejileri geliştirmemiz gerekiyor.

Temel Koruma Katmanları

GraphQL güvenliğini katmanlı düşünmek gerekiyor. Sadece bir yönteme güvenmek yeterli değil. Şu katmanları sırayla uygulaman önerilir:

  • Sorgu derinliği sınırlandırma (Query Depth Limiting)
  • Sorgu karmaşıklığı analizi (Query Complexity Analysis)
  • İstek sayısı sınırlandırma (Request Rate Limiting)
  • Sorgu boyutu sınırlandırma (Query Size Limiting)
  • Timeout mekanizmaları

Gelin her birini somut örneklerle inceleyelim.

1. Sorgu Derinliği Sınırlandırma

İlk ve en temel önlem, sorgu derinliğini sınırlamak. Node.js ekosisteminde graphql-depth-limit paketi bu iş için biçilmiş kaftan.

npm install graphql-depth-limit

Express + Apollo Server kombinasyonuyla nasıl kullanacağına bakalım:

const { ApolloServer } = require('@apollo/server');
const { expressMiddleware } = require('@apollo/server/express4');
const depthLimit = require('graphql-depth-limit');
const express = require('express');

const app = express();

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(5) // Maksimum 5 seviye derinlik izin ver
  ],
});

await server.start();

app.use('/graphql', expressMiddleware(server));

Burada depthLimit(5) diyerek maksimum 5 seviye iç içe sorguya izin verdik. Yukarıdaki kötü niyetli sorgu bu kuralı ihlal edeceği için anında reddedilecek. Uygulamana göre bu değeri ayarlaman gerekiyor; çoğu gerçek dünya uygulaması için 3-7 arası makul bir değer.

2. Sorgu Karmaşıklığı Analizi

Derinlik sınırı tek başına yeterli değil. Aynı derinlikte ama çok sayıda alan içeren bir sorgu da sistemi zorlayabilir. Bu noktada karmaşıklık analizi devreye giriyor.

npm install graphql-query-complexity
const {
  createComplexityLimitRule,
  simpleEstimator,
  fieldExtensionsEstimator,
} = require('graphql-query-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    createComplexityLimitRule(1000, {
      // Her alan varsayılan olarak 1 puan
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 }),
      ],
      onCost: (cost) => {
        console.log(`Sorgu maliyeti: ${cost}`);
      },
      formatErrorMessage: (cost) =>
        `Sorgu karmaşıklığı (${cost}) izin verilen limiti (1000) aşıyor.`,
    }),
  ],
});

Schema tanımlarında da alan bazlı karmaşıklık değerleri belirleyebilirsin:

const typeDefs = gql`
  type Query {
    users: [User] @complexity(value: 5, multipliers: ["limit"])
    user(id: ID!): User @complexity(value: 1)
    posts(limit: Int): [Post] @complexity(value: 3, multipliers: ["limit"])
  }

  type User {
    id: ID!
    name: String
    email: String
    friends(limit: Int): [User] @complexity(value: 10, multipliers: ["limit"])
    posts(limit: Int): [Post] @complexity(value: 5, multipliers: ["limit"])
  }
`;

friends alanına 10 puan veriyoruz çünkü her kullanıcının arkadaşlarını çekmek pahalı bir operasyon. limit argümanı ile bu değer çarpılıyor, yani friends(limit: 100) sorgusu 1000 puan harcıyor ve direkt engelleniyor.

3. HTTP Katmanında Rate Limiting

Uygulama katmanındaki korumalardan önce HTTP katmanında da bir koruma şeridi oluşturman gerekiyor. Nginx ile bunu şöyle yapabilirsin:

# /etc/nginx/conf.d/graphql-ratelimit.conf

# Rate limiting zone tanımlaması
limit_req_zone $binary_remote_addr zone=graphql_api:10m rate=30r/m;
limit_req_zone $http_x_api_key zone=graphql_apikey:10m rate=100r/m;

server {
    listen 443 ssl;
    server_name api.example.com;

    location /graphql {
        # IP bazlı limit: dakikada 30 istek
        limit_req zone=graphql_api burst=10 nodelay;
        
        # API key bazlı limit: dakikada 100 istek
        limit_req zone=graphql_apikey burst=20 nodelay;
        
        # Limit aşıldığında 429 dön
        limit_req_status 429;
        
        # Yanıt headerlarına ekle
        add_header X-RateLimit-Limit 30;
        add_header Retry-After 60;
        
        proxy_pass http://localhost:4000;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Bu konfigürasyon iki katmanlı çalışıyor: hem IP bazlı hem de API key bazlı sınır uyguluyor. Burst değerleri ani trafik artışlarına karşı biraz esneklik sağlıyor.

4. Redis Destekli Gelişmiş Rate Limiting

Gerçek üretim ortamında, özellikle birden fazla sunucu çalıştırıyorsan, merkezi bir rate limiting sistemine ihtiyacın var. Redis burada mükemmel bir çözüm sunuyor.

const Redis = require('ioredis');
const { RateLimiterRedis } = require('rate-limiter-flexible');

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: process.env.REDIS_PORT || 6379,
  password: process.env.REDIS_PASSWORD,
});

// Genel API rate limiter
const apiRateLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'graphql_rl',
  points: 100,        // 100 istek
  duration: 60,       // 60 saniyede
  blockDuration: 300, // Aşılırsa 5 dakika engelle
});

// Ağır sorgular için ayrı limiter
const heavyQueryLimiter = new RateLimiterRedis({
  storeClient: redis,
  keyPrefix: 'graphql_heavy',
  points: 10,
  duration: 60,
  blockDuration: 600,
});

// Express middleware olarak kullan
const rateLimitMiddleware = async (req, res, next) => {
  const clientKey = req.headers['x-api-key'] || req.ip;
  
  try {
    const rateLimitRes = await apiRateLimiter.consume(clientKey);
    
    res.set({
      'X-RateLimit-Limit': 100,
      'X-RateLimit-Remaining': rateLimitRes.remainingPoints,
      'X-RateLimit-Reset': new Date(Date.now() + rateLimitRes.msBeforeNext).toISOString(),
    });
    
    next();
  } catch (rejRes) {
    res.set({
      'Retry-After': Math.ceil(rejRes.msBeforeNext / 1000),
      'X-RateLimit-Limit': 100,
      'X-RateLimit-Remaining': 0,
    });
    
    res.status(429).json({
      errors: [{
        message: 'Çok fazla istek gönderdiniz. Lütfen bekleyin.',
        extensions: {
          code: 'RATE_LIMIT_EXCEEDED',
          retryAfter: Math.ceil(rejRes.msBeforeNext / 1000),
        },
      }],
    });
  }
};

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

5. Kullanıcı Rolüne Göre Dinamik Limitler

Her kullanıcıyı aynı limitle kıstlamak mantıklı değil. Ücretli aboneler, ücretsiz kullanıcılardan daha fazla istek yapabilmeli. Bu mantığı GraphQL context’i üzerinden uygulayabilirsin:

const getRateLimitForUser = (user) => {
  if (!user) {
    return { points: 20, duration: 60 };      // Anonim kullanıcı
  }
  
  switch (user.plan) {
    case 'enterprise':
      return { points: 10000, duration: 60 };  // Enterprise
    case 'pro':
      return { points: 1000, duration: 60 };   // Pro
    case 'basic':
      return { points: 200, duration: 60 };    // Temel plan
    default:
      return { points: 50, duration: 60 };     // Ücretsiz
  }
};

const contextFunction = async ({ req }) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  const user = token ? await verifyToken(token) : null;
  
  const limits = getRateLimitForUser(user);
  
  // Kullanıcıya özel limiter oluştur
  const userLimiter = new RateLimiterRedis({
    storeClient: redis,
    keyPrefix: `graphql_user_${user?.plan || 'anon'}`,
    points: limits.points,
    duration: limits.duration,
  });
  
  const clientKey = user?.id || req.ip;
  
  try {
    await userLimiter.consume(clientKey);
  } catch (e) {
    throw new GraphQLError('Rate limit aşıldı', {
      extensions: { code: 'RATE_LIMIT_EXCEEDED' },
    });
  }
  
  return { user, clientKey };
};

6. Sorgu Boyutu Sınırlandırma

Büyük sorgular bile basit olsa sunucuya yük bindiriyor. Parse etme aşamasından önce boyut kontrolü yapman gerekiyor:

const express = require('express');
const app = express();

// GraphQL middleware'den ÖNCE boyut kontrolü yap
app.use('/graphql', (req, res, next) => {
  const MAX_QUERY_SIZE = 5000; // 5KB
  
  const contentLength = parseInt(req.headers['content-length'] || '0');
  
  if (contentLength > MAX_QUERY_SIZE) {
    return res.status(413).json({
      errors: [{
        message: `Sorgu boyutu (${contentLength} byte) izin verilen limiti (${MAX_QUERY_SIZE} byte) aşıyor.`,
        extensions: { code: 'QUERY_TOO_LARGE' },
      }],
    });
  }
  
  // Body parse edildikten sonra da kontrol et
  next();
});

app.use('/graphql', express.json({ limit: '5kb' }));

// Gerçek query string uzunluğunu da kontrol et
app.use('/graphql', (req, res, next) => {
  const query = req.body?.query || '';
  
  if (query.length > 2000) {
    return res.status(413).json({
      errors: [{
        message: 'Sorgu çok uzun. Lütfen sorgunuzu kısaltın.',
        extensions: { code: 'QUERY_TOO_LARGE' },
      }],
    });
  }
  
  next();
});

7. Introspection Kontrolü

Saldırganlar genellikle önce schema’nı keşfetmek için introspection sorguları gönderir. Üretim ortamında bunu kısıtlaman gerekiyor:

const { NoSchemaIntrospectionCustomRule } = require('graphql');

const isProduction = process.env.NODE_ENV === 'production';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: !isProduction, // Prod'da kapat
  validationRules: isProduction
    ? [
        NoSchemaIntrospectionCustomRule,
        depthLimit(5),
        createComplexityLimitRule(1000),
      ]
    : [depthLimit(10)],
  
  plugins: [
    {
      requestDidStart() {
        return {
          didResolveOperation({ request, document }) {
            // Introspection sorgularını logla
            const query = request.query || '';
            if (query.includes('__schema') || query.includes('__type')) {
              console.warn(`Introspection sorgusu tespit edildi: ${request.http?.headers.get('x-forwarded-for')}`);
              
              if (isProduction) {
                throw new GraphQLError('Introspection bu ortamda devre dışı.', {
                  extensions: { code: 'INTROSPECTION_DISABLED' },
                });
              }
            }
          },
        };
      },
    },
  ],
});

8. Timeout Mekanizması

Rate limiting ile birlikte timeout’ları da ayarlamak kritik. Uzun süren sorgular sunucuyu bloke edebilir:

const { createServer } = require('http');

// Apollo Server plugin ile timeout
const timeoutPlugin = {
  requestDidStart() {
    return {
      executionDidStart() {
        return {
          willResolveField({ info }) {
            const start = Date.now();
            
            return (error, result) => {
              const elapsed = Date.now() - start;
              const FIELD_TIMEOUT = 5000; // 5 saniye
              
              if (elapsed > FIELD_TIMEOUT) {
                throw new GraphQLError(`${info.fieldName} alanı zaman aşımına uğradı.`, {
                  extensions: {
                    code: 'FIELD_TIMEOUT',
                    field: info.fieldName,
                    elapsed,
                  },
                });
              }
            };
          },
        };
      },
    };
  },
};

// HTTP seviyesinde de timeout koy
const httpServer = createServer(app);
httpServer.timeout = 10000; // 10 saniye maksimum

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [timeoutPlugin],
});

Monitoring ve Alerting

Tüm bu önlemleri aldıktan sonra sistemi izlemek çok önemli. Prometheus metrikleri toplayarak Grafana’da görselleştirebilirsin:

const { register, Counter, Histogram } = require('prom-client');

const rateLimitCounter = new Counter({
  name: 'graphql_rate_limit_exceeded_total',
  help: 'Rate limit aşım sayısı',
  labelNames: ['client_type', 'query_type'],
});

const queryComplexityHistogram = new Histogram({
  name: 'graphql_query_complexity',
  help: 'GraphQL sorgu karmaşıklık dağılımı',
  buckets: [10, 50, 100, 250, 500, 1000, 2500],
});

const queryDepthHistogram = new Histogram({
  name: 'graphql_query_depth',
  help: 'GraphQL sorgu derinlik dağılımı',
  buckets: [1, 2, 3, 5, 7, 10],
});

// Middleware'de kullan
const metricsMiddleware = (req, res, next) => {
  const originalJson = res.json.bind(res);
  
  res.json = (body) => {
    if (body?.errors) {
      body.errors.forEach((error) => {
        if (error.extensions?.code === 'RATE_LIMIT_EXCEEDED') {
          rateLimitCounter.inc({
            client_type: req.user ? 'authenticated' : 'anonymous',
            query_type: 'general',
          });
        }
      });
    }
    return originalJson(body);
  };
  
  next();
};

// Metrics endpoint'i ekle
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

Gerçek Dünya Senaryosu: E-Ticaret API’si

Bir e-ticaret platformu düşün. Ürün araması yapan müşteriler, sistemi indirmek isteyen rakipler ve API’yi test eden geliştiriciler aynı endpoint’i kullanıyor. Bunlar için farklı stratejiler gerekiyor.

  • Anonim kullanıcılar: Dakikada 20 istek, maksimum 3 derinlik, 200 karmaşıklık puanı
  • Kayıtlı üyeler: Dakikada 100 istek, maksimum 5 derinlik, 500 karmaşıklık puanı
  • Partner API kullanıcıları: Dakikada 500 istek, maksimum 7 derinlik, 1000 karmaşıklık puanı
  • Dahili servisler: Whitelist ile sınırsız erişim

Saldırı senaryosunda rakip bir şirket, tüm ürünleri fiyatlarıyla çekmek için bot kullanıyor. IP bazlı engelleme aşıldığında API key rotasyonu yapıyorlar. Çözüm: kullanıcı davranışına dayalı anomali tespiti. Aynı IP’den farklı API keylerle art arda gelen benzer sorgular şüpheli olarak işaretlenmeli ve manuel incelemeye alınmalı.

Sık Yapılan Hatalar

  • Sadece IP bazlı rate limiting: VPN ve proxy kullananlar bunu kolayca aşar. API key bazlı limitler daha güvenilirdir.
  • Çok katı limitler: Meşru kullanıcıları da etkiler, müşteri şikayetleri gelir. A/B testi yaparak doğru değerleri bul.
  • Limit aşımında bilgilendirmeyip sessizce hata vermek: Kullanıcıya Retry-After header’ı ile ne zaman tekrar deneyebileceğini söyle.
  • Rate limit bypass endpoint’leri: /graphql-internal gibi korumasız endpoint’ler bırakmak. Tüm endpoint’ler aynı kurallar altında olmalı.
  • Prod’da introspection açık bırakmak: Schema’nı saldırgana hediye etmek gibi.

Sonuç

GraphQL rate limiting, tek bir çözümle halledilebilecek bir konu değil. Katmanlı bir yaklaşım gerekiyor: HTTP seviyesinde Nginx koruması, uygulama seviyesinde sorgu derinliği ve karmaşıklık kontrolü, Redis destekli akıllı rate limiting ve sürekli monitoring. Her katman bir öncekinin kör noktalarını kapatıyor.

Başlangıç noktası olarak şu önceliklendirmeyi öneririm: önce sorgu derinliği sınırını aç, ardından karmaşıklık analizini ekle, introspection’ı kapat, sonra Redis destekli rate limiting’i devreye al. İzleme ve alerting olmadan hiçbir güvenlik önlemi yeterli değil, bu yüzden Prometheus metriklerini en baştan sisteme entegre et.

GraphQL’in esnekliği gerçekten büyük bir güç, ama bu güç beraberinde sorumluluk getiriyor. Kullanıcılarına iyi bir deneyim sunarken sistemini kötü niyetli aktörlerden korumak senin işin. Bu yazıdaki örnekleri kendi projenize adapte ederken uygulamanızın özel ihtiyaçlarını göz önünde bulundurun ve limitleri gerçek kullanım verileriyle kalibre edin.

Bir yanıt yazın

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