Webhook Güvenliği: İmza Doğrulama ve Secret Kullanımı

Bir webhook aldığınızda, karşı tarafın gerçekten beklediğiniz sistem olduğundan emin olabiliyor musunuz? HTTP isteği göndermek teknik olarak herhangi birinin yapabileceği bir şey. Eğer webhook endpoint’iniz herkese açıksa ve gelen veriyi doğrulamıyorsanız, sisteminize sahte veri enjekte edilmesi, iş akışlarınızın manipüle edilmesi ya da güvenlik açıkları tetiklenmesi an meselesi. Webhook güvenliği, özellikle CI/CD pipeline’larında, ödeme sistemlerinde ve otomasyon akışlarında kritik bir konu. Bu yazıda imza doğrulama mekanizmalarını, secret yönetimini ve production ortamında karşılaşacağınız gerçek senaryoları ele alacağız.

Webhook Güvenliğinin Temelleri

Webhook, bir servisin belirli bir olay gerçekleştiğinde sizin belirlediğiniz URL’e HTTP POST isteği göndermesidir. GitHub, Stripe, Shopify, Slack gibi platformların hepsi bu mekanizmayı kullanır. Sorun şu: HTTP isteği göndermek için kaynak IP’yi doğrulamak yeterli değil, çünkü IP sahteciliği mümkün ve birçok servis sabit IP kullanmıyor.

HMAC (Hash-based Message Authentication Code) bu sorunu çözmek için kullanılan en yaygın yöntem. Temel mantık şöyle:

  • Webhook sağlayıcısı ve siz bir “secret” paylaşırsınız
  • Sağlayıcı her isteği göndermeden önce request body’sini bu secret ile imzalar
  • İmzayı HTTP header’a ekler
  • Siz aynı secret ile aynı işlemi yaparsınız ve imzaları karşılaştırırsınız
  • İmzalar eşleşmiyorsa isteği reddedersiniz

Bu mekanizma sayesinde secret’ı bilmeyen hiç kimse geçerli bir imza üretemez.

HMAC İmza Doğrulama: Teori ve Pratik

GitHub webhook’larından başlayalım çünkü en iyi belgelenmiş örneklerden biri. GitHub, X-Hub-Signature-256 header’ını kullanır ve SHA-256 algoritmasıyla imzalar.

Python ile Temel Doğrulama

# Önce Flask kütüphanesini kuralım
pip install flask requests
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)

# Secret'ı environment variable'dan al, asla hardcode etme
WEBHOOK_SECRET = os.environ.get('GITHUB_WEBHOOK_SECRET', '').encode('utf-8')

def verify_github_signature(payload_body, signature_header):
    """GitHub webhook imzasını doğrula"""
    if not signature_header:
        return False
    
    # Header formatı: sha256=<hash>
    if not signature_header.startswith('sha256='):
        return False
    
    expected_signature = signature_header[7:]  # 'sha256=' kısmını at
    
    # HMAC hesapla
    mac = hmac.new(WEBHOOK_SECRET, msg=payload_body, digestmod=hashlib.sha256)
    computed_signature = mac.hexdigest()
    
    # Timing attack'a karşı compare_digest kullan
    return hmac.compare_digest(expected_signature, computed_signature)

@app.route('/webhook/github', methods=['POST'])
def github_webhook():
    signature = request.headers.get('X-Hub-Signature-256')
    payload = request.get_data()
    
    if not verify_github_signature(payload, signature):
        print(f"[SECURITY] Geçersiz imza - IP: {request.remote_addr}")
        abort(401)
    
    event_type = request.headers.get('X-GitHub-Event')
    data = request.get_json()
    
    print(f"[INFO] {event_type} eventi alındı")
    
    # İş mantığını buraya ekle
    if event_type == 'push':
        handle_push_event(data)
    
    return {'status': 'ok'}, 200

def handle_push_event(data):
    branch = data['ref'].split('/')[-1]
    print(f"[INFO] Push alındı - Branch: {branch}")

if __name__ == '__main__':
    app.run(debug=False, host='0.0.0.0', port=5000)

Burada dikkat edilmesi gereken kritik nokta hmac.compare_digest() kullanımı. Normal string karşılaştırması (==) timing attack’a açıktır çünkü Python, karakterleri tek tek karşılaştırır ve ilk farklı karakterde durur. Bu süre farkı ölçülerek secret hakkında bilgi elde edilebilir. compare_digest ise her zaman sabit sürede çalışır.

Stripe Webhook Doğrulama

Stripe biraz farklı bir yaklaşım kullanır. Timestamp’i de imzaya dahil eder, bu da replay attack koruması sağlar.

import stripe
import os
from flask import Flask, request, abort
from datetime import datetime, timezone

app = Flask(__name__)

STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET')
stripe.api_key = os.environ.get('STRIPE_SECRET_KEY')

@app.route('/webhook/stripe', methods=['POST'])
def stripe_webhook():
    payload = request.get_data(as_text=True)
    sig_header = request.headers.get('Stripe-Signature')
    
    try:
        # Stripe kütüphanesi doğrulamayı kendisi yapıyor
        # tolerance parametresi: kaç saniye öncesine kadar kabul et (default 300)
        event = stripe.Webhook.construct_event(
            payload, sig_header, STRIPE_WEBHOOK_SECRET,
            tolerance=300  # 5 dakika
        )
    except ValueError as e:
        print(f"[ERROR] Geçersiz payload: {e}")
        abort(400)
    except stripe.error.SignatureVerificationError as e:
        print(f"[SECURITY] İmza doğrulama hatası: {e}")
        abort(401)
    
    event_type = event['type']
    print(f"[INFO] Stripe eventi: {event_type}")
    
    if event_type == 'payment_intent.succeeded':
        payment_intent = event['data']['object']
        amount = payment_intent['amount'] / 100  # Cent'ten TL'ye
        print(f"[INFO] Ödeme başarılı: {amount} TL")
    
    return {'received': True}, 200

Stripe’ın timestamp yaklaşımı şöyle çalışır: Header’a t=1234567890,v1=abc123... formatında gönderir. t timestamp, v1 imza. Siz de {timestamp}.{payload} stringini imzalarsınız. Böylece aynı webhook isteği 5 dakika sonra tekrar gönderilemez.

Node.js ile Webhook Güvenliği

Birçok ekip Node.js tercih ediyor, o yüzden aynı mantığı JavaScript tarafında da gösterelim.

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

// Raw body gerekiyor, JSON parse etme
app.use('/webhook', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(payload, signature, secret) {
    if (!signature || !secret) return false;
    
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(payload);
    const computedSignature = 'sha256=' + hmac.digest('hex');
    
    // Timing-safe karşılaştırma
    try {
        return crypto.timingSafeEqual(
            Buffer.from(signature),
            Buffer.from(computedSignature)
        );
    } catch (err) {
        // Buffer boyutları eşit değilse hata fırlatır
        return false;
    }
}

app.post('/webhook/github', (req, res) => {
    const signature = req.headers['x-hub-signature-256'];
    const payload = req.body;
    
    if (!verifySignature(payload, signature, WEBHOOK_SECRET)) {
        console.error(`[SECURITY] Geçersiz imza - IP: ${req.ip}`);
        return res.status(401).json({ error: 'Unauthorized' });
    }
    
    const event = req.headers['x-github-event'];
    const data = JSON.parse(payload);
    
    console.log(`[INFO] Event alındı: ${event}`);
    
    // İşlemi asenkron yap, 200 hemen dön
    res.status(200).json({ status: 'accepted' });
    
    // Arka planda işle
    processWebhook(event, data).catch(err => {
        console.error('[ERROR] Webhook işleme hatası:', err);
    });
});

async function processWebhook(event, data) {
    if (event === 'push') {
        const branch = data.ref.split('/').pop();
        console.log(`[INFO] Push - Branch: ${branch}, Commit: ${data.after.substring(0, 7)}`);
    }
}

app.listen(3000, () => console.log('[INFO] Webhook server port 3000'de çalışıyor'));

Burada önemli bir nokak: express.raw() kullanımı zorunlu. express.json() kullanırsanız body parse edilir ve orijinal byte dizisi kaybolur. İmzayı parse edilmiş veri üzerinden hesaplamaya çalışırsanız her zaman başarısız olursunuz.

Secret Yönetimi: Environment Variables ve Vault

Secret’ları nasıl yönettiğiniz en az doğrulama kodunuz kadar önemli. Sık yapılan hatalar:

  • Secret’ı kaynak koduna yazmak (GitHub’a push etmek)
  • Tüm ortamlar için aynı secret kullanmak
  • Secret’ı loglamak (en sinsi hata)
  • Secret’ı uzun süre değiştirmemek

Systemd Servis Dosyasında Secret

# /etc/systemd/system/webhook-server.service

[Unit]
Description=Webhook Server
After=network.target

[Service]
Type=simple
User=webhook
WorkingDirectory=/opt/webhook-server
ExecStart=/usr/bin/python3 app.py
Restart=on-failure

# Secret'ları environment file'dan oku
EnvironmentFile=/etc/webhook-server/secrets.env

# Güvenlik kısıtlamaları
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths=/var/log/webhook-server

[Install]
WantedBy=multi-user.target
# /etc/webhook-server/secrets.env dosyasını oluştur
sudo mkdir -p /etc/webhook-server
sudo touch /etc/webhook-server/secrets.env

# Sadece root okuyabilsin
sudo chmod 600 /etc/webhook-server/secrets.env
sudo chown root:webhook /etc/webhook-server/secrets.env

# Secret üret ve dosyaya yaz
GITHUB_WEBHOOK_SECRET=$(openssl rand -hex 32)
echo "GITHUB_WEBHOOK_SECRET=${GITHUB_WEBHOOK_SECRET}" | sudo tee /etc/webhook-server/secrets.env

# Servisi yeniden başlat
sudo systemctl daemon-reload
sudo systemctl restart webhook-server

HashiCorp Vault ile Secret Çekme

Production ortamlarında Vault kullanmak çok daha güvenli bir yaklaşım.

#!/bin/bash
# /opt/webhook-server/start.sh

# Vault'tan secret çek
export GITHUB_WEBHOOK_SECRET=$(vault kv get -field=github_secret secret/webhook/production)
export STRIPE_WEBHOOK_SECRET=$(vault kv get -field=stripe_secret secret/webhook/production)

# Uygulamayı başlat
exec python3 /opt/webhook-server/app.py

Replay Attack Koruması

Timestamp doğrulaması yapmayan webhook endpoint’leri replay attack’a açıktır. Bir saldırgan geçerli bir webhook isteğini kaydedip tekrar tekrar gönderebilir. Bunu önlemek için iki yöntem kullanılır.

Timestamp Kontrolü

import time
import hmac
import hashlib
import redis
from flask import Flask, request, abort

app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, db=0)

WEBHOOK_SECRET = os.environ.get('WEBHOOK_SECRET', '').encode('utf-8')
MAX_AGE_SECONDS = 300  # 5 dakika

def verify_with_timestamp(payload, timestamp_header, signature_header):
    """Timestamp ve imza doğrulaması"""
    try:
        timestamp = int(timestamp_header)
    except (ValueError, TypeError):
        return False, "Geçersiz timestamp"
    
    # Timestamp çok eski mi?
    current_time = int(time.time())
    if abs(current_time - timestamp) > MAX_AGE_SECONDS:
        return False, f"Timestamp çok eski: {current_time - timestamp} saniye"
    
    # İmzayı doğrula
    signed_payload = f"{timestamp}.{payload.decode('utf-8')}".encode('utf-8')
    mac = hmac.new(WEBHOOK_SECRET, msg=signed_payload, digestmod=hashlib.sha256)
    expected = mac.hexdigest()
    
    if not hmac.compare_digest(expected, signature_header):
        return False, "Geçersiz imza"
    
    return True, "OK"

def check_replay(delivery_id):
    """Aynı webhook ID'si daha önce işlendi mi?"""
    key = f"webhook:seen:{delivery_id}"
    
    # Redis'te varsa replay
    if r.exists(key):
        return True
    
    # Yoksa ekle, 10 dakika sonra otomatik sil
    r.setex(key, 600, '1')
    return False

@app.route('/webhook', methods=['POST'])
def webhook():
    delivery_id = request.headers.get('X-Delivery-ID')
    timestamp = request.headers.get('X-Timestamp')
    signature = request.headers.get('X-Signature')
    payload = request.get_data()
    
    # Replay kontrolü
    if delivery_id and check_replay(delivery_id):
        print(f"[SECURITY] Replay attack tespit edildi - ID: {delivery_id}")
        abort(409)  # Conflict
    
    # Timestamp ve imza kontrolü
    valid, message = verify_with_timestamp(payload, timestamp, signature)
    if not valid:
        print(f"[SECURITY] Doğrulama başarısız: {message}")
        abort(401)
    
    return {'status': 'ok'}, 200

Nginx ile Ön Katman Güvenliği

Webhook endpoint’inizi doğrudan internete açmak yerine Nginx arkasında tutmak iyi bir pratik.

# /etc/nginx/sites-available/webhook

server {
    listen 443 ssl;
    server_name webhooks.sirketiniz.com;
    
    ssl_certificate /etc/letsencrypt/live/webhooks.sirketiniz.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webhooks.sirketiniz.com/privkey.pem;
    
    # Rate limiting zone'u tanımla
    # nginx.conf içinde: limit_req_zone $binary_remote_addr zone=webhook:10m rate=30r/m;
    
    location /webhook/ {
        # Rate limiting: dakikada 30 istek
        limit_req zone=webhook burst=10 nodelay;
        
        # Sadece POST'a izin ver
        limit_except POST {
            deny all;
        }
        
        # Body boyutunu sınırla (büyük payload saldırılarına karşı)
        client_max_body_size 1m;
        
        # Gereksiz header'ları gizle
        proxy_hide_header X-Powered-By;
        
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        # Timeout'ları ayarla
        proxy_connect_timeout 5s;
        proxy_send_timeout 10s;
        proxy_read_timeout 10s;
    }
}

Loglama ve İzleme

Güvenlik olaylarını loglamak hem debugging için hem de sonradan analiz için kritik. Ama dikkat: secret’ı ve hassas payload içeriğini loglamamak gerekiyor.

import logging
import json
from datetime import datetime

# Yapılandırılmış loglama
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s %(levelname)s %(message)s',
    handlers=[
        logging.FileHandler('/var/log/webhook-server/security.log'),
        logging.StreamHandler()
    ]
)

logger = logging.getLogger(__name__)

def log_webhook_event(event_type, source_ip, delivery_id, status, reason=None):
    """Webhook olayını güvenli şekilde logla"""
    log_entry = {
        'timestamp': datetime.utcnow().isoformat(),
        'event_type': event_type,
        'source_ip': source_ip,
        'delivery_id': delivery_id,
        'status': status,
        'reason': reason
    }
    
    if status == 'rejected':
        logger.warning(f"WEBHOOK_REJECTED {json.dumps(log_entry)}")
    else:
        logger.info(f"WEBHOOK_ACCEPTED {json.dumps(log_entry)}")

# Kullanım
@app.route('/webhook/github', methods=['POST'])
def github_webhook():
    delivery_id = request.headers.get('X-GitHub-Delivery')
    source_ip = request.headers.get('X-Real-IP', request.remote_addr)
    
    if not verify_github_signature(request.get_data(), request.headers.get('X-Hub-Signature-256')):
        log_webhook_event('github', source_ip, delivery_id, 'rejected', 'invalid_signature')
        abort(401)
    
    log_webhook_event('github', source_ip, delivery_id, 'accepted')
    return {'status': 'ok'}, 200

Secret Rotasyonu

Production ortamında secret’ları periyodik olarak döndürmek gerekir. Ama bunu downtime olmadan yapmak için transition period gerekiyor.

import os

# Eski ve yeni secret'ları birlikte destekle
OLD_SECRET = os.environ.get('WEBHOOK_SECRET_OLD', '').encode('utf-8')
NEW_SECRET = os.environ.get('WEBHOOK_SECRET_NEW', '').encode('utf-8')

def verify_with_rotation(payload, signature):
    """Geçiş döneminde iki secret'ı da dene"""
    secrets_to_try = []
    
    if NEW_SECRET:
        secrets_to_try.append(('new', NEW_SECRET))
    if OLD_SECRET:
        secrets_to_try.append(('old', OLD_SECRET))
    
    for secret_version, secret in secrets_to_try:
        mac = hmac.new(secret, msg=payload, digestmod=hashlib.sha256)
        computed = 'sha256=' + mac.hexdigest()
        
        if hmac.compare_digest(signature, computed):
            if secret_version == 'old':
                logger.warning("[SECURITY] Eski secret kullanılıyor, rotasyon tamamlanmadı")
            return True
    
    return False

Rotasyon sürecinde şu adımları takip etmek mantıklı:

  • Yeni secret’ı üretin ve WEBHOOK_SECRET_NEW olarak deploy edin
  • Sağlayıcı tarafında yeni secret’ı güncelleyin
  • Birkaç saat hem eski hem yeni secret’ı kabul edin
  • Eski secret’ı loglardan temizlendikten sonra WEBHOOK_SECRET_OLD değişkenini kaldırın

Gerçek Dünya Senaryosu: CI/CD Pipeline Güvenliği

Bir e-ticaret şirketinde çalıştığınızı düşünün. GitHub’daki her push, production deployment tetikliyor. Bu webhook güvenli değilse, saldırgan sahte bir push eventi göndererek kötü amaçlı kod deploy edebilir.

Bu senaryoda minimum güvenlik katmanları şunlar olmalı:

  • HMAC-SHA256 imza doğrulama her zaman zorunlu
  • Timestamp kontrolü 5 dakikalık pencereyle
  • Delivery ID takibi Redis’te replay önleme
  • Branch whitelist sadece main ve release/* branch’lerinden deploy
  • Rate limiting Nginx katmanında dakikada 10 istek
  • Ayrı bir servis hesabı webhook server için minimum yetkiyle
  • Log monitoring başarısız doğrulama sayısı belirli eşiği geçince alarm

Bu katmanların hepsi birlikte çalıştığında, webhook endpoint’iniz saldırılara karşı oldukça güçlü hale gelir.

Sonuç

Webhook güvenliği, “çalışıyor mu?” sorusunun ötesinde “güvenli mi?” sorusunu sürekli sormayı gerektiriyor. HMAC imza doğrulaması temel bir gereklilik, üzerine timestamp kontrolü ve replay protection eklendiğinde ciddi bir güvenlik katmanı oluşturuyor.

En sık yapılan hatayı tekrar vurgulamak istiyorum: secret’ları kaynak koduna yazmak. Environment variable ya da Vault kullanmak birkaç saatlik iş, ama bu küçük yatırım büyük bir güvenlik açığını kapatıyor.

Production’da dikkat etmeniz gereken özet liste:

  • hmac.compare_digest() veya crypto.timingSafeEqual() kullanın, asla == kullanmayın
  • Raw body üzerinden imzalayın, parse edilmiş veri üzerinden değil
  • Secret’ları environment variable’lardan okuyun
  • Timestamp kontrolü ile replay attack’ı önleyin
  • Başarısız doğrulama denemelerini loglayın ve izleyin
  • Secret rotasyonu için geçiş dönemi planlayın
  • Nginx veya benzeri bir reverse proxy arkasında rate limiting uygulayın

Webhook endpoint’inizin bir gün saldırıya maruz kalacağını varsayarak savunma katmanlarınızı kurun. Paranoyak olmak değil bu, sadece iyi bir sysadmin olmak.

Bir yanıt yazın

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