mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 10:21:47 +00:00
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
853 lines
26 KiB
Go
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")
|
|
}
|
|
}
|