Verifikasi webhook signature di Nuxt 3 sering gagal dengan galat signature mismatch saat developer membaca data request menggunakan readBody(event). Masalah ini berakar pada serialisasi ulang JSON yang mengubah integritas byte payload asli sebelum proses hashing HMAC dilakukan.
Root Cause: Mengapa readBody() Merusak Signature HMAC
Penyedia webhook pihak ketiga (seperti Stripe, Midtrans, GitHub, atau Xendit) menandatangani payload HTTP menggunakan HMAC SHA-256 berdasarkan raw byte stream persis seperti yang ditransmisikan melalui jaringan. Nilai hash tersebut dimasukkan ke dalam HTTP header (misalnya x-webhook-signature).
Fungsi readBody(event) pada engine server Nitro secara otomatis mem-parsing body menjadi JavaScript Object (via JSON.parse). Ketika objek ini di-serialize kembali menjadi string untuk verifikasi HMAC via JSON.stringify(), representasi byte-nya hampir dipastikan berubah. Perubahan kecil seperti penghapusan whitespace, perbedaan urutan key, escaping karakter UTF-8, atau format floating point akan menghasilkan digest SHA-256 yang sama sekali berbeda. Akibatnya, verifikasi signature kriptografis selalu gagal.
Solusinya adalah mempertahankan string byte mentah menggunakan readRawBody(event) untuk kalkulasi HMAC, lalu melakukan JSON.parse() secara eksplisit hanya setelah verifikasi kriptografis lolos.
Implementasi server/api/webhook.post.ts
Berikut adalah implementasi handler webhook di Nuxt 3 Nitro yang memverifikasi signature secara timing-safe dan menerapkan proteksi idempotensi menggunakan unstorage bawaan Nitro.
import crypto from 'node:crypto'
export default defineEventHandler(async (event) => {
const signatureHeader = getHeader(event, 'x-signature')
if (!signatureHeader) {
throw createError({
statusCode: 400,
statusMessage: 'Missing signature header',
})
}
// 1. Ambil payload mentah tanpa parsing JSON otomatis
const rawBody = await readRawBody(event, 'utf-8')
if (!rawBody) {
throw createError({
statusCode: 400,
statusMessage: 'Empty payload body',
})
}
// 2. Verifikasi HMAC SHA-256 menggunakan timingSafeEqual
const secret = process.env.WEBHOOK_SECRET || 'secret-key'
const computedHash = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex')
const signatureBuffer = Buffer.from(signatureHeader, 'utf-8')
const computedBuffer = Buffer.from(computedHash, 'utf-8')
if (
signatureBuffer.length !== computedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, computedBuffer)
) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid webhook signature',
})
}
// 3. Parse JSON setelah raw body terverifikasi aman
const payload = JSON.parse(rawBody)
const eventId = payload.id || payload.event_id
if (!eventId) {
throw createError({
statusCode: 400,
statusMessage: 'Missing event identifier',
})
}
// 4. Idempotency Check via Nitro unstorage
// ponytail: memory storage ceiling; ganti mount driver ke Redis di nitro.config.ts untuk production multi-instance
const storage = useStorage('webhook')
const processedKey = `events:${eventId}`
if (await storage.hasItem(processedKey)) {
// Replay delivery: return 200 agar provider berhenti retry
return { status: 'acknowledged', duplicate: true }
}
// Simpan ID event dengan state processing
await storage.setItem(processedKey, {
receivedAt: new Date().toISOString(),
status: 'completed',
})
// 5. Eksekusi domain logic
await processWebhookBusinessLogic(payload)
return { status: 'success' }
})
async function processWebhookBusinessLogic(payload: Record<string, any>) {
// Implementasi bisnis spesifik (update DB, kirim email, dsb)
return true
}Kode di atas melewati: pemisahan layer storage terpusat (menggunakan unstorage default). Tambahkan mount driver Redis pada nitro.config.ts ketika aplikasi berjalan di lingkungan multi-server atau serverless tanpa shared memory.
Pola Idempotensi dengan unstorage
Penyedia webhook menggunakan strategi retry berkala saat endpoint lambat merespons atau mengembalikan status code selain 2xx. Tanpa mekanisme idempotensi, pengiriman berulang akan memicu duplicate charge, pemrosesan pesanan ganda, atau status flow yang inkonsisten.
Nitro menyediakan abstraksi key-value melalui unstorage yang dapat diakses melalui fungsi bawaan useStorage(). Konfigurasikan mount point pada nuxt.config.ts untuk menghubungkan storage ke backend persistence seperti Redis saat masuk fase production:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
storage: {
webhook: {
driver: 'redis',
host: process.env.REDIS_HOST || '127.0.0.1',
port: 6379,
ttl: 60 * 60 * 24 // Simpan riwayat event ID selama 24 jam
}
}
}
})Mapping Respons HTTP Standar
Penanganan status code pada webhook handler harus mengikuti kontrak standar:
- 400 Bad Request: Dikembalikan ketika raw payload kosong, header signature absen, atau hash tidak valid. Menandakan request rusak atau berasal dari pihak tak terotentikasi.
- 200 OK (Duplikasi Terdeteksi): Jika event ID telah tercatat di storage, handler harus mengembalikan status 200 OK. Jika mengembalikan error 4xx atau 5xx, sistem webhook pengirim akan terus mengulangi request (exponential backoff).
- 500 Internal Server Error: Hanya dikembalikan jika proses write database internal gagal dan Anda secara sengaja menginginkan provider mengirimkan retry payload.
Verifikasi Pengujian Menggunakan Vitest
Gunakan unit test berikut untuk memvalidasi alur verifikasi signature dan deteksi duplikasi pada Nitro route handler.
import { describe, it, expect, vi } from 'vitest'
import crypto from 'node:crypto'
describe('Webhook Handler Security & Idempotency', () => {
const secret = 'test-secret'
const payload = JSON.stringify({ id: 'evt_100', type: 'payment.succeeded' })
it('menghasilkan HMAC signature yang valid dari raw body', () => {
const hash = crypto.createHmac('sha256', secret).update(payload).digest('hex')
const recomputed = crypto.createHmac('sha256', secret).update(payload).digest('hex')
const match = crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(recomputed)
)
expect(match).toBe(true)
})
it('gagal verifikasi jika payload diubah whitespace-nya', () => {
const validHash = crypto.createHmac('sha256', secret).update(payload).digest('hex')
const alteredPayload = JSON.stringify(JSON.parse(payload), null, 2)
const tamperedHash = crypto.createHmac('sha256', secret).update(alteredPayload).digest('hex')
expect(validHash).not.toBe(tamperedHash)
})
})
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!