Files
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

580 lines
20 KiB
Go

package http
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/hex"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/crypto"
"github.com/nextlevelbuilder/goclaw/internal/edition"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// Compile-time assertion: WebhooksAdminHandler must implement routeRegistrar
// (the interface defined in internal/gateway/server.go).
var _ interface{ RegisterRoutes(mux *http.ServeMux) } = (*WebhooksAdminHandler)(nil)
// webhookKinds is the set of valid webhook kinds.
var webhookKinds = map[string]bool{
"llm": true,
"message": true,
}
// WebhooksAdminHandler implements CRUD for webhook registry entries.
// All endpoints are tenant-admin-gated (requireTenantAdmin).
// encKey is the AES-256-GCM encryption key (GOCLAW_ENCRYPTION_KEY); if empty, encrypted_secret
// is stored as "" and HMAC auth requires rotation before it can be used.
type WebhooksAdminHandler struct {
webhooks store.WebhookStore
tenants store.TenantStore
msgBus *bus.MessageBus
encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest
}
// NewWebhooksAdminHandler creates a handler for webhook admin endpoints.
func NewWebhooksAdminHandler(webhooks store.WebhookStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler {
return &WebhooksAdminHandler{
webhooks: webhooks,
tenants: tenants,
msgBus: msgBus,
}
}
// SetEncKey sets the AES-256-GCM encryption key used to encrypt raw webhook secrets at rest.
// Must be called before the first Create/Rotate request; safe to call at startup only.
func (h *WebhooksAdminHandler) SetEncKey(encKey string) {
h.encKey = encKey
}
// RegisterRoutes registers all webhook admin routes on mux.
// Admin CRUD routes mount for both editions.
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06
// conditionally: message-kind only if edition.Current().AllowsChannels().
func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /v1/webhooks", h.requireAdmin(h.handleCreate))
mux.HandleFunc("GET /v1/webhooks", h.requireAdmin(h.handleList))
mux.HandleFunc("GET /v1/webhooks/{id}", h.requireAdmin(h.handleGet))
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate))
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate))
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke))
}
func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if role := permissions.Role(store.RoleFromContext(r.Context())); role != "" {
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
writeJSON(w, http.StatusForbidden, map[string]string{
"error": i18n.T(store.LocaleFromContext(r.Context()), i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(permissions.RoleAdmin)+" role"),
})
return
}
next(w, r)
return
}
requireAuth(permissions.RoleAdmin, next)(w, r)
}
}
// --- Create ---
// createWebhookReq is the request body for POST /v1/webhooks.
type createWebhookReq struct {
Name string `json:"name"`
Kind string `json:"kind"` // "llm" | "message"
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
RateLimitPerMin int `json:"rate_limit_per_min,omitempty"`
IPAllowlist []string `json:"ip_allowlist,omitempty"`
RequireHMAC bool `json:"require_hmac,omitempty"`
LocalhostOnly bool `json:"localhost_only,omitempty"`
}
// webhookCreateResp is the response for create and rotate — includes raw secret once.
// hmac_signing_key = raw secret itself — callers sign HMAC requests using raw secret bytes.
// The raw secret is encrypted at rest; secret_hash is kept only for bearer-token lookup.
type webhookCreateResp struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenant_id"`
AgentID *uuid.UUID `json:"agent_id,omitempty"`
Name string `json:"name"`
Kind string `json:"kind"`
SecretPrefix string `json:"secret_prefix"`
Secret string `json:"secret"` // raw secret — shown ONCE; use this as HMAC key
HMACSigningKey string `json:"hmac_signing_key"` // same as Secret — raw bytes for X-GoClaw-Signature
Scopes []string `json:"scopes"`
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
RateLimitPerMin int `json:"rate_limit_per_min"`
IPAllowlist []string `json:"ip_allowlist"`
RequireHMAC bool `json:"require_hmac"`
LocalhostOnly bool `json:"localhost_only"`
CreatedAt time.Time `json:"created_at"`
}
func (h *WebhooksAdminHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "create", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
// Defense-in-depth: primary guard is skip-mount in gateway_http_wiring.go.
// This secondary guard protects if the handler is ever wired without an encKey
// (e.g. test harness or future refactor that bypasses the wiring guard).
if h.encKey == "" {
slog.Error("security.webhook.admin_no_enc_key", "action", "create")
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
return
}
var req createWebhookReq
if !bindJSON(w, r, locale, &req) {
return
}
// Validate required fields.
if req.Name == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
return
}
if len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
return
}
if !webhookKinds[req.Kind] {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "kind must be 'llm' or 'message'"))
return
}
// Edition gate: message kind requires channels edition.
if req.Kind == "message" && !edition.Current().AllowsChannels() {
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "message webhooks require Standard edition"))
return
}
// Lite edition: force localhost_only=true for all webhook kinds.
if !edition.Current().AllowsChannels() {
req.LocalhostOnly = true
}
raw, secretHash, secretPrefix, err := generateWebhookSecret()
if err != nil {
slog.Error("webhook.admin.secret_generate_failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
return
}
// Encrypt raw secret at rest. If encKey is empty, encryptedSecret is "" (requires rotation).
encryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
if encErr != nil {
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
return
}
ctx := r.Context()
tenantID := store.TenantIDFromContext(ctx)
now := time.Now()
wh := &store.WebhookData{
ID: store.GenNewID(),
TenantID: tenantID,
AgentID: req.AgentID,
Name: req.Name,
Kind: req.Kind,
SecretPrefix: secretPrefix,
SecretHash: secretHash,
EncryptedSecret: encryptedSecret,
Scopes: req.Scopes,
ChannelID: req.ChannelID,
RateLimitPerMin: req.RateLimitPerMin,
IPAllowlist: req.IPAllowlist,
RequireHMAC: req.RequireHMAC,
LocalhostOnly: req.LocalhostOnly,
Revoked: false,
CreatedBy: extractUserID(r),
CreatedAt: now,
UpdatedAt: now,
}
if wh.Scopes == nil {
wh.Scopes = []string{}
}
if wh.IPAllowlist == nil {
wh.IPAllowlist = []string{}
}
if err := h.webhooks.Create(ctx, wh); err != nil {
slog.Error("webhook.admin.create_failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "webhook", "internal error"))
return
}
slog.Info("webhook.created", "id", wh.ID, "tenant_id", tenantID, "actor", wh.CreatedBy, "kind", wh.Kind)
h.emitCacheInvalidate(wh.ID.String())
writeJSON(w, http.StatusCreated, webhookCreateResp{
ID: wh.ID,
TenantID: wh.TenantID,
AgentID: wh.AgentID,
Name: wh.Name,
Kind: wh.Kind,
SecretPrefix: wh.SecretPrefix,
Secret: raw,
HMACSigningKey: raw, // raw secret bytes are the HMAC key (encrypted at rest; decrypted at sign time)
Scopes: wh.Scopes,
ChannelID: wh.ChannelID,
RateLimitPerMin: wh.RateLimitPerMin,
IPAllowlist: wh.IPAllowlist,
RequireHMAC: wh.RequireHMAC,
LocalhostOnly: wh.LocalhostOnly,
CreatedAt: wh.CreatedAt,
})
}
// --- List ---
func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "list", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
// Optional ?agent_id= filter.
var f store.WebhookListFilter
if agentIDStr := r.URL.Query().Get("agent_id"); agentIDStr != "" {
aid, err := uuid.Parse(agentIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id"))
return
}
f.AgentID = &aid
}
rows, err := h.webhooks.List(r.Context(), f)
if err != nil {
slog.Error("webhook.admin.list_failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
return
}
if rows == nil {
rows = []store.WebhookData{}
}
writeJSON(w, http.StatusOK, rows)
}
// --- Get ---
func (h *WebhooksAdminHandler) handleGet(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "get", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
id, ok := parseWebhookID(w, r, locale)
if !ok {
return
}
wh, err := h.webhooks.GetByID(r.Context(), id)
if err != nil || wh == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
// Cross-tenant isolation: GetByID is tenant-scoped via context, but verify explicitly.
tenantID := store.TenantIDFromContext(r.Context())
if !store.IsOwnerRole(r.Context()) && wh.TenantID != tenantID {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
writeJSON(w, http.StatusOK, wh)
}
// --- Update ---
// updateWebhookReq is the request body for PATCH /v1/webhooks/{id}.
// All fields are optional; omitted fields are not changed.
type updateWebhookReq struct {
Name *string `json:"name,omitempty"`
Scopes []string `json:"scopes,omitempty"`
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
RateLimitPerMin *int `json:"rate_limit_per_min,omitempty"`
IPAllowlist []string `json:"ip_allowlist,omitempty"`
RequireHMAC *bool `json:"require_hmac,omitempty"`
LocalhostOnly *bool `json:"localhost_only,omitempty"`
}
func (h *WebhooksAdminHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "update", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
id, ok := parseWebhookID(w, r, locale)
if !ok {
return
}
ctx := r.Context()
// Verify ownership before mutating.
wh, err := h.webhooks.GetByID(ctx, id)
if err != nil || wh == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
tenantID := store.TenantIDFromContext(ctx)
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
var req updateWebhookReq
if !bindJSON(w, r, locale, &req) {
return
}
updates := make(map[string]any)
if req.Name != nil {
if *req.Name == "" {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
return
}
if len(*req.Name) > 100 {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
return
}
updates["name"] = *req.Name
}
if req.Scopes != nil {
updates["scopes"] = req.Scopes
}
if req.ChannelID != nil {
updates["channel_id"] = *req.ChannelID
}
if req.RateLimitPerMin != nil {
updates["rate_limit_per_min"] = *req.RateLimitPerMin
}
if req.IPAllowlist != nil {
updates["ip_allowlist"] = req.IPAllowlist
}
if req.RequireHMAC != nil {
updates["require_hmac"] = *req.RequireHMAC
}
if req.LocalhostOnly != nil {
// Lite edition: cannot unset localhost_only.
if !*req.LocalhostOnly && !edition.Current().AllowsChannels() {
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "localhost_only cannot be disabled on Lite edition"))
return
}
updates["localhost_only"] = *req.LocalhostOnly
}
if len(updates) == 0 {
// Nothing to update — return current state.
writeJSON(w, http.StatusOK, wh)
return
}
if err := h.webhooks.Update(ctx, id, updates); err != nil {
slog.Error("webhook.admin.update_failed", "error", err, "id", id)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "webhook", "internal error"))
return
}
slog.Info("webhook.updated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
// Re-fetch to return updated state.
updated, err := h.webhooks.GetByID(ctx, id)
if err != nil || updated == nil {
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "fetch updated webhook"))
return
}
writeJSON(w, http.StatusOK, updated)
}
// --- Rotate Secret ---
func (h *WebhooksAdminHandler) handleRotate(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "rotate", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
// Defense-in-depth: same guard as handleCreate — encryption key must be present
// before we generate and persist a new secret.
if h.encKey == "" {
slog.Error("security.webhook.admin_no_enc_key", "action", "rotate")
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
return
}
id, ok := parseWebhookID(w, r, locale)
if !ok {
return
}
ctx := r.Context()
// Verify ownership before mutating.
wh, err := h.webhooks.GetByID(ctx, id)
if err != nil || wh == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
tenantID := store.TenantIDFromContext(ctx)
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
raw, newHash, newPrefix, err := generateWebhookSecret()
if err != nil {
slog.Error("webhook.admin.secret_generate_failed", "error", err)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
return
}
newEncryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
if encErr != nil {
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
return
}
if err := h.webhooks.RotateSecret(ctx, id, newHash, newPrefix, newEncryptedSecret); err != nil {
slog.Error("webhook.admin.rotate_failed", "error", err, "id", id)
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "rotate secret"))
return
}
slog.Info("webhook.rotated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
// Invalidate the cache so the middleware picks up the new hash immediately.
h.emitCacheInvalidate(id.String())
writeJSON(w, http.StatusOK, map[string]any{
"id": id,
"secret": raw, // new raw secret — shown ONCE; use as HMAC key
"hmac_signing_key": raw, // same as secret; raw bytes are HMAC key (encrypted at rest)
"secret_prefix": newPrefix,
})
}
// --- Revoke ---
func (h *WebhooksAdminHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
locale := extractLocale(r)
if !requireTenantAdmin(w, r, h.tenants) {
slog.Warn("security.webhook.admin_denied", "action", "revoke", "path", r.URL.Path,
"user_id", store.UserIDFromContext(r.Context()))
return
}
id, ok := parseWebhookID(w, r, locale)
if !ok {
return
}
ctx := r.Context()
// Verify ownership before revoking.
wh, err := h.webhooks.GetByID(ctx, id)
if err != nil || wh == nil {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
tenantID := store.TenantIDFromContext(ctx)
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
if err := h.webhooks.Revoke(ctx, id); err != nil {
slog.Error("webhook.admin.revoke_failed", "error", err, "id", id)
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
return
}
slog.Info("webhook.revoked", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
// Invalidate the cache so the middleware rejects the old secret immediately.
h.emitCacheInvalidate(id.String())
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// --- Helpers ---
// generateWebhookSecret creates a new webhook secret in format "wh_<base32(24 bytes)>".
// Returns (rawSecret, secretHash, secretPrefix, error).
// secretPrefix = first 8 chars of rawSecret (includes "wh_" + start of base32).
// secretHash = hex(SHA-256(rawSecret)) — stored in DB, used as HMAC signing key.
func generateWebhookSecret() (raw, secretHash, secretPrefix string, err error) {
b := make([]byte, 24)
if _, err = rand.Read(b); err != nil {
return "", "", "", err
}
// base32 (no padding) produces 40 chars for 24 bytes.
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
raw = "wh_" + encoded // total 43 chars
h := sha256.Sum256([]byte(raw))
secretHash = hex.EncodeToString(h[:])
// First 8 chars of the full raw secret (includes "wh_" + first 5 base32 chars).
secretPrefix = raw[:8]
return raw, secretHash, secretPrefix, nil
}
// parseWebhookID parses the {id} path value, writing a 400 on error.
func parseWebhookID(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, bool) {
idStr := r.PathValue("id")
id, err := uuid.Parse(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "webhook"))
return uuid.Nil, false
}
return id, true
}
// emitCacheInvalidate broadcasts a cache invalidation event for webhook secrets.
// This signals the WebhookAuthMiddleware (phase 03) to drop cached entries.
func (h *WebhooksAdminHandler) emitCacheInvalidate(webhookID string) {
if h.msgBus == nil {
return
}
h.msgBus.Broadcast(bus.Event{
Name: protocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: "webhooks", Key: webhookID},
})
}