Mengimplementasikan webhook receiver membutuhkan penanganan keamanan khusus. Masalah utama yang sering dihadapi adalah verifikasi signature HMAC yang gagal secara misterius, rentan terhadap timing attack, atau sistem yang memproses event duplikat akibat mekanisme retry otomatis dari pengirim webhook.

Artikel ini membahas cara membangun webhook handler yang aman menggunakan Go Fiber (fasthttp), mengamankan payload menggunakan crypto/hmac dan crypto/subtle, memvalidasi window waktu request, serta menerapkan deduplikasi event.

Akar Masalah Kegagalan HMAC pada Fasthttp / Fiber

Penyebab paling umum kegagalan verifikasi HMAC-SHA256 di Go Fiber adalah penggunaan struct yang di-bind ulang untuk menghitung signature. Webhook provider menghasilkan signature dari raw byte sequence tepat seperti yang dikirimkan melalui jaringan.

Jika Anda melakukan binding payload ke struct menggunakan c.BodyParser(&payload) lalu melakukan json.Marshal kembali untuk mengecek hash, urutan key JSON, spasi, newline, dan representasi floating point dapat berubah. Perubahan satu byte saja menghasilkan hash SHA-256 yang sama sekali berbeda.

Fiber berjalan di atas fasthttp. Untuk mendapatkan payload asli tanpa mutasi, gunakan c.Body():

// SALAH: Re-serialize struct mengubah urutan byte dan spasi
var data Payload
_ = c.BodyParser(&data)
marshaled, _ := json.Marshal(data)
hash := computeHMAC(marshaled, secret)

// BENAR: Gunakan raw bytes langsung dari request context
rawPayload := c.Body()
hash := computeHMAC(rawPayload, secret)
Catatan: c.Body() mengembalikan slice byte yang valid selama context request aktif. Jangan memodifikasi buffer ini sebelum proses verifikasi signature selesai.

Anatomi Validasi: HMAC, Timing Attack, dan Replay Attack

Pola perlindungan standar industri (seperti yang digunakan Stripe, GitHub, dan Xendit) melibatkan tiga layer verifikasi:

  • HMAC-SHA256: Memvalidasi bahwa payload berasal dari sumber tepercaya dan isinya tidak dimodifikasi di tengah jalan.
  • Constant-Time Comparison: Menggunakan crypto/subtle.ConstantTimeCompare untuk mencegah attacker menebak signature byte-per-byte berdasarkan waktu respons server (timing attack).
  • Timestamp Verification: Memastikan request dikirim dalam rentang waktu toleransi (misalnya 5 menit) guna mencegah replay attack jika data signature sempat disadap.
  • Event Deduplication: Menyimpan ID unik webhook (misal header X-Webhook-ID) secara atomik dengan TTL untuk mencegah eksekusi ganda saat provider melakukan retry.

Implementasi Middleware Validasi Signature

Berikut adalah implementasi middleware di Go Fiber yang memvalidasi timestamp dan signature HMAC-SHA256 secara ketat.

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"strconv"
	"time"

	"github.com/gofiber/fiber/v2"
)

const (
	SignatureHeader = "X-Signature-SHA256"
	TimestampHeader = "X-Webhook-Timestamp"
	Tolerance       = 5 * time.Minute
)

func WebhookAuthMiddleware(secret []byte) fiber.Handler {
	return func(c *fiber.Ctx) error {
		sigHex := c.Get(SignatureHeader)
		tsStr := c.Get(TimestampHeader)

		if sigHex == "" || tsStr == "" {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "missing authentication headers",
			})
		}

		// 1. Mitigasi Replay Attack via Timestamp Tolerance
		tsUnix, err := strconv.ParseInt(tsStr, 10, 64)
		if err != nil {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
				"error": "invalid timestamp format",
			})
		}

		reqTime := time.Unix(tsUnix, 0)
		if time.Since(reqTime).Abs() > Tolerance {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "request timestamp outside tolerance window",
			})
		}

		// 2. Decode expected signature
		sigBytes, err := hex.DecodeString(sigHex)
		if err != nil {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "invalid signature encoding",
			})
		}

		// 3. Compute HMAC over: timestamp + "." + raw_body
		mac := hmac.New(sha256.New, secret)
		mac.Write([]byte(tsStr))
		mac.Write([]byte("."))
		mac.Write(c.Body())
		expectedMAC := mac.Sum(nil)

		// 4. Mitigasi Timing Attack dengan ConstantTimeCompare
		if subtle.ConstantTimeCompare(sigBytes, expectedMAC) != 1 {
			return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
				"error": "invalid signature",
			})
		}

		return c.Next()
	}
}

Deduplikasi Event (Idempotency) via Redis SETNX

Webhook provider menerapkan strategi pengiriman at-least-once. Jika server Anda lambat merespons atau terjadi network blip, provider akan mengirim ulang event yang sama. Anda harus mendeduplikasi event menggunakan Webhook-ID.

Gunakan operasi atomik SETNX dengan TTL di Redis sebelum mengeksekusi proses bisnis:

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type WebhookStorage struct {
	rdb *redis.Client
}

func (s *WebhookStorage) Deduplicate(ctx context.Context, webhookID string, ttl time.Duration) (bool, error) {
	key := fmt.Sprintf("webhook:idempotency:%s", webhookID)
	// SET key value EX ttl NX
	ok, err := s.rdb.SetNX(ctx, key, "1", ttl).Result()
	if err != nil {
		return false, err
	}
	// true jika key baru (belum diproses), false jika key sudah pernah ada
	return ok, nil
}

Di dalam handler Fiber, periksa status deduplikasi sebelum parsing payload utama:

func WebhookHandler(storage *WebhookStorage) fiber.Handler {
	return func(c *fiber.Ctx) error {
		eventID := c.Get("X-Webhook-ID")
		if eventID == "" {
			return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing event id"})
		}

		isNew, err := storage.Deduplicate(c.Context(), eventID, 24*time.Hour)
		if err != nil {
			return c.Status(fiber.StatusInternalServerError).SendStatus(500)
		}

		if !isNew {
			// Event sudah diproses sebelumnya, return 200 OK agar provider berhenti me-retry
			return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "already_processed"})
		}

		// Lanjutkan proses asynchronous atau update database
		return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "accepted"})
	}
}

Unit Testing Handler Menggunakan app.Test

Fiber menyediakan method app.Test untuk menguji flow HTTP in-memory tanpa perlu binding ke interface TCP jaringan nyata. Berikut pengujian lengkap untuk skenario payload valid, invalid signature, dan expired timestamp:

package main

import (
	"bytes"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"testing"
	"time"

	"github.com/gofiber/fiber/v2"
)

func signPayload(secret []byte, ts string, payload []byte) string {
	mac := hmac.New(sha256.New, secret)
	mac.Write([]byte(ts))
	mac.Write([]byte("."))
	mac.Write(payload)
	return hex.EncodeToString(mac.Sum(nil))
}

func TestWebhookHandler(t *testing.T) {
	secret := []byte("super-secret-key")
	app := fiber.New()
	app.Post("/webhook", WebhookAuthMiddleware(secret), func(c *fiber.Ctx) error {
		return c.SendStatus(fiber.StatusOK)
	})

	body := []byte(`{"event":"payment.success","amount":50000}`)

	t.Run("success valid signature", func(t *testing.T) {
		ts := strconv.FormatInt(time.Now().Unix(), 10)
		sig := signPayload(secret, ts, body)

		req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
		req.Header.Set(SignatureHeader, sig)
		req.Header.Set(TimestampHeader, ts)
		req.Header.Set("Content-Type", "application/json")

		resp, err := app.Test(req)
		if err != nil || resp.StatusCode != fiber.StatusOK {
			t.Fatalf("expected 200, got %d", resp.StatusCode)
		}
	})

	t.Run("fail invalid signature", func(t *testing.T) {
		ts := strconv.FormatInt(time.Now().Unix(), 10)
		sig := "invalidhexdeadbeef"

		req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
		req.Header.Set(SignatureHeader, sig)
		req.Header.Set(TimestampHeader, ts)

		resp, _ := app.Test(req)
		if resp.StatusCode != fiber.StatusUnauthorized {
			t.Fatalf("expected 401, got %d", resp.StatusCode)
		}
	})

	t.Run("fail expired timestamp", func(t *testing.T) {
		expiredTs := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
		sig := signPayload(secret, expiredTs, body)

		req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
		req.Header.Set(SignatureHeader, sig)
		req.Header.Set(TimestampHeader, expiredTs)

		resp, _ := app.Test(req)
		if resp.StatusCode != fiber.StatusUnauthorized {
			t.Fatalf("expected 401 for expired timestamp, got %d", resp.StatusCode)
		}
	})
}

Kesimpulan dan Best Practices

Saat membangun webhook receiver di Go Fiber:

  1. Selalu gunakan c.Body() untuk validasi hash sebelum mengurai data dengan deserializer JSON apa pun.
  2. Gunakan crypto/subtle.ConstantTimeCompare untuk memitigasi serangan berbasis timing leak.
  3. Batas toleransi timestamp (umumnya 300 detik) mencegah replay attack secara efisien.
  4. Gunakan atomic write seperti Redis SETNX dengan TTL pada unique identifier webhook untuk menjaga sifat idempotensi endpoint.