mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 14:22:57 +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
488 lines
16 KiB
Go
488 lines
16 KiB
Go
package http
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const (
|
|
// webhookBearerPrefix is the well-known prefix for raw webhook secrets.
|
|
// Presence allows fast rejection of non-webhook bearer tokens.
|
|
webhookBearerPrefix = "wh_"
|
|
|
|
// webhookHMACSkewSeconds is the maximum |now - t| allowed for HMAC timestamps.
|
|
webhookHMACSkewSeconds = 300
|
|
|
|
// webhookMaxBodyMessage is the body cap for /v1/webhooks/message endpoints.
|
|
WebhookMaxBodyMessage = 256 * 1024 // 256 KB
|
|
|
|
// webhookMaxBodyLLM is the body cap for /v1/webhooks/llm endpoints.
|
|
WebhookMaxBodyLLM = 1024 * 1024 // 1 MB
|
|
)
|
|
|
|
// WebhookAuthMiddleware is the composed middleware chain for all /v1/webhooks/*
|
|
// runtime endpoints. Order: body cap → bearer/HMAC auth → localhost gate →
|
|
// IP allowlist → rate limit → inject context → idempotency guard → next.
|
|
//
|
|
// Parameters:
|
|
// - ws: WebhookStore for secret + row lookup.
|
|
// - calls: WebhookCallStore for idempotency checks.
|
|
// - limiter: shared process-lifetime rate limiter (never nil).
|
|
// - encKey: AES-256-GCM key for decrypting encrypted_secret at HMAC verify time.
|
|
// If "" and encrypted_secret is present, HMAC auth returns errWebhookHMACInvalid.
|
|
// - kind: expected webhook kind ("llm" or "message") — enforced vs row.
|
|
// - maxBody: body size cap in bytes (use WebhookMaxBodyMessage/LLM constants).
|
|
func WebhookAuthMiddleware(
|
|
ws store.WebhookStore,
|
|
calls store.WebhookCallStore,
|
|
limiter *webhookLimiter,
|
|
encKey string,
|
|
kind string,
|
|
maxBody int64,
|
|
) func(http.Handler) http.Handler {
|
|
// Shared per-handler nonce cache — process lifetime, single-node scope.
|
|
// See docs/webhooks.md §"HMAC Replay Protection" for multi-node caveat.
|
|
nonces := newWebhookNonceCache()
|
|
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
// 1. Read and cap body — HMAC needs raw bytes, so we buffer once and
|
|
// restore r.Body so downstream JSON decoders see correct content.
|
|
body, err := readLimitedBody(r, maxBody)
|
|
if err != nil {
|
|
slog.Warn("security.webhook.body_too_large",
|
|
"path", r.URL.Path,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookBodyTooLarge),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 2. Resolve webhook row via bearer or HMAC using unscoped lookups.
|
|
// K1: auth resolution happens BEFORE tenant is in context; we inject
|
|
// tenant below (step 7) so all downstream queries remain tenant-scoped.
|
|
webhook, sig, err := resolveWebhook(r, body, ws, nonces, encKey)
|
|
if err != nil {
|
|
slog.Warn("security.webhook.auth_failed",
|
|
"reason", err.Error(),
|
|
"path", r.URL.Path,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
status := http.StatusUnauthorized
|
|
msg := i18n.T(locale, i18n.MsgWebhookAuthFailed)
|
|
// Surface specific reasons for well-defined failure modes.
|
|
switch {
|
|
case errors.Is(err, errWebhookRevoked):
|
|
msg = i18n.T(locale, i18n.MsgWebhookRevoked)
|
|
case errors.Is(err, errWebhookHMACInvalid):
|
|
msg = i18n.T(locale, i18n.MsgWebhookHMACInvalid)
|
|
case errors.Is(err, errWebhookTimestampSkew):
|
|
msg = i18n.T(locale, i18n.MsgWebhookHMACTimestampSkew)
|
|
case errors.Is(err, errWebhookBearerRequiresHMAC):
|
|
msg = i18n.T(locale, i18n.MsgWebhookBearerRequiredHMAC)
|
|
case errors.Is(err, errWebhookReplay):
|
|
// Replay: still 401, but distinct log tag already emitted in resolver.
|
|
}
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
return
|
|
}
|
|
_ = sig // resolved sig used internally by resolveWebhook for nonce check
|
|
|
|
// 3. Localhost-only gate (checked after auth to avoid timing oracle on
|
|
// the existence of localhost-only webhooks).
|
|
if webhook.LocalhostOnly {
|
|
if !isLoopback(r.RemoteAddr) {
|
|
slog.Warn("security.webhook.localhost_only_violation",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookLocalhostOnlyViolation),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 4. K7 — IP allowlist enforcement.
|
|
// Empty allowlist = allow all (back-compat).
|
|
// Entries may be single IPs or CIDRs (RFC 4632).
|
|
// Proxy note: X-Forwarded-For is NOT trusted — no proxy-trust config
|
|
// exists in this codebase (YAGNI). Use RemoteAddr only.
|
|
if len(webhook.IPAllowlist) > 0 {
|
|
if !ipAllowed(r.RemoteAddr, webhook.IPAllowlist) {
|
|
slog.Warn("security.webhook.ip_denied",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"remote_addr", r.RemoteAddr,
|
|
)
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookIPDenied),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// 5. Kind match — reject if caller path targets wrong kind.
|
|
if webhook.Kind != kind {
|
|
slog.Warn("security.webhook.kind_mismatch",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"expected_kind", webhook.Kind,
|
|
"requested_kind", kind,
|
|
)
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookKindMismatch),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 6. Rate limits — per-webhook then per-tenant (both must pass).
|
|
tenantID := webhook.TenantID.String()
|
|
webhookID := webhook.ID.String()
|
|
|
|
if !limiter.AllowWebhook(webhookID, webhook.RateLimitPerMin) {
|
|
slog.Warn("security.webhook.rate_limited",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"tier", "webhook",
|
|
)
|
|
w.Header().Set("Retry-After", "60")
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookRateLimited),
|
|
})
|
|
return
|
|
}
|
|
if !limiter.AllowTenant(tenantID) {
|
|
slog.Warn("security.webhook.rate_limited",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"tier", "tenant",
|
|
)
|
|
w.Header().Set("Retry-After", "60")
|
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
|
"error": i18n.T(locale, i18n.MsgWebhookRateLimited),
|
|
})
|
|
return
|
|
}
|
|
|
|
// 7. Inject webhook + tenant into context; propagate to stores.
|
|
// K1: tenant injected HERE so all store calls below are tenant-scoped.
|
|
ctx = WithWebhookData(ctx, webhook)
|
|
ctx = WithWebhookRawBody(ctx, body)
|
|
ctx = store.WithTenantID(ctx, webhook.TenantID)
|
|
if webhook.AgentID != nil {
|
|
ctx = store.WithAgentID(ctx, *webhook.AgentID)
|
|
}
|
|
scopedReq := r.WithContext(ctx)
|
|
|
|
// 8. Idempotency check. This must run after tenant injection because
|
|
// WebhookCallStore lookups are tenant scoped.
|
|
proceed, _ := checkIdempotency(w, scopedReq, body, webhook.ID, calls)
|
|
if !proceed {
|
|
return
|
|
}
|
|
|
|
// Best-effort touch — don't block on failure. Use WithoutCancel so
|
|
// the DB write is not cancelled when the HTTP response completes.
|
|
go func() { _ = ws.TouchLastUsed(context.WithoutCancel(scopedReq.Context()), webhook.ID) }()
|
|
|
|
next.ServeHTTP(w, scopedReq)
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- sentinel errors (unexported; tested via errors.Is) ----
|
|
|
|
var (
|
|
errWebhookRevoked = errors.New("webhook_revoked")
|
|
errWebhookHMACInvalid = errors.New("hmac_invalid")
|
|
errWebhookTimestampSkew = errors.New("hmac_timestamp_skew")
|
|
errWebhookBearerRequiresHMAC = errors.New("bearer_requires_hmac")
|
|
errWebhookNotFound = errors.New("webhook_not_found")
|
|
errWebhookReplay = errors.New("hmac_replay")
|
|
errWebhookIPDenied = errors.New("ip_denied")
|
|
)
|
|
|
|
// resolveWebhook determines auth mode from headers and delegates to the
|
|
// appropriate resolver. Returns a non-nil *WebhookData on success.
|
|
// The second return value is the resolved HMAC signature hex (empty for bearer).
|
|
//
|
|
// Auth mode detection:
|
|
// - HMAC mode: X-GoClaw-Signature header present → resolveByHMAC.
|
|
// - Bearer mode: Authorization: Bearer wh_* → resolveByBearer.
|
|
// - Neither → 401 (errWebhookNotFound used as catch-all).
|
|
//
|
|
// K1: uses unscoped store lookups — tenant is NOT required in ctx here.
|
|
// Tenant is injected by the caller (WebhookAuthMiddleware step 8) after resolution.
|
|
func resolveWebhook(r *http.Request, body []byte, ws store.WebhookStore, nonces *webhookNonceCache, encKey string) (*store.WebhookData, string, error) {
|
|
sigHeader := r.Header.Get("X-GoClaw-Signature")
|
|
authHeader := r.Header.Get("Authorization")
|
|
|
|
if sigHeader != "" {
|
|
// HMAC mode: need X-Webhook-Id to look up the row.
|
|
webhookIDStr := r.Header.Get("X-Webhook-Id")
|
|
return resolveByHMAC(r, body, ws, nonces, webhookIDStr, sigHeader, encKey)
|
|
}
|
|
|
|
if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
|
|
raw := after
|
|
if strings.HasPrefix(raw, webhookBearerPrefix) {
|
|
wh, err := resolveByBearer(r, raw, ws)
|
|
return wh, "", err
|
|
}
|
|
}
|
|
|
|
return nil, "", errWebhookNotFound
|
|
}
|
|
|
|
// resolveByBearer performs SHA-256 of the raw secret, then looks up the webhook
|
|
// by hash using an unscoped query (K1 fix). Rejects revoked rows and rows that
|
|
// require HMAC.
|
|
func resolveByBearer(r *http.Request, rawSecret string, ws store.WebhookStore) (*store.WebhookData, error) {
|
|
// Always compute hash — constant-time mitigation against timing oracle on
|
|
// "does this prefix exist" (hash computation is fixed cost).
|
|
h := sha256.Sum256([]byte(rawSecret))
|
|
hashHex := hex.EncodeToString(h[:])
|
|
|
|
// K1: unscoped lookup — no tenant required in ctx at this stage.
|
|
webhook, err := ws.GetByHashUnscoped(r.Context(), hashHex)
|
|
if errors.Is(err, sql.ErrNoRows) || webhook == nil {
|
|
return nil, errWebhookNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, errWebhookNotFound
|
|
}
|
|
if webhook.Revoked {
|
|
return nil, errWebhookRevoked
|
|
}
|
|
if webhook.RequireHMAC {
|
|
return nil, errWebhookBearerRequiresHMAC
|
|
}
|
|
return webhook, nil
|
|
}
|
|
|
|
// resolveByHMAC parses the X-GoClaw-Signature header, validates clock skew,
|
|
// looks up the webhook row by UUID using an unscoped query (K1 fix), verifies
|
|
// the HMAC, and checks the replay-nonce cache (K8).
|
|
//
|
|
// Signature format: "t=<unix_seconds>,v1=<hex_hmac_sha256>"
|
|
// Signed payload: "<unix_seconds>.<raw_body>"
|
|
// HMAC key: raw webhook secret (decrypted from encrypted_secret at verify time).
|
|
func resolveByHMAC(r *http.Request, body []byte, ws store.WebhookStore, nonces *webhookNonceCache, webhookIDStr, sigHeader, encKey string) (*store.WebhookData, string, error) {
|
|
// Parse t= and v1= from header.
|
|
ts, sig, err := parseHMACHeader(sigHeader)
|
|
if err != nil {
|
|
return nil, "", errWebhookHMACInvalid
|
|
}
|
|
|
|
// Clock-skew check before any DB lookup (cheap).
|
|
now := time.Now().Unix()
|
|
if abs64(now-ts) > webhookHMACSkewSeconds {
|
|
return nil, "", errWebhookTimestampSkew
|
|
}
|
|
|
|
// Look up webhook by UUID using unscoped query (K1 fix).
|
|
webhookID, uuidErr := uuid.Parse(webhookIDStr)
|
|
if uuidErr != nil {
|
|
return nil, "", errWebhookNotFound
|
|
}
|
|
|
|
// K1: unscoped lookup — no tenant required in ctx at this stage.
|
|
webhook, err := ws.GetByIDUnscoped(r.Context(), webhookID)
|
|
if errors.Is(err, sql.ErrNoRows) || webhook == nil {
|
|
return nil, "", errWebhookNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, "", errWebhookNotFound
|
|
}
|
|
if webhook.Revoked {
|
|
return nil, "", errWebhookRevoked
|
|
}
|
|
|
|
// K6: derive HMAC key from the decrypted raw secret (not from secret_hash bytes).
|
|
// encrypted_secret = "" means the webhook was created before K6 and requires rotation.
|
|
if webhook.EncryptedSecret == "" {
|
|
slog.Warn("security.webhook.hmac_requires_rotation",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"reason", "encrypted_secret empty — rotate webhook secret to enable HMAC auth",
|
|
)
|
|
return nil, "", errWebhookHMACInvalid
|
|
}
|
|
rawSecret, decErr := crypto.Decrypt(webhook.EncryptedSecret, encKey)
|
|
if decErr != nil {
|
|
slog.Error("security.webhook.hmac_decrypt_failed",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"error", decErr,
|
|
)
|
|
return nil, "", errWebhookHMACInvalid
|
|
}
|
|
secretKeyBytes := []byte(rawSecret)
|
|
|
|
tsStr := strconv.FormatInt(ts, 10)
|
|
signed := append([]byte(tsStr+"."), body...)
|
|
mac := hmac.New(sha256.New, secretKeyBytes)
|
|
_, _ = mac.Write(signed)
|
|
expected := mac.Sum(nil)
|
|
|
|
// Decode caller-provided hex signature.
|
|
callerSig, decErr := hex.DecodeString(sig)
|
|
if decErr != nil || len(callerSig) == 0 {
|
|
return nil, "", errWebhookHMACInvalid
|
|
}
|
|
|
|
// Constant-time comparison — no early exit on mismatch.
|
|
if subtle.ConstantTimeCompare(expected, callerSig) != 1 {
|
|
return nil, "", errWebhookHMACInvalid
|
|
}
|
|
|
|
// K8 — Replay nonce check. Must be after HMAC verify to avoid
|
|
// cache poisoning by unsigned requests with arbitrary signatures.
|
|
if nonces != nil {
|
|
key := nonceKey(webhook.TenantID.String(), sig)
|
|
if nonces.Seen(key) {
|
|
slog.Warn("security.webhook.hmac_replay",
|
|
"webhook_id_hint", webhook.SecretPrefix,
|
|
"tenant_id", webhook.TenantID,
|
|
)
|
|
return nil, "", errWebhookReplay
|
|
}
|
|
}
|
|
|
|
return webhook, sig, nil
|
|
}
|
|
|
|
// ipAllowed reports whether the request's remote IP matches any entry in the
|
|
// allowlist. Entries may be single IPs or CIDR ranges (RFC 4632).
|
|
// Invalid entries are logged and skipped (fail-open per entry, not per list).
|
|
// An empty allowlist always returns true (back-compat: deny-by-list must be
|
|
// explicitly configured).
|
|
//
|
|
// Proxy note: only r.RemoteAddr is consulted — X-Forwarded-For is NOT trusted
|
|
// as no proxy-trust configuration exists. Document in docs/webhooks.md.
|
|
func ipAllowed(remoteAddr string, allowlist []string) bool {
|
|
// Strip port from RemoteAddr.
|
|
host, _, err := net.SplitHostPort(remoteAddr)
|
|
if err != nil {
|
|
// remoteAddr has no port (unusual but handle gracefully).
|
|
host = remoteAddr
|
|
}
|
|
clientIP := net.ParseIP(host)
|
|
if clientIP == nil {
|
|
// Cannot parse — deny.
|
|
return false
|
|
}
|
|
|
|
for _, entry := range allowlist {
|
|
entry = strings.TrimSpace(entry)
|
|
if strings.Contains(entry, "/") {
|
|
// CIDR entry.
|
|
_, network, parseErr := net.ParseCIDR(entry)
|
|
if parseErr != nil {
|
|
slog.Warn("security.webhook.ip_allowlist_invalid_cidr",
|
|
"entry", entry,
|
|
"err", parseErr,
|
|
)
|
|
continue // skip malformed entry
|
|
}
|
|
if network.Contains(clientIP) {
|
|
return true
|
|
}
|
|
} else {
|
|
// Single IP entry.
|
|
entryIP := net.ParseIP(entry)
|
|
if entryIP == nil {
|
|
slog.Warn("security.webhook.ip_allowlist_invalid_entry",
|
|
"entry", entry,
|
|
)
|
|
continue // skip malformed entry
|
|
}
|
|
if entryIP.Equal(clientIP) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// readLimitedBody reads at most maxBytes from r.Body using http.MaxBytesReader.
|
|
// On success it replaces r.Body with a fresh NopCloser over the buffer so
|
|
// downstream JSON decoders see the same bytes. r.ContentLength is also updated.
|
|
func readLimitedBody(r *http.Request, maxBytes int64) ([]byte, error) {
|
|
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
|
|
buf, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
// http.MaxBytesReader returns an error when the limit is exceeded.
|
|
return nil, err
|
|
}
|
|
// Restore body so downstream handlers can decode it.
|
|
r.Body = io.NopCloser(bytes.NewReader(buf))
|
|
r.ContentLength = int64(len(buf))
|
|
return buf, nil
|
|
}
|
|
|
|
// parseHMACHeader splits "t=<unix>,v1=<hex>" into (timestamp, hexSig, error).
|
|
func parseHMACHeader(header string) (int64, string, error) {
|
|
var ts int64
|
|
var sig string
|
|
for part := range strings.SplitSeq(header, ",") {
|
|
part = strings.TrimSpace(part)
|
|
switch {
|
|
case strings.HasPrefix(part, "t="):
|
|
v, err := strconv.ParseInt(strings.TrimPrefix(part, "t="), 10, 64)
|
|
if err != nil {
|
|
return 0, "", errors.New("invalid t= field")
|
|
}
|
|
ts = v
|
|
case strings.HasPrefix(part, "v1="):
|
|
sig = strings.TrimPrefix(part, "v1=")
|
|
}
|
|
}
|
|
if ts == 0 || sig == "" {
|
|
return 0, "", errors.New("missing t= or v1= field")
|
|
}
|
|
return ts, sig, nil
|
|
}
|
|
|
|
// isLoopback reports whether the RemoteAddr is a loopback address.
|
|
// Uses netip.ParseAddrPort for correct IPv4/IPv6 handling (not string prefix).
|
|
func isLoopback(remoteAddr string) bool {
|
|
ap, err := netip.ParseAddrPort(remoteAddr)
|
|
if err != nil {
|
|
// Fall back: try parsing as bare address (no port).
|
|
a, err2 := netip.ParseAddr(remoteAddr)
|
|
if err2 != nil {
|
|
return false
|
|
}
|
|
return a.IsLoopback()
|
|
}
|
|
return ap.Addr().IsLoopback()
|
|
}
|
|
|
|
// abs64 returns the absolute value of x.
|
|
func abs64(x int64) int64 {
|
|
if x < 0 {
|
|
return -x
|
|
}
|
|
return x
|
|
}
|