Files
xarmian bfa32dde5a fix(security): encrypt webhook HMAC secrets at rest, mask in responses (BUG-2057) (#915)
Webhook signing secrets were stored plaintext in the webhooks.secret column
and echoed back in every API response. Encrypt them at rest (reusing the
existing AES-256-GCM store helpers, same pattern as TOTP secrets) and return
the raw secret ONLY in the creation response; list responses now mask it and
expose a has_secret flag instead.

- store: encrypt on CreateWebhook, decrypt on Get/ListWebhooks so the
  dispatcher still signs with the plaintext secret. Reuses the secret column
  with the "enc:" prefix — no new column/migration. Keyless self-host stays a
  no-op fallback (encrypt returns plaintext; decrypt passes legacy rows
  through unchanged).
- BackfillEncryptWebhookSecrets encrypts pre-existing plaintext rows on
  startup once a key is configured (idempotent), mirroring the TOTP backfill.
- model: add HasSecret so masked responses still signal presence.
- handlers: mask secret on list; document raw-only-on-create.
- tests: encrypt-at-rest round-trip + HMAC validity, list decrypt,
  plaintext backfill/back-compat, and the API mask-except-on-create contract.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 00:10:27 -04:00

34 lines
1.4 KiB
Go

package models
import "time"
// Webhook represents a registered webhook endpoint that receives
// POST notifications when events occur in a workspace.
type Webhook struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
URL string `json:"url"`
// Secret is the HMAC signing secret in PLAINTEXT. It is encrypted at rest
// (see internal/store/webhooks.go) and decrypted on read for internal use
// (the dispatcher signs payloads with it). API responses return the raw
// secret ONLY on creation; list/get responses mask it — see
// internal/server/handlers_webhooks.go::maskWebhookSecret.
Secret string `json:"secret,omitempty"`
// HasSecret reports whether an HMAC secret is configured, without leaking
// the value. Populated on reads so masked responses still signal presence.
HasSecret bool `json:"has_secret"`
Events string `json:"events"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at,omitempty"`
FailureCount int `json:"failure_count"`
}
// WebhookCreate is the input for registering a new webhook.
type WebhookCreate struct {
URL string `json:"url"`
Secret string `json:"secret,omitempty"`
Events string `json:"events,omitempty"` // JSON array of event types, defaults to ["*"]
}