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:
2026-05-07 02:34:40 +03:00
parent 793570f4a1
commit 831b5c1a43
14 changed files with 827 additions and 40 deletions
+83 -2
View File
@@ -2,6 +2,9 @@ package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -16,6 +19,47 @@ import (
"github.com/alexei/tinyforge/internal/store"
)
// signatureHeader is the canonical Gitea/GitHub-compatible header name for
// HMAC-SHA256 signatures over the raw request body. Tinyforge accepts the
// same header so existing CI integrations work unchanged.
const signatureHeader = "X-Hub-Signature-256"
// verifyHMAC validates the X-Hub-Signature-256 header against the raw body
// using HMAC-SHA256. The function does the comparison in constant time.
//
// Behavior:
// - signingSecret == "": signing not configured for this entity. The
// function returns (false, false) — the caller decides whether to
// enforce based on the require_signature flag.
// - header missing: returns (false, true) — caller-decided.
// - header malformed or signature mismatch: returns (false, true).
// - signature valid: returns (true, true).
//
// First return: whether the signature was successfully verified.
// Second return: whether the verification was attempted (i.e., a header was
// present or signing is configured). The caller uses this to distinguish
// "no signature submitted" from "wrong signature submitted".
func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified, attempted bool) {
if signingSecret == "" {
return false, false
}
if headerValue == "" {
return false, false
}
const prefix = "sha256="
if !strings.HasPrefix(headerValue, prefix) {
return false, true
}
provided, err := hex.DecodeString(headerValue[len(prefix):])
if err != nil {
return false, true
}
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write(body)
expected := mac.Sum(nil)
return hmac.Equal(provided, expected), true
}
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
// webhooks. Above this limit, requests are rejected with 503.
const maxSiteConcurrentSyncs = 4
@@ -217,9 +261,31 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// Read body once so we can both verify HMAC and decode JSON.
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
// HMAC enforcement: a configured signing secret + the require_signature
// flag together produce a hard reject on missing/invalid signatures.
// When the flag is off we still verify any submitted signature so a
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
if project.WebhookRequireSignature && !verified {
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: bad signature", "project", project.Name)
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
var payload Payload
dec := json.NewDecoder(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err := dec.Decode(&payload); err != nil {
if err := json.Unmarshal(body, &payload); err != nil {
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
@@ -347,6 +413,21 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
return
}
// HMAC enforcement matches the project flow: hard reject when required,
// soft reject when an invalid signature is supplied without enforcement.
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, r.Header.Get(signatureHeader))
if site.WebhookRequireSignature && !verified {
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
return
}
if attempted && !verified {
slog.Warn("webhook: site bad signature", "site", site.Name)
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
return
}
if len(body) > 0 {
if err := json.Unmarshal(body, &payload); err != nil {
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")