Webhook imzasını üretim seviyesinde (production-grade) doğrula
`X-Moonborn-Signature` HMAC'ini doğrula, yeniden oynatma (replay) saldırılarını zaman damgası (timestamp) koruyucusuyla reddet, sır döndürme (secret rotation) bekleme penceresini (grace window) yönet.
Bir webhook uç noktasının üretim seviyesinde olması için üç şeyi doğru yapması gerekir: HMAC imzasını doğru hesaplamak, zaman damgasını kontrol ederek yeniden oynatma saldırısını engellemek ve sır döndürme sırasında bekleme penceresinde iki imzayı da kabul etmek. Bu rehber üçünü de Node.js + Python ile gösterir.
Eğitim seviyesinde webhook akışını Voice drift'i ele al rehberinde gördün; burada üretim seviyesinde uygulama detayı var.
Bu rehberi bitirdiğinde
X-Moonborn-Signaturebaşlığının (header) biçimini (format) ayrıştırabileceksin.- HMAC-SHA256 imzasını zamanlama-güvenli (timing-safe) olarak karşılaştırabileceksin.
- Yeniden oynatma saldırılarını zaman damgası penceresi kontrolüyle reddedebileceksin.
- Sır döndürme bekleme penceresinde iki imzayı (eski + yeni) destekleyebileceksin.
- Ham gövde (raw body) kullanmanın neden zorunlu olduğunu (yeniden serileştirme — re-serialize — hatasını) bileceksin.
Ön koşul: Bir webhook uç noktası kurulumu + imzalama sırrı (signing secret). Webhook kurulumu ve olay yükü (payload) örnekleri için Voice drift'i ele al eğitimine bak.
Başlık biçimi
Her webhook teslimi şu biçimde bir X-Moonborn-Signature başlığı taşır:
X-Moonborn-Signature: t=1747497600,v1=2c4f8a...Alanlar:
| Alan | Anlamı |
|---|---|
t | İmzanın oluşturulduğu Unix zaman damgası (saniye) |
v1 | İmzalama sırrı ile anahtarlanmış {t}.{rawBody} dizesinin hex biçimli HMAC-SHA256'sı |
Tam doğrulama akışı:
- Başlığı ayrıştır →
tvev1'i çıkar. tçok eskiyse (örn. 5 dakikadan fazla) reddet.- Beklenen imzayı
HMAC-SHA256(secret, "{t}.{rawBody}")ile hesapla. - Beklenen ile gelen
v1'i zamanlama-güvenli karşılaştır.
Üretim seviyesinde doğrulama
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_AGE_SECONDS = 300; // 5 dakika
export function verifyMoonbornSignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('=', 2) as [string, string]),
);
const t = Number(parts['t']);
const v1 = parts['v1'];
if (!Number.isFinite(t) || typeof v1 !== 'string') return false;
// Yeniden oynatma penceresi koruyucusu
const ageSec = Math.abs(Date.now() / 1000 - t);
if (ageSec > MAX_AGE_SECONDS) return false;
// Hesapla
const signed = `${t}.${rawBody}`;
const expected = createHmac('sha256', secret).update(signed).digest('hex');
// Zamanlama-güvenli karşılaştırma
if (v1.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}Neden ham gövde (raw body) kullanmak zorundasın
import express from 'express';
const app = express();
// Webhook uç noktası için JSON ayrıştırmayı değil ham gövdeyi kullan
app.post(
'/webhooks/moonborn',
express.raw({ type: 'application/json' }),
(req, res) => {
const rawBody = req.body.toString('utf8'); // Buffer → string
const signature = req.headers['x-moonborn-signature'] as string;
if (!verifyMoonbornSignature(rawBody, signature, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
// Olayı işle
res.status(202).end();
},
);Yeniden oynatma penceresi — neden var, ne kadar sıkı olmalı
t yaşı reddi olmadan, geçerli bir teslimi yakalayan bir saldırgan onu sonsuza dek yeniden oynatabilir. Zaman damgası koruyucusu bu pencereyi sınırlar.
MAX_AGE_SECONDS | Senaryo |
|---|---|
300 (varsayılan, 5 dakika) | Çoğu durum için makul; cömert saat kayması (clock skew) toleransı |
60 (1 dakika) | Bölgesel alıcı; aynı bölgede, düşük gecikme süresi |
900 (15 dakika) | Yüksek gecikme süresi veya çevrimdışı arabellek (offline buffering) olasılığı |
Sır döndürme (secret rotation) bekleme penceresi
İmzalama sırrı döndürüldüğünde, geçiş döneminde teslimler iki v1= girdisi taşıyabilir — biri eski sırla, biri yeni sırla. Her iki adayı da kabul et:
export function verifyWithRotation(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
const parts = signatureHeader.split(',');
const t = parts.find((p) => p.startsWith('t='))?.slice(2);
const v1Candidates = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t) return false;
const tNum = Number(t);
if (!Number.isFinite(tNum) || Math.abs(Date.now() / 1000 - tNum) > 300) return false;
const signed = `${t}.${rawBody}`;
const expected = createHmac('sha256', secret).update(signed).digest('hex');
return v1Candidates.some((v) => {
if (v.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(v, 'hex'), Buffer.from(expected, 'hex'));
});
}Bekleme süresi varsayılanı: api.webhooks.secret_rotation_grace_minutes = 60. Bu pencere içinde iki imza paralel gelir; pencere dolunca sadece yeni sırla imzalanan teslimler gelir.
Doğrulamada yaygın hata kaynakları
Plan gereksinimi
Webhook'lar Team ve üstü; doğrulama mantığı her planda aynıdır.
İlgili
Webhook akışını uçtan uca kurma; imza doğrulama bu rehberle birleşir.
Teslim panosu, ölü mektup kuyruğu, yeniden deneme akışı.
Tüm olay tipleri ve yük şemaları.
Denetim + test webhook'larını kalite kontrol kuyruğuna bağlamak.