Webhook HMAC di Rust biasanya gagal bukan karena algoritmenya sulit, tetapi karena detail implementasi yang mudah terlewat: body yang sudah berubah setelah parsing JSON, timestamp tidak divalidasi, atau event diproses dua kali saat provider melakukan retry. Jika salah satu bagian ini longgar, Anda bisa menolak webhook yang valid atau justru menerima request palsu.
Untuk endpoint webhook yang aman, urutannya umumnya adalah: baca raw request body, ambil header yang relevan, verifikasi timestamp dalam replay window, hitung HMAC dari payload yang tepat, bandingkan signature dengan constant-time comparison, lalu lakukan deduplikasi berdasarkan event ID sebelum memproses bisnis. Artikel ini fokus pada implementasi praktis di Rust backend.
Kontrak header webhook yang umum
Setiap provider punya format berbeda, tetapi pola kontraknya sering mirip. Biasanya request membawa beberapa informasi berikut:
- Signature header, misalnya
X-Signature,X-Webhook-Signature, atau format vendor sepertit=...,v1=.... - Timestamp header untuk mencegah replay, misalnya
X-Timestamp. - Event ID atau delivery ID, misalnya
X-Event-Id, untuk deduplikasi saat retry. - Secret tidak pernah dikirim; secret disimpan hanya di server penerima.
Beberapa provider menandatangani raw body saja. Yang lain menandatangani gabungan timestamp + raw body. Ada juga yang mengirim lebih dari satu signature selama proses rotasi secret. Karena itu, baca dokumentasi provider Anda dengan teliti dan jangan menebak format payload yang ditandatangani.
Aturan praktis: verifikasi harus memakai byte persis yang diterima dari jaringan, bukan string JSON yang sudah diparse lalu diserialisasi ulang.
Urutan verifikasi yang benar
Urutan ini penting karena mempengaruhi keamanan, performa, dan akurasi error handling.
- Baca raw request body sebagai bytes.
- Ambil header yang dibutuhkan: signature, timestamp, event ID, dan mungkin key ID.
- Validasi timestamp dalam replay window, misalnya 5 menit.
- Bangun payload yang ditandatangani sesuai kontrak provider.
- Hitung HMAC dengan secret yang benar.
- Bandingkan signature dengan perbandingan konstan waktu.
- Cek idempotency / deduplikasi menggunakan event ID atau delivery ID.
- Baru parse JSON dan jalankan logika bisnis.
Mengapa parse JSON diletakkan setelah verifikasi? Karena parser bisa mengubah representasi data. Misalnya, spasi, urutan key object, atau normalisasi Unicode bisa berubah saat payload dibaca sebagai struktur JSON lalu ditulis ulang. Jika provider menandatangani raw bytes, perubahan ini membuat HMAC tidak cocok meski payload semantisnya sama.
Kenapa raw request body wajib dipakai
HMAC bekerja di level byte. Jika provider menandatangani byte tertentu, Anda harus menghitung HMAC atas byte yang sama. Kesalahan yang sering terjadi:
- Body dibaca sebagai JSON lalu diserialisasi ulang sebelum diverifikasi.
- Framework otomatis mengubah encoding atau line ending.
- Body dibaca dua kali, tetapi stream request hanya bisa dikonsumsi sekali.
- Whitespace dihapus atau field diurutkan ulang.
Contoh masalah klasik:
// Salah secara konsep jika provider menandatangani raw bytes JSON yang asli
let value: serde_json::Value = serde_json::from_slice(&body_bytes)?;
let canonical = serde_json::to_vec(&value)?; // bisa berbeda dari body asli
let mac = hmac_sha256(secret, &canonical);Walaupun isi JSON tampak identik, hasil serialisasi ulang bisa berbeda dari body awal. Untuk webhook HMAC di Rust, simpan dulu body mentah, verifikasi signature, lalu parse hanya jika lolos.
Implementasi verifikasi Webhook HMAC di Rust
Berikut contoh implementasi dengan pendekatan yang tetap mudah dipindahkan ke framework lain. Contoh ini mengasumsikan provider menandatangani string {timestamp}.{raw_body} menggunakan HMAC-SHA256, dan mengirim header:
X-Webhook-TimestampX-Webhook-Signatureberisi hex digestX-Webhook-Event-Id
Jika provider Anda memakai format lain, ubah fungsi pembentuk payload dan parser header signaturenya.
Dependensi inti
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use hex::decode as hex_decode;
type HmacSha256 = Hmac<Sha256>;Fungsi verifikasi signature dan replay window
#[derive(Debug)]
pub enum VerifyError {
MissingHeader(&'static str),
InvalidHeader(&'static str),
TimestampSkew,
InvalidSignature,
}
pub struct VerifiedWebhook {
pub event_id: String,
pub timestamp: i64,
pub raw_body: Vec<u8>,
}
pub fn verify_webhook(
raw_body: Vec<u8>,
timestamp_header: Option<&str>,
signature_header: Option<&str>,
event_id_header: Option<&str>,
secret: &[u8],
replay_window_secs: i64,
now_epoch_secs: i64,
) -> Result<VerifiedWebhook, VerifyError> {
let timestamp_str = timestamp_header.ok_or(VerifyError::MissingHeader("X-Webhook-Timestamp"))?;
let signature_hex = signature_header.ok_or(VerifyError::MissingHeader("X-Webhook-Signature"))?;
let event_id = event_id_header.ok_or(VerifyError::MissingHeader("X-Webhook-Event-Id"))?;
let timestamp: i64 = timestamp_str
.parse()
.map_err(|_| VerifyError::InvalidHeader("X-Webhook-Timestamp"))?;
let age = (now_epoch_secs - timestamp).abs();
if age > replay_window_secs {
return Err(VerifyError::TimestampSkew);
}
let mut signed_payload = timestamp.to_string().into_bytes();
signed_payload.push(b'.');
signed_payload.extend_from_slice(&raw_body);
let mut mac = HmacSha256::new_from_slice(secret)
.map_err(|_| VerifyError::InvalidSignature)?;
mac.update(&signed_payload);
let received_sig = hex_decode(signature_hex)
.map_err(|_| VerifyError::InvalidHeader("X-Webhook-Signature"))?;
mac.verify_slice(&received_sig)
.map_err(|_| VerifyError::InvalidSignature)?;
Ok(VerifiedWebhook {
event_id: event_id.to_string(),
timestamp,
raw_body,
})
}
pub fn unix_now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time went backwards")
.as_secs() as i64
}Poin penting dari implementasi di atas:
- Timestamp diverifikasi lebih dulu untuk mengurangi peluang replay lawas.
- Payload HMAC dibentuk dari timestamp dan raw body, bukan JSON yang sudah diparse.
mac.verify_slice()digunakan agar perbandingan signature tidak dilakukan dengan string biasa yang berisiko bocor melalui timing.- Fungsi menerima
now_epoch_secssebagai parameter, sehingga mudah diuji tanpa bergantung pada jam sistem langsung.
Contoh handler dengan Axum
Jika Anda memakai Axum, pola amannya adalah mengambil header lebih dulu dan membaca body sebagai Bytes. Setelah verifikasi lolos, baru parse JSON.
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::Deserialize;
#[derive(Clone)]
struct AppState {
webhook_secret: Vec<u8>,
}
#[derive(Deserialize)]
struct EventPayload {
id: String,
event_type: String,
}
async fn webhook_handler(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let raw_body = body.to_vec();
let ts = headers
.get("x-webhook-timestamp")
.and_then(|v| v.to_str().ok());
let sig = headers
.get("x-webhook-signature")
.and_then(|v| v.to_str().ok());
let event_id = headers
.get("x-webhook-event-id")
.and_then(|v| v.to_str().ok());
let verified = match verify_webhook(
raw_body,
ts,
sig,
event_id,
&state.webhook_secret,
300,
unix_now_secs(),
) {
Ok(v) => v,
Err(_) => return StatusCode::UNAUTHORIZED.into_response(),
};
// Deduplikasi event_id sebaiknya dilakukan di storage terpisah sebelum bisnis diproses.
let payload: EventPayload = match serde_json::from_slice(&verified.raw_body) {
Ok(v) => v,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
// Proses bisnis Anda di sini, idealnya asynchronous dan idempotent.
let _ = payload;
StatusCode::OK.into_response()
}Contoh ini sengaja sederhana. Di sistem nyata, Anda biasanya ingin memisahkan tahap ingest webhook, deduplikasi, enqueue ke worker, lalu ACK cepat ke provider.
Replay window: mencegah request lama diputar ulang
Signature yang valid belum cukup. Jika penyerang memperoleh request lama yang valid dan dapat mengirimkannya ulang, endpoint Anda bisa memproses event yang sama berulang kali. Karena itu, banyak provider menyertakan timestamp dalam material yang ditandatangani.
Berapa replay window yang masuk akal?
Nilai umum adalah beberapa menit, sering kali sekitar 5 menit. Window yang terlalu kecil meningkatkan risiko webhook valid ditolak ketika ada latensi jaringan, antrean internal provider, atau clock skew. Window yang terlalu besar memperlebar kesempatan replay. Pilih nilai yang cukup longgar untuk kondisi operasional Anda, lalu ukur dari log nyata.
Clock skew dan sumber waktu
Masalah yang sering terjadi adalah jam server meleset. Jika waktu sistem Anda tidak sinkron, banyak request valid akan dianggap kedaluwarsa. Praktiknya:
- Pastikan sinkronisasi waktu host atau container berjalan baik.
- Log selisih antara
nowdantimestampuntuk debugging. - Jika provider memang kadang terlambat, pertimbangkan sedikit toleransi tambahan, tetapi jangan menghilangkan validasi timestamp.
Catatan: replay window mencegah replay lama, tetapi tidak menggantikan deduplikasi. Request yang sama masih bisa datang dua kali dalam window yang valid karena retry atau jaringan.
Retry provider dan deduplikasi event
Banyak provider akan mengirim ulang webhook bila endpoint Anda timeout, mengembalikan 5xx, atau bahkan saat terjadi gangguan jaringan di tengah respons. Artinya, menerima event yang sama lebih dari sekali adalah perilaku normal, bukan edge case langka.
Strategi idempotency yang aman
Gunakan event ID atau delivery ID dari provider sebagai kunci deduplikasi. Simpan status bahwa event tersebut sudah pernah diterima atau diproses. Kunci ini harus diletakkan di storage yang mendukung operasi atomik atau constraint unik.
Pseudocode penyimpanan idempotency:
function handleWebhook(event_id, payload):
inserted = insert into webhook_receipts(event_id, status='received', received_at=now)
on conflict(event_id) do nothing
if not inserted:
return 200 // duplicate delivery, sudah pernah diterima
try:
process_business_logic(payload)
update webhook_receipts set status='processed', processed_at=now where event_id=event_id
return 200
catch transient_error:
update webhook_receipts set status='failed_retryable', last_error=... where event_id=event_id
return 500
catch permanent_error:
update webhook_receipts set status='failed_permanent', last_error=... where event_id=event_id
return 200 or 400 depending on contractPrinsipnya:
- Insert dulu, proses kemudian untuk menutup race condition antar request paralel.
- Gunakan unique constraint pada
event_id. - Bedakan duplicate delivery dengan retryable failure.
- Pastikan logika bisnis juga idempotent. Deduplikasi di ingress membantu, tetapi side effect downstream tetap bisa dobel jika desainnya tidak aman.
Out-of-order event
Webhook tidak selalu tiba sesuai urutan waktu kejadian. Jangan berasumsi event updated pasti datang setelah created. Jika urutan penting:
- Gunakan versi objek atau timestamp domain dari provider bila tersedia.
- Ambil state terbaru dari API provider sebelum menulis keputusan final.
- Desain handler agar aman terhadap event yang datang terlambat.
Deduplikasi tidak menyelesaikan out-of-order. Itu masalah berbeda.
Desain endpoint yang lebih tahan produksi
ACK cepat, proses di background
Jika provider mengharapkan respons cepat, lebih aman memverifikasi, deduplikasi, simpan event mentah, enqueue ke worker, lalu kembalikan 200 OK. Ini mengurangi retry yang tidak perlu karena proses bisnis lambat.
Simpan payload mentah untuk audit
Menyimpan raw body, header utama, hasil verifikasi, dan waktu terima sangat membantu saat debugging. Saat signature gagal di produksi, Anda perlu tahu byte apa yang benar-benar diterima, bukan hanya hasil parse.
Secret rotation
Rotasi secret sering terlupakan. Beberapa provider mendukung masa transisi dengan dua secret aktif. Strateginya:
- Coba verifikasi dengan secret baru.
- Jika gagal, coba secret lama selama masa transisi.
- Log secret mana yang sukses, tanpa pernah menulis nilai secret ke log.
Jangan menyimpan secret di source code. Ambil dari secret manager atau environment yang aman.
Checklist produksi untuk webhook HMAC di Rust
- Endpoint membaca raw request body sebelum parsing apa pun.
- Signature diverifikasi dengan HMAC sesuai format provider.
- Perbandingan signature memakai constant-time verification.
- Timestamp divalidasi dalam replay window yang jelas.
- Clock server sinkron dan skew dimonitor.
- Event ID atau delivery ID disimpan untuk deduplikasi.
- Storage idempotency memakai unique constraint atau operasi atomik.
- Handler mengembalikan status HTTP yang sesuai kontrak provider.
- Payload mentah dan header penting disimpan atau dicatat secukupnya untuk audit.
- Secret mendukung rotation tanpa downtime.
- Logika bisnis downstream idempotent atau terlindung dari side effect ganda.
- Tersedia pengujian untuk payload valid, signature salah, timestamp kedaluwarsa, dan duplicate delivery.
Observability dan debugging
Untuk webhook, observability bukan sekadar nice-to-have. Tanpanya, Anda sulit membedakan apakah kegagalan berasal dari provider, jaringan, clock server, parsing, atau secret yang salah.
Apa yang perlu dicatat
- Request ID internal dan event ID provider.
- Status verifikasi: missing header, skew, invalid signature, duplicate, processed.
- Selisih timestamp terhadap waktu lokal.
- Ukuran body, bukan isi sensitifnya secara penuh jika tidak perlu.
- Hasil enqueue atau pemrosesan downstream.
Metrik yang berguna
- Jumlah request webhook per provider.
- Rasio signature invalid.
- Rasio timestamp skew.
- Jumlah duplicate delivery.
- Latency verifikasi dan latency end-to-end pemrosesan.
- Jumlah retry dari provider yang teramati.
Tips debugging saat signature selalu gagal
- Pastikan Anda menghitung HMAC dari raw bytes asli.
- Periksa apakah provider memakai hex, base64, atau format header bertingkat seperti
t=...,v1=.... - Verifikasi apakah newline tambahan atau transformasi encoding ikut masuk.
- Pastikan secret yang dipakai sesuai environment yang mengirim webhook.
- Cek apakah timestamp ikut dimasukkan ke payload yang ditandatangani.
- Pastikan header dibaca persis, terutama jika ada proxy yang mengubah atau menghapus header tertentu.
Kesalahan umum yang sering terjadi
Webhook valid ditolak
- Body sudah diparse lalu diserialisasi ulang sebelum verifikasi.
- Clock server meleset sehingga replay window gagal.
- Secret salah karena tertukar antara staging dan production.
- Signature provider dikirim dalam base64, tetapi server menganggap hex.
- Header timestamp atau signature dibaca dengan nama yang salah.
- Request body stream sudah habis dibaca middleware lain.
Webhook palsu diterima
- Hanya memeriksa adanya header signature tanpa memverifikasi HMAC.
- Membandingkan signature dengan string biasa tanpa verifikasi yang aman.
- Tidak memvalidasi timestamp, sehingga request lama dapat diputar ulang.
- Tidak melakukan deduplikasi, sehingga replay dalam window valid tetap diproses berkali-kali.
- Menerima event hanya berdasarkan IP allowlist dan mengabaikan signature.
IP allowlist bisa membantu sebagai lapisan tambahan, tetapi jangan menggantikan verifikasi kriptografis. IP provider dapat berubah, dan jaringan internal Anda belum tentu menjamin keaslian payload.
Pengujian yang sebaiknya Anda miliki
- Unit test untuk fungsi verifikasi HMAC dengan fixture payload dan header yang diketahui valid.
- Test timestamp untuk request tepat di batas replay window.
- Test duplicate delivery dengan event ID yang sama dikirim dua kali.
- Test malformed header untuk format signature/timestamp yang rusak.
- Integration test yang memastikan body tidak berubah sebelum verifikasi.
Jika provider menyediakan tool CLI atau fitur pengiriman ulang webhook, manfaatkan itu untuk menguji jalur produksi secara realistis.
Penutup
Implementasi Webhook HMAC di Rust yang aman bergantung pada disiplin di detail: verifikasi atas raw request body, validasi timestamp untuk replay window, dan deduplikasi event agar retry tidak menimbulkan side effect ganda. Setelah itu, baru parse payload dan jalankan logika bisnis.
Jika Anda hanya mengambil satu aturan dari artikel ini, ambil yang ini: jangan pernah memverifikasi signature dari JSON yang sudah berubah. Sebagian besar bug webhook yang sulit dilacak bermula dari sana.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!