Files
goclaw/internal/http/webhooks_auth_test.go
Duy /zuey/andGitHub 532ff91d8e fix(security): harden upstream critical surfaces (#32)
* fix(security): harden upstream critical surfaces

Refs #30

* fix(security): close pre-landing review gaps

Refs #30

* fix(security): close official release blockers
2026-05-20 16:33:49 +07:00

853 lines
26 KiB
Go

package http
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// testEncKeyAuth is the AES-256-GCM key used for encrypted_secret in auth tests.
const testEncKeyAuth = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
// ---- stub store implementations ----
type stubWebhookStore struct {
byHash map[string]*store.WebhookData
byID map[uuid.UUID]*store.WebhookData
}
func newStubWebhookStore(rows ...*store.WebhookData) *stubWebhookStore {
s := &stubWebhookStore{
byHash: make(map[string]*store.WebhookData),
byID: make(map[uuid.UUID]*store.WebhookData),
}
for _, r := range rows {
s.byHash[r.SecretHash] = r
s.byID[r.ID] = r
}
return s
}
func (s *stubWebhookStore) GetByHash(_ context.Context, h string) (*store.WebhookData, error) {
r, ok := s.byHash[h]
if !ok {
return nil, sql.ErrNoRows
}
return r, nil
}
func (s *stubWebhookStore) GetByID(_ context.Context, id uuid.UUID) (*store.WebhookData, error) {
r, ok := s.byID[id]
if !ok {
return nil, sql.ErrNoRows
}
return r, nil
}
// GetByHashUnscoped and GetByIDUnscoped delegate to in-memory maps — same data,
// no tenant filter needed in stub (mirrors production semantics: globally unique hash).
func (s *stubWebhookStore) GetByHashUnscoped(_ context.Context, h string) (*store.WebhookData, error) {
r, ok := s.byHash[h]
if !ok {
return nil, sql.ErrNoRows
}
return r, nil
}
func (s *stubWebhookStore) GetByIDUnscoped(_ context.Context, id uuid.UUID) (*store.WebhookData, error) {
r, ok := s.byID[id]
if !ok {
return nil, sql.ErrNoRows
}
return r, nil
}
func (s *stubWebhookStore) Create(_ context.Context, _ *store.WebhookData) error { return nil }
func (s *stubWebhookStore) List(_ context.Context, _ store.WebhookListFilter) ([]store.WebhookData, error) {
return nil, nil
}
func (s *stubWebhookStore) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
func (s *stubWebhookStore) RotateSecret(_ context.Context, _ uuid.UUID, _, _, _ string) error {
return nil
}
func (s *stubWebhookStore) Revoke(_ context.Context, _ uuid.UUID) error { return nil }
func (s *stubWebhookStore) TouchLastUsed(_ context.Context, _ uuid.UUID) error { return nil }
type stubWebhookCallStore struct {
calls map[string]*store.WebhookCallData // key = idempotency_key
lastTenant uuid.UUID
}
func newStubCallStore(calls ...*store.WebhookCallData) *stubWebhookCallStore {
s := &stubWebhookCallStore{calls: make(map[string]*store.WebhookCallData)}
for _, c := range calls {
if c.IdempotencyKey != nil {
s.calls[*c.IdempotencyKey] = c
}
}
return s
}
func (s *stubWebhookCallStore) GetByIdempotency(ctx context.Context, _ uuid.UUID, key string) (*store.WebhookCallData, error) {
s.lastTenant = store.TenantIDFromContext(ctx)
c, ok := s.calls[key]
if !ok {
return nil, sql.ErrNoRows
}
return c, nil
}
func (s *stubWebhookCallStore) Create(_ context.Context, _ *store.WebhookCallData) error { return nil }
func (s *stubWebhookCallStore) GetByID(_ context.Context, _ uuid.UUID) (*store.WebhookCallData, error) {
return nil, sql.ErrNoRows
}
func (s *stubWebhookCallStore) UpdateStatus(_ context.Context, _ uuid.UUID, _ map[string]any) error {
return nil
}
func (s *stubWebhookCallStore) UpdateStatusCAS(_ context.Context, _ uuid.UUID, _ string, _ map[string]any) error {
return nil
}
func (s *stubWebhookCallStore) ClaimNext(_ context.Context, _ uuid.UUID, _ time.Time) (*store.WebhookCallData, error) {
return nil, sql.ErrNoRows
}
func (s *stubWebhookCallStore) List(_ context.Context, _ store.WebhookCallListFilter) ([]store.WebhookCallData, error) {
return nil, nil
}
func (s *stubWebhookCallStore) DeleteOlderThan(_ context.Context, _ uuid.UUID, _ time.Time) (int64, error) {
return 0, nil
}
func (s *stubWebhookCallStore) ReclaimStale(_ context.Context, _ time.Time) (int64, error) {
return 0, nil
}
// ---- helpers ----
// makeSecret generates a raw bearer secret and its SHA-256 hash.
func makeSecret() (raw, hashHex string) {
raw = "wh_testsecretvalue1234567890abcdef"
h := sha256.Sum256([]byte(raw))
hashHex = hex.EncodeToString(h[:])
return
}
// makeHMACSecret returns a raw secret, its hash, an encrypted ciphertext, and the
// raw bytes for HMAC signing. Per K6: HMAC key = raw secret bytes (not hash bytes).
// encKey is the AES-256-GCM encryption key used to encrypt the raw secret at rest.
func makeHMACSecret(encKey string) (secretHash, encryptedSecret string, keyBytes []byte) {
rawStr := "wh_hmac_raw_secret_for_testing_1234"
keyBytes = []byte(rawStr)
h := sha256.Sum256([]byte(rawStr))
secretHash = hex.EncodeToString(h[:])
var err error
encryptedSecret, err = crypto.Encrypt(rawStr, encKey)
if err != nil {
panic("makeHMACSecret: encrypt failed: " + err.Error())
}
return
}
func signHMAC(keyBytes []byte, ts int64, body []byte) string {
tsStr := strconv.FormatInt(ts, 10)
signed := append([]byte(tsStr+"."), body...)
mac := hmac.New(sha256.New, keyBytes)
mac.Write(signed)
return hex.EncodeToString(mac.Sum(nil))
}
func makeWebhook(kind string, opts ...func(*store.WebhookData)) *store.WebhookData {
raw, hashHex := makeSecret()
_ = raw
w := &store.WebhookData{
ID: uuid.New(),
TenantID: uuid.New(),
Kind: kind,
SecretPrefix: "wh_test",
SecretHash: hashHex,
RateLimitPerMin: 0, // unlimited by default
}
for _, o := range opts {
o(w)
}
return w
}
func withRevoked(w *store.WebhookData) { w.Revoked = true }
func withRequireHMAC(w *store.WebhookData) { w.RequireHMAC = true }
func withLocalhostOnly(w *store.WebhookData) { w.LocalhostOnly = true }
func withRPM(rpm int) func(*store.WebhookData) {
return func(w *store.WebhookData) { w.RateLimitPerMin = rpm }
}
func makeMiddleware(ws store.WebhookStore, calls store.WebhookCallStore, kind string, maxBody int64) http.Handler {
return makeMiddlewareWithKey(ws, calls, "", kind, maxBody)
}
func makeMiddlewareWithKey(ws store.WebhookStore, calls store.WebhookCallStore, encKey, kind string, maxBody int64) http.Handler {
limiter := newWebhookLimiter(0) // tenant limiter disabled
mw := WebhookAuthMiddleware(ws, calls, limiter, encKey, kind, maxBody)
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
return mw(ok)
}
func bearerReq(secret, body string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", bytes.NewBufferString(body))
r.Header.Set("Authorization", "Bearer "+secret)
r.Header.Set("Content-Type", "application/json")
return r
}
func hmacReq(webhookID uuid.UUID, keyBytes []byte, body string, tsOffset int64) *http.Request {
ts := time.Now().Unix() + tsOffset
sig := signHMAC(keyBytes, ts, []byte(body))
sigHeader := fmt.Sprintf("t=%d,v1=%s", ts, sig)
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", bytes.NewBufferString(body))
r.Header.Set("X-GoClaw-Signature", sigHeader)
r.Header.Set("X-Webhook-Id", webhookID.String())
r.Header.Set("Content-Type", "application/json")
return r
}
// ---- tests ----
func TestWebhookAuth_BearerHappyPath(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm")
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, bearerReq(raw, `{"input":"hello"}`))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestWebhookAuth_BearerRevoked(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withRevoked)
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, bearerReq(raw, `{}`))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for revoked, got %d", w.Code)
}
}
func TestWebhookAuth_BearerRequireHMAC(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withRequireHMAC)
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, bearerReq(raw, `{}`))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 when require_hmac=true but bearer used, got %d", w.Code)
}
}
func TestWebhookAuth_HMACHappyPath(t *testing.T) {
secretHash, encSecret, keyBytes := makeHMACSecret(testEncKeyAuth)
wh := makeWebhook("llm")
wh.SecretHash = secretHash
wh.EncryptedSecret = encSecret
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
body := `{"input":"hi"}`
handler := makeMiddlewareWithKey(ws, calls, testEncKeyAuth, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, hmacReq(wh.ID, keyBytes, body, 0))
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for valid HMAC, got %d: %s", w.Code, w.Body.String())
}
}
func TestWebhookAuth_HMACTamperedBody(t *testing.T) {
secretHash, encSecret, keyBytes := makeHMACSecret(testEncKeyAuth)
wh := makeWebhook("llm")
wh.SecretHash = secretHash
wh.EncryptedSecret = encSecret
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
body := `{"input":"legitimate"}`
ts := time.Now().Unix()
sig := signHMAC(keyBytes, ts, []byte(body))
// Send tampered body — signature won't match.
tamperedBody := `{"input":"tampered"}`
sigHeader := fmt.Sprintf("t=%d,v1=%s", ts, sig)
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", bytes.NewBufferString(tamperedBody))
r.Header.Set("X-GoClaw-Signature", sigHeader)
r.Header.Set("X-Webhook-Id", wh.ID.String())
handler := makeMiddlewareWithKey(ws, calls, testEncKeyAuth, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for tampered body, got %d", w.Code)
}
}
func TestWebhookAuth_HMACSkewBoundary(t *testing.T) {
secretHash, encSecret, keyBytes := makeHMACSecret(testEncKeyAuth)
wh := makeWebhook("llm")
wh.SecretHash = secretHash
wh.EncryptedSecret = encSecret
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
body := `{}`
handler := makeMiddlewareWithKey(ws, calls, testEncKeyAuth, "llm", WebhookMaxBodyLLM)
// t = now-299 → within window → should pass.
t.Run("within_skew", func(t *testing.T) {
w := httptest.NewRecorder()
handler.ServeHTTP(w, hmacReq(wh.ID, keyBytes, body, -299))
if w.Code != http.StatusOK {
t.Fatalf("expected 200 at -299s skew, got %d", w.Code)
}
})
// t = now-301 → outside window → should fail.
t.Run("outside_skew", func(t *testing.T) {
w := httptest.NewRecorder()
handler.ServeHTTP(w, hmacReq(wh.ID, keyBytes, body, -301))
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 at -301s skew, got %d", w.Code)
}
})
}
func TestWebhookAuth_KindMismatch(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("message") // webhook is "message" kind
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
// But middleware is configured for "llm" — mismatch.
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
handler.ServeHTTP(w, bearerReq(raw, `{}`))
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for kind mismatch, got %d", w.Code)
}
}
func TestWebhookAuth_LocalhostOnlyRemoteIP(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withLocalhostOnly)
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "203.0.113.42:12345" // non-loopback
handler.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-loopback with localhost_only, got %d", w.Code)
}
}
func TestWebhookAuth_LocalhostOnlyLoopback(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withLocalhostOnly)
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "127.0.0.1:55000" // loopback — should pass
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for loopback with localhost_only, got %d", w.Code)
}
}
func TestWebhookAuth_RateLimitExceeded(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withRPM(1)) // 1 req/min → burst=1
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
limiter := newWebhookLimiter(0)
mw := WebhookAuthMiddleware(ws, calls, limiter, "", "llm", WebhookMaxBodyLLM)
ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
handler := mw(ok)
// First request — should pass (burst=1).
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, bearerReq(raw, `{}`))
if w1.Code != http.StatusOK {
t.Fatalf("expected first request to pass, got %d", w1.Code)
}
// Second request immediately — should be rate limited.
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, bearerReq(raw, `{}`))
if w2.Code != http.StatusTooManyRequests {
t.Fatalf("expected 429 on second request within 1 rpm, got %d", w2.Code)
}
}
func TestWebhookAuth_BodyTooLarge(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("message")
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
// Cap at 256 KB; send 257 KB.
bigBody := make([]byte, 257*1024)
for i := range bigBody {
bigBody[i] = 'x'
}
handler := makeMiddleware(ws, calls, "message", WebhookMaxBodyMessage)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/message", bytes.NewReader(bigBody))
r.Header.Set("Authorization", "Bearer "+raw)
handler.ServeHTTP(w, r)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("expected 413 for oversized body, got %d", w.Code)
}
}
func TestWebhookAuth_IdempotencyReplay(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm")
ws := newStubWebhookStore(wh)
// Pre-load a completed call with matching body hash in canonical JSON format.
// Post-K2: request_payload is {"body_hash":"<sha256-hex>","meta":{...}} — not the old hex-prefix format.
body := `{"input":"idempotent"}`
payload, err := buildAuditPayload([]byte(body), map[string]string{"kind": "llm"})
if err != nil {
t.Fatalf("buildAuditPayload: %v", err)
}
idKey := "idem-key-abc123"
existingCall := &store.WebhookCallData{
ID: uuid.New(),
WebhookID: wh.ID,
IdempotencyKey: &idKey,
Status: "done",
Response: []byte(`{"result":"cached"}`),
RequestPayload: payload,
}
calls := newStubCallStore(existingCall)
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, body)
r.Header.Set("Idempotency-Key", idKey)
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 replay, got %d", w.Code)
}
got := w.Body.String()
if got != `{"result":"cached"}` {
t.Fatalf("expected cached response body, got %q", got)
}
if w.Header().Get("X-Idempotency-Replayed") != "true" {
t.Fatal("expected X-Idempotency-Replayed: true header")
}
}
func TestWebhookAuth_IdempotencyRunsWithTenantContext(t *testing.T) {
raw, hashHex := makeSecret()
wh := makeWebhook("llm")
wh.SecretHash = hashHex
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{"input":"hi"}`)
r.Header.Set("Idempotency-Key", "tenant-context-key")
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected middleware to proceed, got %d", w.Code)
}
if calls.lastTenant != wh.TenantID {
t.Fatalf("idempotency lookup tenant = %s, want %s", calls.lastTenant, wh.TenantID)
}
}
func TestWebhookAuth_NoAuthHeader(t *testing.T) {
wh := makeWebhook("llm")
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", bytes.NewBufferString(`{}`))
handler.ServeHTTP(w, r)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with no auth header, got %d", w.Code)
}
}
func TestReadLimitedBody_WithinLimit(t *testing.T) {
body := `{"hello":"world"}`
r := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
buf, err := readLimitedBody(r, 1024)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(buf) != body {
t.Fatalf("body mismatch: got %q want %q", buf, body)
}
// Verify body is restored.
restored, _ := io.ReadAll(r.Body)
if string(restored) != body {
t.Fatalf("restored body mismatch: got %q", restored)
}
}
func TestParseHMACHeader(t *testing.T) {
ts, sig, err := parseHMACHeader("t=1700000000,v1=abcdef1234")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ts != 1700000000 {
t.Fatalf("ts mismatch: %d", ts)
}
if sig != "abcdef1234" {
t.Fatalf("sig mismatch: %q", sig)
}
}
func TestParseHMACHeader_MissingFields(t *testing.T) {
cases := []string{
"",
"t=1700000000",
"v1=abcdef",
"t=bad,v1=abc",
}
for _, c := range cases {
_, _, err := parseHMACHeader(c)
if err == nil {
t.Errorf("expected error for header %q, got nil", c)
}
}
}
func TestIsLoopback(t *testing.T) {
cases := []struct {
addr string
loopback bool
}{
{"127.0.0.1:8080", true},
{"[::1]:8080", true},
{"203.0.113.1:8080", false},
{"10.0.0.1:8080", false},
{"", false},
}
for _, c := range cases {
got := isLoopback(c.addr)
if got != c.loopback {
t.Errorf("isLoopback(%q) = %v, want %v", c.addr, got, c.loopback)
}
}
}
func TestWebhookRateLimiter_TwoTier(t *testing.T) {
wl := newWebhookLimiter(2) // tenant: 2 rpm
id := uuid.New().String()
tid := uuid.New().String()
// webhook tier unlimited (rpm=0) — passes always.
if !wl.AllowWebhook(id, 0) {
t.Fatal("unlimited webhook tier should always allow")
}
// Tenant tier: first two pass, third fails.
if !wl.AllowTenant(tid) {
t.Fatal("first tenant request should pass")
}
if !wl.AllowTenant(tid) {
t.Fatal("second tenant request (burst=2) should pass")
}
if wl.AllowTenant(tid) {
t.Fatal("third tenant request should be rate limited")
}
}
// ---- K1: bearer/HMAC succeed without pre-existing tenant in context ----
// TestWebhookAuth_BearerSucceedsWithoutTenantInCtx verifies that bearer auth
// works even when no tenant is present in the incoming request context.
// K1 root-cause: old code called GetByHash (tenant-scoped) before injecting tenant.
func TestWebhookAuth_BearerSucceedsWithoutTenantInCtx(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm")
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
// Request context has no tenant — simulates unauthenticated incoming HTTP
// request (normal case for an inbound webhook from an external caller).
r := bearerReq(raw, `{"input":"hello"}`)
if tid := store.TenantIDFromContext(r.Context()); tid != (uuid.UUID{}) {
t.Skip("context unexpectedly has a tenant — test premise invalid")
}
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for bearer auth without prior tenant in ctx, got %d: %s", w.Code, w.Body.String())
}
}
// TestWebhookAuth_HMACSucceedsWithoutTenantInCtx verifies HMAC auth works
// without a pre-existing tenant in context (K1 fix — GetByIDUnscoped).
func TestWebhookAuth_HMACSucceedsWithoutTenantInCtx(t *testing.T) {
secretHash, encSecret, keyBytes := makeHMACSecret(testEncKeyAuth)
wh := makeWebhook("llm")
wh.SecretHash = secretHash
wh.EncryptedSecret = encSecret
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
body := `{"input":"hi"}`
handler := makeMiddlewareWithKey(ws, calls, testEncKeyAuth, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := hmacReq(wh.ID, keyBytes, body, 0)
if tid := store.TenantIDFromContext(r.Context()); tid != (uuid.UUID{}) {
t.Skip("context unexpectedly has a tenant")
}
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for HMAC auth without prior tenant in ctx, got %d: %s", w.Code, w.Body.String())
}
}
// ---- K8: HMAC replay-nonce rejection ----
// TestWebhookAuth_HMACReplayRejected verifies that replaying the same HMAC
// signature within the nonce TTL window returns 401.
func TestWebhookAuth_HMACReplayRejected(t *testing.T) {
secretHash, encSecret, keyBytes := makeHMACSecret(testEncKeyAuth)
wh := makeWebhook("llm")
wh.SecretHash = secretHash
wh.EncryptedSecret = encSecret
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
body := `{"input":"replay-test"}`
handler := makeMiddlewareWithKey(ws, calls, testEncKeyAuth, "llm", WebhookMaxBodyLLM)
// Build a single signed request — both calls reuse the same ts+sig.
ts := time.Now().Unix()
sig := signHMAC(keyBytes, ts, []byte(body))
sigHeader := fmt.Sprintf("t=%d,v1=%s", ts, sig)
makeReq := func() *http.Request {
r := httptest.NewRequest(http.MethodPost, "/v1/webhooks/llm", bytes.NewBufferString(body))
r.Header.Set("X-GoClaw-Signature", sigHeader)
r.Header.Set("X-Webhook-Id", wh.ID.String())
r.Header.Set("Content-Type", "application/json")
return r
}
// First request — must succeed.
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, makeReq())
if w1.Code != http.StatusOK {
t.Fatalf("first HMAC request should succeed, got %d: %s", w1.Code, w1.Body.String())
}
// Second request with identical signature — must be rejected as replay.
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, makeReq())
if w2.Code != http.StatusUnauthorized {
t.Fatalf("replayed HMAC request should return 401, got %d", w2.Code)
}
}
// ---- K7: IP allowlist enforcement ----
func withIPAllowlist(entries ...string) func(*store.WebhookData) {
return func(w *store.WebhookData) { w.IPAllowlist = entries }
}
// TestWebhookAuth_IPAllowlistCIDRPass verifies a request from an IP inside a
// CIDR range is allowed.
func TestWebhookAuth_IPAllowlistCIDRPass(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withIPAllowlist("10.0.0.0/8"))
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "10.1.2.3:54321"
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for IP inside CIDR allowlist, got %d: %s", w.Code, w.Body.String())
}
}
// TestWebhookAuth_IPAllowlistCIDRDeny verifies a request from an IP outside all
// CIDR ranges is rejected with 403.
func TestWebhookAuth_IPAllowlistCIDRDeny(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withIPAllowlist("10.0.0.0/8"))
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "1.2.3.4:54321"
handler.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for IP outside CIDR allowlist, got %d", w.Code)
}
}
// TestWebhookAuth_IPAllowlistExactMatch verifies single-IP allowlist entries.
func TestWebhookAuth_IPAllowlistExactMatch(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm", withIPAllowlist("192.168.1.100"))
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
t.Run("exact_match_pass", func(t *testing.T) {
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "192.168.1.100:54321"
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for exact IP match, got %d", w.Code)
}
})
t.Run("exact_match_miss", func(t *testing.T) {
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "192.168.1.101:54321"
handler.ServeHTTP(w, r)
if w.Code != http.StatusForbidden {
t.Fatalf("expected 403 for non-matching IP, got %d", w.Code)
}
})
}
// TestWebhookAuth_IPAllowlistEmptyAllowsAll verifies back-compat: empty
// allowlist allows all source IPs.
func TestWebhookAuth_IPAllowlistEmptyAllowsAll(t *testing.T) {
raw, _ := makeSecret()
wh := makeWebhook("llm") // no IPAllowlist set
ws := newStubWebhookStore(wh)
calls := newStubCallStore()
handler := makeMiddleware(ws, calls, "llm", WebhookMaxBodyLLM)
w := httptest.NewRecorder()
r := bearerReq(raw, `{}`)
r.RemoteAddr = "203.0.113.99:54321"
handler.ServeHTTP(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for empty allowlist (allow-all), got %d", w.Code)
}
}
// ---- Unit tests for ipAllowed helper ----
func TestIPAllowed(t *testing.T) {
cases := []struct {
name string
remoteAddr string
allowlist []string
want bool
}{
{"cidr_match", "10.1.2.3:8080", []string{"10.0.0.0/8"}, true},
{"cidr_miss", "1.2.3.4:8080", []string{"10.0.0.0/8"}, false},
{"exact_match", "192.168.1.5:8080", []string{"192.168.1.5"}, true},
{"exact_miss", "192.168.1.6:8080", []string{"192.168.1.5"}, false},
{"multi_second_matches", "172.16.0.1:8080", []string{"10.0.0.0/8", "172.16.0.0/12"}, true},
{"invalid_cidr_skipped_second_matches", "1.2.3.4:8080", []string{"bad/cidr", "1.2.3.4"}, true},
{"ipv6_cidr", "[::1]:8080", []string{"::1/128"}, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ipAllowed(c.remoteAddr, c.allowlist)
if got != c.want {
t.Errorf("ipAllowed(%q, %v) = %v, want %v", c.remoteAddr, c.allowlist, got, c.want)
}
})
}
}
// ---- Unit tests for nonce cache ----
func TestWebhookNonceCache_FirstSeenReturnsFalse(t *testing.T) {
c := newWebhookNonceCache()
defer c.Stop()
if c.Seen("key1") {
t.Fatal("first Seen() call should return false (not a replay)")
}
}
func TestWebhookNonceCache_SecondSeenReturnsTrue(t *testing.T) {
c := newWebhookNonceCache()
defer c.Stop()
c.Seen("key1")
if !c.Seen("key1") {
t.Fatal("second Seen() call with same key should return true (replay)")
}
}
func TestWebhookNonceCache_DifferentKeysIndependent(t *testing.T) {
c := newWebhookNonceCache()
defer c.Stop()
c.Seen("key1")
if c.Seen("key2") {
t.Fatal("different keys should be independent")
}
}