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_NEWolarak 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_OLDdeğ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
mainverelease/*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()veyacrypto.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.
