From 80714119c829fbfbc0be6e3b5abd4e88d5992190 Mon Sep 17 00:00:00 2001 From: matthew-pilot Date: Thu, 28 May 2026 04:40:31 +0000 Subject: [PATCH] fix(webhook): add HMAC-SHA256 signature for outbound events (PILOT-90) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webhook Client POSTs unsigned JSON with no integrity or authenticity guarantee. Add an optional pre-shared secret (WithSecret option); when set, every outbound POST includes an X-Pilot-Signature-256 header with the hex-encoded HMAC-SHA256 of the request body. Receivers that do not care about signatures simply ignore the header (backward-compatible). Receivers that DO care recompute the HMAC against the shared secret and reject mismatches. Changes: - Client.secret field + WithSecret() option - crypto/hmac, crypto/sha256, encoding/hex imports - post() switches from client.Post to http.NewRequest + client.Do to inject the header (same send semantics, same retry+circuit-breaker logic preserved) Verification: - go build ./... → clean - go vet ./... → clean - All existing tests pass + new TestWebhookClientHMACSignatureHeader and TestWebhookClientNoSignatureWhenNoSecret Closes PILOT-90 --- webhook.go | 33 ++++++++++++++++++++++- zz_fuzz_webhook_test.go | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/webhook.go b/webhook.go index 81dde09..27304cc 100644 --- a/webhook.go +++ b/webhook.go @@ -15,6 +15,9 @@ package webhook import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "log/slog" "net/http" @@ -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 @@ -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 == "" { @@ -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 @@ -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 diff --git a/zz_fuzz_webhook_test.go b/zz_fuzz_webhook_test.go index 42504b6..5559d7b 100644 --- a/zz_fuzz_webhook_test.go +++ b/zz_fuzz_webhook_test.go @@ -3,6 +3,9 @@ package webhook_test import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "io" "net/http" @@ -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") + } +}