Ödeme webhook’ları: imza, tekrar oynatma ve idempotency anahtarı
İmzayı ham gövde üzerinden doğrulayın, eski timestamp'i reddedin, event id'yi saklayın, tutara güvenmeyin. Kompakt handler ve çevresindeki kontroller.
Callback uç noktası, uygulamada bir yabancının para hareket ettiren cümleyi kurabildiği tek yerdir. Bir POST gelir ve gövde 4711 numaralı siparişin ödendiğini söyler. İncelemem istenen kurulumların çoğu o gövdeyi parse eder, siparişi bulur, ödendi yazar ve 200 döner. Bunu internete açık bırakabileceğiniz bir uç noktadan ayıran şey üç küçük ekleme.
Gerçekte ne oluyor
Sağlayıcı isteği imzalar. Gövdenin tam baytlarını alır, genelde bir zaman damgasıyla birleştirir ve yalnızca ikinizde bulunan bir secret ile HMAC hesaplar. Başlık bu özeti ve yanında zaman damgasını taşır. Siz aynı değeri yeniden hesaplar ve karşılaştırırsınız.
Pratikte dört şey bozulur ve kabaca şu sırayla bozulur.
- Web framework'ü gövdeyi, kodunuz görmeden önce nesneye çevirir. Yeniden serileştirilmiş kopya üzerinden doğrulama tutmaz, çünkü anahtar sırası ve boşluklar değişmiştir. Geliştirici de çoğu zaman bunu "hiçbir şey doğrulamayarak" aşar.
- Karşılaştırma normal bir eşitlik kontrolüdür ve iki bayt ayrıştığı anda döner. Tekrarlanan istekler bu süre farkını, özeti bayt bayt tahmin etmenin yoluna çevirir. Yavaş bir saldırıdır ve gerçek bir saldırıdır.
- İmzanın son kullanma tarihi yoktur. Geçerli bir geçmiş isteği ele geçiren, mesela proxy log'undan, hata raporundan veya izleme kaydından alan biri onu tekrar gönderebilir. Doğrulamadan geçer ve sipariş ikinci kez ödenir.
- Gövdedeki tutar olduğu gibi kabul edilir. Alan, kendi kaydınızla karşılaştırılmadan güvenilirse, değiştirilmiş veya tutmayan bir ödeme tam ödeme sayılır.
Nasıl görülür
İki curl çağrısı nerede durduğunuzu söyler. Birincisi hiç imzası olmayan bir gövde gönderir:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://app.ornek.com/webhooks/payments \
-H "content-type: application/json" \
-d '{"event_id":"evt_1","order_id":4711,"status":"paid","amount":100}'Bu 200 basıyorsa uç nokta hiçbir şey doğrulamıyor ve sipariş şu anda ödendi. İkincisi, log'unuzdan gerçek bir geçmiş isteği alıp hiç değiştirmeden tekrar gönderir. Orada 200 görmek ve hesapta ikinci bir alacak oluşması, ne replay koruması ne de idempotency olduğu anlamına gelir.
Log'lardayken içinde ne olduğuna da bakın. Bir ödeme callback'inin ham gövdesini olduğu gibi saklamak, tutmaya niyetlenmediğiniz bir müşteri verisi kopyası demektir ve bu, yanlış kurulmuş bir root yüzünden gizli dosyanın sunulmasıyla aynı sınıftan bir açıklık. Log'a payload'ı değil, event id'yi ve doğrulama sonucunu yazın.
Çözüm
Ham gövdeyi yalnızca o route'ta yakalayın, doğrulayın, sonra parse edin. Express tarzı bir uygulamada bu bir satır middleware ve yirmi satır kadar handler demek:
import crypto from 'node:crypto';
app.post('/webhooks/payments',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.get('x-signature') || '';
const timestamp = req.get('x-timestamp') || '';
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > 300) {
return res.status(400).send('stale');
}
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
const stored = await db.query(
`INSERT INTO payment_events (id, type, payload, received_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (id) DO NOTHING`,
[event.id, event.type, event],
);
if (stored.rowCount === 1) await queue.add('payment-event', { id: event.id });
return res.status(200).send('ok');
});Bunu dört karar olarak okuyun. Zaman damgası önce ve ucuz biçimde kontrol edilir, böylece tekrar oynatılan istek seli HMAC hesabına hiç ulaşmaz. Özet, zaman damgasını ve ham baytları birlikte kapsar; bir imzanın yeni bir zaman damgasıyla yeniden kullanılmasını engelleyen şey budur. timingSafeEqual sabit zamanda karşılaştırır, öncesinde uzunluk kontrolü vardır çünkü uzunluklar farklıysa fonksiyon hata atar. ON CONFLICT DO NOTHING ile yapılan insert ise uç noktanın tamamını idempotent kılar: tekrarlanan teslim satırı zaten bulur, kuyruğa bir şey atmaz ve yine 200 döner ki sağlayıcı denemeyi bıraksın.
Handler orada biter. Geri kalan her şey, dikkatli olmaya vakti olan worker'da yapılır:
async function processPaymentEvent({ id }) {
const { payload } = await db.one(`SELECT payload FROM payment_events WHERE id = $1`, [id]);
const order = await db.one(`SELECT id, total, currency, status FROM orders WHERE id = $1`,
[payload.order_id]);
if (order.status === 'paid') return;
if (order.total !== payload.amount || order.currency !== payload.currency) {
await flagForReview(order.id, 'tutar uyuşmuyor');
return;
}
await markPaid(order.id, id);
}Siparişi ödeme başlamadan önce siz oluşturdunuz ve toplamı siz hesapladınız. Callback o sipariş hakkında bir iddiadır, worker da iddiayı kontrol eder. Uyuşmazlık fırlatılacak bir hata değil, bir insanın bakması gereken bir vakadır; o kadar nadir olmalı ki gerçekten bakan biri çıksın.
Worker'ın tekrar deneme davranışı da en az uç nokta kadar önemli. Sağlayıcı en az bir kez teslim ettiğine ve kendi kuyruğunuz da aynısını yaptığına göre, işin tam olarak bir kez yapma yazısındaki özelliklere ihtiyacı var. Event id zaten doğal bir idempotency anahtarı, onu öyle kullanın.
Çalıştığını nasıl doğrularsınız
Dört istek, dört farklı cevap:
BODY='{"id":"evt_test_1","type":"payment.succeeded","order_id":4711,"amount":100}'
URL=https://app.ornek.com/webhooks/payments
send() {
TS=$1
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -s -o /dev/null -w "$2: %{http_code}\n" -X POST "$URL" \
-H "content-type: application/json" \
-H "x-timestamp: $TS" -H "x-signature: ${3:-$SIG}" -d "$BODY"
}
send "$(date +%s)" gecerli # 200
send "$(date +%s)" bozuk deadbeef # 401
send "$(( $(date +%s) - 7200 ))" eski # 400
send "$(date +%s)" tekrar # 200Asıl kontrol dördüncüsü ve onu kanıtlayan şey durum kodu değil. Ardından satırları sayın: payment_events tablosunda o id için tek satır, siparişte tek ödeme, kuyrukta tek iş olmalı.
Sonra güvenlik ağını kurun. Günde bir kez sağlayıcının bir önceki güne ait kapanmış ödeme listesini çekip kendi kaydınızla karşılaştırın:
SELECT o.id, o.total, o.status, p.id AS event_id
FROM orders o
LEFT JOIN payment_events p ON p.payload->>'order_id' = o.id::text
WHERE o.created_at::date = current_date - 1
AND (p.id IS NULL OR o.status <> 'paid');Mutabakat, uç noktanın kaçırdığını yakalayan tek kontroldür: hiç gelmemiş bir teslim, kesinti sırasında reddettiğiniz bir webhook, sağlayıcı tarafında yapılmış ama veritabanınızın haberi olmayan bir iade. İmza tek bir mesajı doğrular, mutabakat ise günü doğrular.
Nelere dikkat etmeli
- İş kuralı sorunlarında 500 dönmeyin. 2xx dışındaki cevap sağlayıcıya "tekrar dene" der; bilinmeyen sipariş veya tutmayan tutar kaydedilip 200 ile cevaplanmalı, olayı kaydedememek gibi gerçek bir hata ise gürültüyle başarısız olup tekrar denenmeli.
- IP beyaz listesi iyi bir ikinci kilit, kötü bir birinci kilittir. Adresler haber verilmeden değişir ve doğru adresten gelen istek de hâlâ kimliği doğrulanmamış bir istektir.
- Secret rotasyonu sırasında hem yeni hem eski anahtarı destekleyin, ikisinden biri doğruluyorsa isteği kabul edin. Aksi hâlde rotasyon, reddedilen ödemelerden oluşan bir pencere demektir.
- Test ve canlı olayları aynı uç noktaya düşebilir. Gövdedeki ortam bayrağını kontrol edip ait olmayanı reddedin; yoksa bir test ödemesi gerçek bir siparişi ödendi yapar.
- Olaylar sırasız gelir. Bir iade, iade ettiği çekimden önce size ulaşabilir; worker, henüz görmediği bir kaydın olayını da önce gelen teslim raporunu karşıladığı gibi karşılayabilmeli.
Ödeme callback'i, imzalanmış olmasının dışında güvenilmeyen bir istektir. Baytları doğrulayın, eski olanı reddedin, event id'yi bir kez yazın ve içindeki sayıları kendi oluşturduğunuz kayda karşı doğrulanacak veriler olarak görün. Hiçbiri bir saatlik işten fazla değil; karşılığında uygulamanın en cazip uç noktası en sıkıcı uç noktalarından birine dönüşüyor.
Sorular ve cevaplar
- Kod doğru görünüyorken imza neden tutmuyor?
- Neredeyse her zaman framework gövdeyi JSON'a çevirdiği ve kod yeniden serileştirilmiş hâli doğruladığı için. Parse edip tekrar yazma döngüsünde anahtar sırası, boşluklar ve unicode kaçışları değişir; imza ise orijinal baytlar üzerinden hesaplanmıştır. Ham gövdeyi sadece o route'ta yakalayın, önce doğrulayın, sonra parse edin.
- İmza geçerliyse zaman damgası kontrolü gerçekten gerekli mi?
- Evet, çünkü imza sonsuza kadar geçerli kalır. Geçmiş bir isteğin kopyasını ele geçiren herkes, ister proxy log'undan ister bir izleme aracından ister eski bir yedekten olsun, onu tekrar gönderebilir ve doğrulamadan geçer. İmzalanan veriye dahil edilmiş bir zaman damgasına karşı beş dakikalık bir tolerans penceresi, bu tekrarı işe yaramaz hâle getirir.
- Webhook handler'ı işi doğrudan yapmalı mı?
- Hayır. Sağlayıcılar birkaç saniye içinde cevap bekler ve geciktiğinizde tekrar denerler; kredi düşen, mail gönderen ve rapor yazan bir handler kendi eliyle tekrarlanan teslimler üretir. Doğrulayın, olayı kaydedin, 200 dönün, gerisini worker yapsın.
- Callback'teki tutara güvenebilir miyim?
- Onu doğrulanacak bir iddia sayın, olgu saymayın. Tutarı ve para birimini ödeme başlamadan önce oluşturduğunuz siparişle karşılaştırın, yalnızca eşleşirse siparişi ödendi işaretleyin. Sağlayıcı sorgulama uç noktası sunuyorsa ödemeyi oradan geri okumak, gövdeye inanmaktan daha sağlamdır.