Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ package webhook

import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
Expand Down Expand Up @@ -110,6 +113,7 @@ type Client struct {
nextID atomic.Uint64
dropped atomic.Uint64
initialBackoff time.Duration // retry backoff (default 1s)
secret string // HMAC-SHA256 pre-shared secret (empty = no sig)

// Circuit breaker state. After CircuitOpenThreshold (5)
// consecutive total failures (each event = up to MaxRetries
Expand Down Expand Up @@ -138,6 +142,15 @@ func WithRetryBackoff(d time.Duration) Option {
return func(wc *Client) { wc.initialBackoff = d }
}

// WithSecret sets the HMAC-SHA256 pre-shared secret. When non-empty, every
// outbound POST includes an X-Pilot-Signature-256 header with the hex-encoded
// HMAC-SHA256 of the request body. Receivers can verify authenticity and
// integrity by recomputing the HMAC — the header is simply ignored if the
// receiver does not care (backward-compatible).
func WithSecret(secret string) Option {
return func(wc *Client) { wc.secret = secret }
}

// NewClient creates a webhook dispatcher. If url is empty, returns nil.
func NewClient(url string, nodeIDFunc func() uint32, opts ...Option) *Client {
if url == "" {
Expand Down Expand Up @@ -260,6 +273,15 @@ func (wc *Client) post(ev *Event) {
return
}

// HMAC-SHA256 signature header (PILOT-90): if a secret is configured,
// sign the body so the receiver can verify authenticity+integrity.
var sigHeader string
if wc.secret != "" {
mac := hmac.New(sha256.New, []byte(wc.secret))
mac.Write(body)
sigHeader = hex.EncodeToString(mac.Sum(nil))
}

// Circuit breaker (v1.9.1): if the breaker is open AND we're still
// inside the cooldown window, short-circuit. The first event after
// cooldown elapses is the probe — if it succeeds, breaker resets
Expand All @@ -284,7 +306,16 @@ func (wc *Client) post(ev *Event) {
backoff *= 2
}

resp, err := wc.client.Post(wc.url, "application/json", bytes.NewReader(body))
req, err := http.NewRequest(http.MethodPost, wc.url, bytes.NewReader(body))
if err != nil {
slog.Warn("webhook POST request build failed", "event", ev.Event, "error", err)
continue
}
req.Header.Set("Content-Type", "application/json")
if sigHeader != "" {
req.Header.Set("X-Pilot-Signature-256", sigHeader)
}
resp, err := wc.client.Do(req)
if err != nil {
slog.Warn("webhook POST failed", "event", ev.Event, "attempt", attempt+1, "error", err)
continue // network error → retry
Expand Down
59 changes: 59 additions & 0 deletions zz_fuzz_webhook_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
package webhook_test

import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
Expand Down Expand Up @@ -330,3 +333,59 @@ func TestPendingHandshakeStruct(t *testing.T) {
t.Fatal("PendingHandshake field mismatch")
}
}

// ---------------------------------------------------------------------------
// HMAC signature — PILOT-90
// ---------------------------------------------------------------------------

func TestWebhookClientHMACSignatureHeader(t *testing.T) {
t.Parallel()
secret := "test-secret-key"
var sigHeader string
var body []byte

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sigHeader = r.Header.Get("X-Pilot-Signature-256")
body, _ = io.ReadAll(r.Body)
w.WriteHeader(200)
}))
defer ts.Close()

wc := webhook.NewClient(ts.URL, func() uint32 { return 42 },
webhook.WithSecret(secret))
if wc == nil {
t.Fatal("expected non-nil client with secret")
}
wc.Emit("test.event", map[string]string{"key": "val"})
wc.Close()

if sigHeader == "" {
t.Fatal("X-Pilot-Signature-256 header not set when secret is configured")
}
// Verify the HMAC ourselves
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if sigHeader != expected {
t.Fatalf("HMAC mismatch: got %s, want %s", sigHeader, expected)
}
}

func TestWebhookClientNoSignatureWhenNoSecret(t *testing.T) {
t.Parallel()
var sigHeader string

ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sigHeader = r.Header.Get("X-Pilot-Signature-256")
w.WriteHeader(200)
}))
defer ts.Close()

wc := webhook.NewClient(ts.URL, func() uint32 { return 42 })
wc.Emit("test.event", nil)
wc.Close()

if sigHeader != "" {
t.Fatal("X-Pilot-Signature-256 should NOT be set when no secret configured")
}
}
Loading