Cloudflare Workers ile Kötü Trafikleri Rate Limiting ile Engelleme

Edge’de çalışan bir fonksiyon yazıp sunucuna tek satır kod eklemeden kötü trafiği engelleyebiliyorsun. Bu, Cloudflare Workers’ın sysadminlere sunduğu en güçlü özelliklerden biri. Rate limiting konusu kulağa basit gelebilir ama production ortamında yanlış yapılandırılmış bir rate limiter, meşru kullanıcıları engellerken bot trafiğini geçirebilir. Bu yazıda sıfırdan gerçek dünya senaryolarına kadar Cloudflare Workers ile rate limiting nasıl yapılır, detaylı anlatalım.

Cloudflare Workers Neden Bu İş İçin İdeal?

Geleneksel rate limiting için genellikle Nginx’e limit_req_zone eklersin, ya da uygulama katmanında Redis tabanlı bir çözüm kurarsın. Her ikisi de sunucuna ulaşan trafiği işlemek zorunda. Yani DDoS saldırısı veya scraping botu, en azından sunucunun ağ katmanına kadar gelir.

Cloudflare Workers ise Cloudflare’in edge node’larında çalışır. Türkiye’den gelen bir istek İstanbul’daki Cloudflare PoP’una ulaşır ve orada işlenir. Sunucun bunu hiç görmez. Bu yaklaşımın avantajları:

  • Sıfır gecikme eklentisi: Edge’de çalıştığı için origin’e gitmeden karar verilir
  • Küresel dağıtım: Tüm Cloudflare PoP’larında aynı anda çalışır
  • KV Store: Workers KV ile global state tutabilirsin
  • Durable Objects: Tutarlı sayaç yönetimi için kullanılabilir

Temel Rate Limiting Mantığı

Önce en basit haliyle başlayalım. Bir IP adresinden belirli sürede kaç istek geldiğini sayıp, limiti aşanları engelleyelim.

// wrangler.toml'da KV namespace bağlaman gerekiyor:
// [[kv_namespaces]]
// binding = "RATE_LIMIT"
// id = "your-kv-namespace-id"

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const key = `rate_limit:${ip}`;
    
    // KV'den mevcut sayacı al
    const current = await env.RATE_LIMIT.get(key);
    const count = current ? parseInt(current) : 0;
    
    // Limit: 60 saniyede 100 istek
    const LIMIT = 100;
    const WINDOW = 60; // saniye
    
    if (count >= LIMIT) {
      return new Response('Too Many Requests', {
        status: 429,
        headers: {
          'Retry-After': WINDOW.toString(),
          'X-RateLimit-Limit': LIMIT.toString(),
          'X-RateLimit-Remaining': '0',
        },
      });
    }
    
    // Sayacı artır, TTL ile birlikte kaydet
    ctx.waitUntil(
      env.RATE_LIMIT.put(key, (count + 1).toString(), {
        expirationTtl: WINDOW,
      })
    );
    
    // Orijinal isteği ilet
    const response = await fetch(request);
    return response;
  },
};

Bu basit örnek çalışır ama bir sorunu var: Fixed Window problemi. Pencere sınırında burst saldırıları yapılabilir. Mesela 59. saniyede 100 istek, 61. saniyede 100 istek. Toplam 2 saniyede 200 istek geçer.

Sliding Window Rate Limiting

Daha sağlam bir çözüm için sliding window algoritması kullanmalısın. Her istek için zaman damgası saklanır ve sadece son WINDOW saniyedeki istekler sayılır.

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const now = Date.now();
    const LIMIT = 100;
    const WINDOW_MS = 60 * 1000; // 60 saniye
    const key = `sliding:${ip}`;
    
    // Mevcut timestamp listesini al
    const stored = await env.RATE_LIMIT.get(key, { type: 'json' });
    let timestamps = stored || [];
    
    // Pencere dışındaki eski timestamp'leri temizle
    timestamps = timestamps.filter(ts => now - ts < WINDOW_MS);
    
    if (timestamps.length >= LIMIT) {
      const oldestInWindow = timestamps[0];
      const retryAfter = Math.ceil((oldestInWindow + WINDOW_MS - now) / 1000);
      
      return new Response(
        JSON.stringify({
          error: 'Rate limit exceeded',
          retryAfter: retryAfter,
        }),
        {
          status: 429,
          headers: {
            'Content-Type': 'application/json',
            'Retry-After': retryAfter.toString(),
            'X-RateLimit-Limit': LIMIT.toString(),
            'X-RateLimit-Remaining': '0',
            'X-RateLimit-Reset': new Date(oldestInWindow + WINDOW_MS).toISOString(),
          },
        }
      );
    }
    
    // Yeni timestamp ekle ve kaydet
    timestamps.push(now);
    ctx.waitUntil(
      env.RATE_LIMIT.put(key, JSON.stringify(timestamps), {
        expirationTtl: Math.ceil(WINDOW_MS / 1000),
      })
    );
    
    const response = await fetch(request);
    
    // Response header'larına rate limit bilgisi ekle
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('X-RateLimit-Limit', LIMIT.toString());
    newResponse.headers.set(
      'X-RateLimit-Remaining',
      (LIMIT - timestamps.length).toString()
    );
    
    return newResponse;
  },
};

Endpoint Bazlı Farklı Limitler

Gerçek dünyada her endpoint aynı limiti hak etmez. Login endpoint’i dakikada 5 istek alabilirken, statik asset endpoint’i çok daha fazlasını kaldırmalıdır.

const RATE_LIMITS = {
  '/api/auth/login': { limit: 5, window: 60 },
  '/api/auth/register': { limit: 3, window: 300 },
  '/api/search': { limit: 30, window: 60 },
  '/api/data': { limit: 100, window: 60 },
  default: { limit: 200, window: 60 },
};

function getRateLimit(pathname) {
  // Exact match önce dene
  if (RATE_LIMITS[pathname]) {
    return RATE_LIMITS[pathname];
  }
  
  // Prefix match
  for (const [pattern, config] of Object.entries(RATE_LIMITS)) {
    if (pattern !== 'default' && pathname.startsWith(pattern)) {
      return config;
    }
  }
  
  return RATE_LIMITS.default;
}

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const ip = request.headers.get('CF-Connecting-IP');
    const { limit, window } = getRateLimit(url.pathname);
    
    const key = `rl:${ip}:${url.pathname}`;
    const current = await env.RATE_LIMIT.get(key);
    const count = current ? parseInt(current) : 0;
    
    if (count >= limit) {
      return new Response(
        JSON.stringify({ error: 'Too many requests', path: url.pathname }),
        {
          status: 429,
          headers: {
            'Content-Type': 'application/json',
            'X-RateLimit-Limit': limit.toString(),
            'X-RateLimit-Remaining': '0',
          },
        }
      );
    }
    
    ctx.waitUntil(
      env.RATE_LIMIT.put(key, (count + 1).toString(), {
        expirationTtl: window,
      })
    );
    
    return fetch(request);
  },
};

Bot ve Scraper Tespiti

Rate limiting tek başına yeterli değil. Akıllı botlar limit altında kalarak scraping yapabilir. Bunları tespit etmek için ek sinyaller kullanmalısın.

function calculateBotScore(request) {
  let score = 0;
  const ua = request.headers.get('User-Agent') || '';
  
  // User-Agent kontrolleri
  const suspiciousUAs = [
    /python-requests/i,
    /curl//i,
    /wget//i,
    /scrapy/i,
    /bot/i,
    /crawler/i,
    /spider/i,
    /headless/i,
  ];
  
  for (const pattern of suspiciousUAs) {
    if (pattern.test(ua)) {
      score += 30;
      break;
    }
  }
  
  // Boş veya çok kısa User-Agent
  if (ua.length < 10) score += 25;
  
  // Accept header yoksa şüpheli
  if (!request.headers.get('Accept')) score += 15;
  
  // Accept-Language yoksa şüpheli
  if (!request.headers.get('Accept-Language')) score += 10;
  
  // Cloudflare bot skoru varsa kullan
  const cfBotScore = request.cf?.botManagement?.score;
  if (cfBotScore !== undefined) {
    if (cfBotScore < 30) score += 40;
    else if (cfBotScore < 60) score += 20;
  }
  
  // Tor exit node kontrolü
  if (request.cf?.isEUCountry === false && request.cf?.threatScore > 20) {
    score += 15;
  }
  
  return score;
}

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const botScore = calculateBotScore(request);
    
    // Yüksek bot skoru olan trafiği daha sıkı limit
    let limit = 100;
    let windowSeconds = 60;
    
    if (botScore >= 50) {
      // Muhtemelen bot, çok sıkı limit
      limit = 10;
      windowSeconds = 300;
    } else if (botScore >= 25) {
      // Şüpheli, orta limit
      limit = 30;
      windowSeconds = 60;
    }
    
    const key = `bot_rl:${ip}`;
    const current = await env.RATE_LIMIT.get(key);
    const count = current ? parseInt(current) : 0;
    
    if (count >= limit) {
      // Bot trafiğini logla
      ctx.waitUntil(
        logBlockedRequest(env, {
          ip,
          botScore,
          url: request.url,
          ua: request.headers.get('User-Agent'),
          timestamp: new Date().toISOString(),
        })
      );
      
      return new Response('Access Denied', { status: 403 });
    }
    
    ctx.waitUntil(
      env.RATE_LIMIT.put(key, (count + 1).toString(), {
        expirationTtl: windowSeconds,
      })
    );
    
    return fetch(request);
  },
};

async function logBlockedRequest(env, data) {
  // Logları KV'ye yaz (veya external logging servisine gönder)
  const logKey = `blocked_log:${Date.now()}`;
  await env.RATE_LIMIT.put(logKey, JSON.stringify(data), {
    expirationTtl: 86400, // 24 saat sakla
  });
}

IP Allowlist ve Denylist Yönetimi

Bazı IP’leri tamamen engellemek veya whitelist’e almak isteyebilirsin. Cloudflare Workers bunu dinamik olarak yapmanı sağlar.

async function checkIPLists(ip, env) {
  // KV'den allowlist ve denylist'i al
  const [allowlistData, denylistData] = await Promise.all([
    env.RATE_LIMIT.get('allowlist', { type: 'json' }),
    env.RATE_LIMIT.get('denylist', { type: 'json' }),
  ]);
  
  const allowlist = allowlistData || [];
  const denylist = denylistData || [];
  
  // IP veya CIDR kontrolü
  const isAllowed = allowlist.some(entry => ipMatchesCIDR(ip, entry));
  const isDenied = denylist.some(entry => ipMatchesCIDR(ip, entry));
  
  return { isAllowed, isDenied };
}

function ipToInt(ip) {
  return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0;
}

function ipMatchesCIDR(ip, cidr) {
  if (!cidr.includes('/')) {
    return ip === cidr;
  }
  
  const [network, bits] = cidr.split('/');
  const mask = ~((1 << (32 - parseInt(bits))) - 1) >>> 0;
  
  return (ipToInt(ip) & mask) === (ipToInt(network) & mask);
}

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const { isAllowed, isDenied } = await checkIPLists(ip, env);
    
    // Denylist'teki IP'yi direkt engelle
    if (isDenied) {
      return new Response('Forbidden', { status: 403 });
    }
    
    // Allowlist'teki IP'ye rate limit uygulama
    if (isAllowed) {
      return fetch(request);
    }
    
    // Normal rate limiting mantığı devam eder
    const key = `rl:${ip}`;
    const current = await env.RATE_LIMIT.get(key);
    const count = current ? parseInt(current) : 0;
    
    if (count >= 100) {
      // Otomatik denylist'e ekle (çok fazla istek yapıyorsa)
      if (count >= 500) {
        ctx.waitUntil(addToDenylist(ip, env));
      }
      
      return new Response('Too Many Requests', { status: 429 });
    }
    
    ctx.waitUntil(
      env.RATE_LIMIT.put(key, (count + 1).toString(), {
        expirationTtl: 60,
      })
    );
    
    return fetch(request);
  },
};

async function addToDenylist(ip, env) {
  const denylist = (await env.RATE_LIMIT.get('denylist', { type: 'json' })) || [];
  
  if (!denylist.includes(ip)) {
    denylist.push(ip);
    await env.RATE_LIMIT.put('denylist', JSON.stringify(denylist));
    
    console.log(`Auto-blocked IP: ${ip}`);
  }
}

Denylist Yönetim Script’i

IP listelerini CLI’dan yönetmek için Wrangler kullanabilirsin:

# Wrangler kurulumu ve login
npm install -g wrangler
wrangler login

# KV namespace oluştur
wrangler kv:namespace create "RATE_LIMIT"
wrangler kv:namespace create "RATE_LIMIT" --preview

# Denylist'e IP ekle
wrangler kv:key put --namespace-id="YOUR_NS_ID" "denylist" 
  '["192.168.1.100", "10.0.0.0/8", "185.220.101.0/24"]'

# Allowlist ekle (örn. ofis IP'leri)
wrangler kv:key put --namespace-id="YOUR_NS_ID" "allowlist" 
  '["203.0.113.0/24", "198.51.100.50"]'

# Mevcut denylist'i görüntüle
wrangler kv:key get --namespace-id="YOUR_NS_ID" "denylist"

# Bir IP'nin kaç istek yaptığını kontrol et
wrangler kv:key get --namespace-id="YOUR_NS_ID" "rl:192.168.1.100"

# Worker'ı deploy et
wrangler deploy

# Logları izle (real-time)
wrangler tail

Token Bucket Algoritması

Bazı senaryolarda burst trafiğe izin vermek isteyebilirsin. Örneğin normal şartlarda dakikada 60 istek yapan bir kullanıcı, 2 dakika beklerse 120 istek burst yapabilmeli. Token Bucket bu durumu çözer.

const BUCKET_CAPACITY = 100;   // Maximum token sayısı
const REFILL_RATE = 1;         // Saniyede eklenecek token
const REFILL_INTERVAL = 1000;  // millisecond

export default {
  async fetch(request, env, ctx) {
    const ip = request.headers.get('CF-Connecting-IP');
    const key = `bucket:${ip}`;
    const now = Date.now();
    
    const stored = await env.RATE_LIMIT.get(key, { type: 'json' });
    
    let tokens;
    let lastRefill;
    
    if (!stored) {
      // İlk istek, bucket'ı dolu başlat
      tokens = BUCKET_CAPACITY;
      lastRefill = now;
    } else {
      tokens = stored.tokens;
      lastRefill = stored.lastRefill;
      
      // Geçen süreye göre token ekle
      const elapsed = (now - lastRefill) / 1000; // saniye
      const tokensToAdd = Math.floor(elapsed * REFILL_RATE);
      
      if (tokensToAdd > 0) {
        tokens = Math.min(BUCKET_CAPACITY, tokens + tokensToAdd);
        lastRefill = now;
      }
    }
    
    if (tokens < 1) {
      // Token kalmadı, isteği reddet
      const timeToRefill = Math.ceil((1 - tokens) / REFILL_RATE);
      
      return new Response(
        JSON.stringify({
          error: 'Rate limit exceeded',
          tokensRemaining: 0,
          refillIn: timeToRefill,
        }),
        {
          status: 429,
          headers: {
            'Content-Type': 'application/json',
            'X-RateLimit-Remaining': '0',
            'Retry-After': timeToRefill.toString(),
          },
        }
      );
    }
    
    // Bir token harca
    tokens -= 1;
    
    ctx.waitUntil(
      env.RATE_LIMIT.put(
        key,
        JSON.stringify({ tokens, lastRefill }),
        { expirationTtl: BUCKET_CAPACITY / REFILL_RATE + 60 }
      )
    );
    
    const response = await fetch(request);
    const newResponse = new Response(response.body, response);
    newResponse.headers.set('X-RateLimit-Remaining', Math.floor(tokens).toString());
    newResponse.headers.set('X-RateLimit-Limit', BUCKET_CAPACITY.toString());
    
    return newResponse;
  },
};

Wrangler Konfigürasyonu

Tüm bu Worker’ları deploy etmek için wrangler.toml dosyasını doğru yapılandırman gerekiyor:

# wrangler.toml örneği
cat > wrangler.toml << 'EOF'
name = "rate-limiter"
main = "src/index.js"
compatibility_date = "2024-01-01"

[vars]
ENVIRONMENT = "production"

[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-production-kv-id"
preview_id = "your-preview-kv-id"

[triggers]
routes = [
  { pattern = "example.com/*", zone_name = "example.com" },
  { pattern = "api.example.com/*", zone_name = "example.com" }
]

# CPU limiti aşımını önlemek için
[limits]
cpu_ms = 50
EOF

# Geliştirme ortamında test et
wrangler dev --local

# Production'a deploy et
wrangler deploy

# Belirli bir ortama deploy
wrangler deploy --env production

# Worker metriklerini izle
wrangler metrics

Gerçek Dünya Senaryosu: API Güvenliği

Bir e-ticaret sitesinin API’sini düşün. Login brute force, product scraping ve checkout spam sorunlarıyla karşılaşıyorsun. İşte bütünleşik bir çözüm:

// Gerçek dünya: E-ticaret API koruma katmanı

const ENDPOINT_CONFIGS = {
  '/api/v1/auth/login': {
    limit: 5,
    window: 300,
    blockDuration: 3600,
    message: 'Çok fazla giriş denemesi. 1 saat sonra tekrar deneyin.',
  },
  '/api/v1/checkout': {
    limit: 10,
    window: 60,
    blockDuration: 300,
    message: 'Çok fazla ödeme denemesi.',
  },
  '/api/v1/products': {
    limit: 200,
    window: 60,
    blockDuration: 60,
    message: 'API limiti aşıldı.',
  },
  '/api/v1/search': {
    limit: 60,
    window: 60,
    blockDuration: 120,
    message: 'Arama limiti aşıldı.',
  },
};

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    const ip = request.headers.get('CF-Connecting-IP');
    const country = request.cf?.country || 'XX';
    
    // Config'i bul
    let config = null;
    for (const [path, cfg] of Object.entries(ENDPOINT_CONFIGS)) {
      if (url.pathname.startsWith(path)) {
        config = { path, ...cfg };
        break;
      }
    }
    
    if (!config) {
      return fetch(request);
    }
    
    // Aktif block kontrolü
    const blockKey = `block:${ip}:${config.path}`;
    const isBlocked = await env.RATE_LIMIT.get(blockKey);
    
    if (isBlocked) {
      const blockData = JSON.parse(isBlocked);
      
      return new Response(
        JSON.stringify({
          error: config.message,
          blockedAt: blockData.blockedAt,
          reason: blockData.reason,
        }),
        {
          status: 429,
          headers: { 'Content-Type': 'application/json' },
        }
      );
    }
    
    // Rate limit kontrolü
    const countKey = `count:${ip}:${config.path}`;
    const current = await env.RATE_LIMIT.get(countKey);
    const count = current ? parseInt(current) : 0;
    
    if (count >= config.limit) {
      // Block uygula
      ctx.waitUntil(
        env.RATE_LIMIT.put(
          blockKey,
          JSON.stringify({
            blockedAt: new Date().toISOString(),
            reason: `Exceeded ${config.limit} requests in ${config.window}s`,
            ip,
            country,
            endpoint: config.path,
          }),
          { expirationTtl: config.blockDuration }
        )
      );
      
      return new Response(
        JSON.stringify({ error: config.message }),
        {
          status: 429,
          headers: { 'Content-Type': 'application/json' },
        }
      );
    }
    
    ctx.waitUntil(
      env.RATE_LIMIT.put(countKey, (count + 1).toString(), {
        expirationTtl: config.window,
      })
    );
    
    const response = await fetch(request);
    const newResponse = new Response(response.body, response);
    
    newResponse.headers.set('X-RateLimit-Limit', config.limit.toString());
    newResponse.headers.set(
      'X-RateLimit-Remaining',
      Math.max(0, config.limit - count - 1).toString()
    );
    
    return newResponse;
  },
};

Monitoring ve Alerting

Rate limiting çalışıyor mu, ne kadar trafik engellendi gibi soruları yanıtlamak için metrics toplaman gerekiyor. Cloudflare Workers Logpush veya bir webhook ile bunu dışarıya taşıyabilirsin:

# Cloudflare API ile Worker loglarını sorgula
curl -X GET 
  "https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/scripts/{script_name}/tail" 
  -H "Authorization: Bearer YOUR_API_TOKEN" 
  -H "Content-Type: application/json"

# Blocked IP sayısını KV'den çek
wrangler kv:key list --namespace-id="YOUR_NS_ID" --prefix="block:" | 
  python3 -c "import sys, json; data=json.load(sys.stdin); print(f'Toplam engellenmiş IP: {len(data["result"])}')"

# Son 1 saatteki engelleme loglarını listele
wrangler kv:key list --namespace-id="YOUR_NS_ID" --prefix="blocked_log:" | head -50

# Wrangler tail ile canlı log izleme (filtrelenmiş)
wrangler tail --format=pretty --search="Rate limit exceeded"

Yaygın Hatalar ve Çözümleri

Production’da karşılaşılan tipik sorunlar şunlar:

  • KV Yazma Gecikmesi: KV eventually consistent’tır, yani aynı IP’den eş zamanlı istekler aynı sayacı okuyabilir. Bunun için Durable Objects kullanmak daha güvenilirdir ama maliyeti artırır.
  • False Positive: Meşru kullanıcıların engellenmesi. Bunu önlemek için IP yerine JWT token veya API key bazlı rate limiting tercih et. Shared IP’ler (NAT arkası kurumsal ağlar gibi) sorun çıkarabilir.
  • Worker CPU Limiti: Karmaşık bot tespit algoritmaları 50ms CPU limitini aşabilir. Sliding window yerine fixed window kullanmak veya hesaplamayı sadeleştirmek gerekebilir.
  • KV Maliyet: Yüksek trafikli sitelerde KV okuma/yazma maliyetleri artabilir. Her istek için iki KV operasyonu yapıyorsan, Workers Paid planında KV fiyatlarını hesapla.
  • HTTPS Olmayan Origin: Worker üzerinden geçen isteklerin origin’e yönlendirilmesinde SSL sertifika sorunlarıyla karşılaşabilirsin. fetch çağrılarında cf: { ssl: true } seçeneğini kullan.

Sonuç

Cloudflare Workers ile rate limiting, sunucu tarafında herhangi bir değişiklik yapmadan edge seviyesinde güçlü bir güvenlik katmanı oluşturmana imkan tanıyor. Basit sabit pencere sayacından, token bucket algoritmasına ve akıllı bot tespitine kadar her seviyede uygulama yapabilirsin.

Başlangıç için önerilen yol şu şekilde olabilir: önce endpoint bazlı basit rate limiting ile başla, production’da gerçek trafiği izle, false positive’leri analiz et ve sonra daha gelişmiş algoritmalar ekle. Token bucket özellikle API’ler için, sliding window ise auth endpoint’leri için iyi bir tercih.

Unutma, rate limiting tek başına yeterli değil. Cloudflare WAF kuralları, bot management ve rate limiting birlikte kullanıldığında gerçek anlamda sağlam bir savunma elde edersin. Workers’ı diğer Cloudflare özellikleriyle entegre etmek, sysadmin olarak senin en büyük avantajın olacak.

Bir yanıt yazın

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