feat(webhook): HMAC-SHA256 signature verification on inbound webhooks
Adds an opt-in inbound HMAC scheme so a leaked URL alone is not enough
to forge deploy/sync requests — the caller must also know a separate
signing secret. Header format is X-Hub-Signature-256, matching the
Gitea/GitHub/GitLab convention so existing CI integrations work without
custom code.
Behaviour:
- per-project / per-site signing_secret is independent of the URL secret
- require_signature flag does a hard 401 on missing/invalid signatures
- even when require_signature is off, an *invalid* submitted signature
returns 401 — surfaces CI misconfiguration instead of silently passing
- comparison uses subtle/hmac.Equal (constant time)
Backend:
- store: webhook_signing_secret + webhook_require_signature columns on
projects + static_sites; scanProject helper, scan helpers updated; new
Set* helpers for both fields
- webhook/handler: verifyHMAC helper, body read once, integrated into
both project and site handlers
- api: per-entity signing-secret rotate / disable / require-toggle
endpoints under /api/{projects,sites}/{id}/webhook/...
Frontend:
- WebhookPanel gains optional signing handlers (no breaking change for
existing callers; signing UI hides when handlers aren't wired)
- one-shot reveal of the issued secret with copy + dismiss
- ToggleSwitch for require-signature, disabled until a secret is issued
- en/ru i18n strings
Tests:
- HMACRequiredAndValid (200 + deploy fires)
- HMACRequiredButMissing (401, no deploy)
- HMACPresentButWrong (401 even when require_signature=false)
- HMACOptionalUnsignedAccepted (200 when neither configured)
This commit is contained in:
@@ -245,6 +245,10 @@ func (s *Server) Router() chi.Router {
|
||||
// Per-project webhook URL management.
|
||||
r.Get("/webhook", s.getProjectWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
||||
// Inbound HMAC signing — secret rotation + enforcement toggle.
|
||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
|
||||
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
|
||||
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
|
||||
|
||||
// Per-project outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
||||
@@ -325,6 +329,9 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/start", s.startStaticSite)
|
||||
r.Get("/webhook", s.getStaticSiteWebhook)
|
||||
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
|
||||
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
|
||||
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
|
||||
|
||||
// Per-site outgoing-webhook signing & test.
|
||||
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
||||
|
||||
+182
-6
@@ -27,8 +27,20 @@ func generateWebhookSecret() string {
|
||||
// Clients never see raw secrets except at issue/rotate time via these fields;
|
||||
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
|
||||
type webhookURLResponse struct {
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
WebhookSecret string `json:"webhook_secret"`
|
||||
HasSigningSecret bool `json:"has_signing_secret"`
|
||||
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
||||
}
|
||||
|
||||
// signingSecretResponse is returned when a signing secret is issued or rotated.
|
||||
type signingSecretResponse struct {
|
||||
SigningSecret string `json:"signing_secret"`
|
||||
}
|
||||
|
||||
// signingToggleRequest is the body of the require-signature toggle endpoint.
|
||||
type signingToggleRequest struct {
|
||||
RequireSignature bool `json:"require_signature"`
|
||||
}
|
||||
|
||||
// getProjectWebhook handles GET /api/projects/{id}/webhook.
|
||||
@@ -48,12 +60,97 @@ func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
project, err := s.store.GetProjectByID(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/" + secret,
|
||||
WebhookSecret: secret,
|
||||
WebhookURL: "/api/webhook/" + secret,
|
||||
WebhookSecret: secret,
|
||||
HasSigningSecret: project.WebhookSigningSecret != "",
|
||||
WebhookRequireSignature: project.WebhookRequireSignature,
|
||||
})
|
||||
}
|
||||
|
||||
// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate.
|
||||
// Issues a fresh HMAC signing secret for inbound webhook verification. The
|
||||
// secret is returned exactly once — the UI is responsible for letting the
|
||||
// user copy it into their CI configuration.
|
||||
func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil {
|
||||
slog.Error("rotate project signing secret", "project", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
||||
return
|
||||
}
|
||||
slog.Info("project webhook signing secret rotated", "project", id)
|
||||
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
||||
}
|
||||
|
||||
// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret.
|
||||
// Clears the HMAC signing secret and disables enforcement.
|
||||
func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil {
|
||||
slog.Warn("disable project require_signature", "project", id, "error", err)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature.
|
||||
// Toggles whether unsigned/invalidly-signed inbound webhook requests are
|
||||
// rejected with 401.
|
||||
func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req signingToggleRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.RequireSignature {
|
||||
project, err := s.store.GetProjectByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||
return
|
||||
}
|
||||
if project.WebhookSigningSecret == "" {
|
||||
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "project")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
|
||||
// Rotates the project's webhook secret, invalidating the old URL.
|
||||
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -99,12 +196,91 @@ func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
site, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||
WebhookURL: "/api/webhook/sites/" + secret,
|
||||
WebhookSecret: secret,
|
||||
WebhookURL: "/api/webhook/sites/" + secret,
|
||||
WebhookSecret: secret,
|
||||
HasSigningSecret: site.WebhookSigningSecret != "",
|
||||
WebhookRequireSignature: site.WebhookRequireSignature,
|
||||
})
|
||||
}
|
||||
|
||||
// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate.
|
||||
func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
secret := generateWebhookSecret()
|
||||
if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil {
|
||||
slog.Error("rotate site signing secret", "site", id, "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
||||
return
|
||||
}
|
||||
slog.Info("static site webhook signing secret rotated", "site", id)
|
||||
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
||||
}
|
||||
|
||||
// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret.
|
||||
func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil {
|
||||
slog.Warn("disable site require_signature", "site", id, "error", err)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
|
||||
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req signingToggleRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.RequireSignature {
|
||||
site, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
if site.WebhookSigningSecret == "" {
|
||||
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
|
||||
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
Reference in New Issue
Block a user