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
+48 -7
View File
@@ -13,7 +13,8 @@ import (
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
storage_enabled, storage_limit_mb, webhook_secret,
storage_enabled, storage_limit_mb,
webhook_secret, webhook_signing_secret, webhook_require_signature,
notification_url, notification_secret,
created_at, updated_at`
@@ -31,13 +32,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
_, err := s.db.Exec(
`INSERT INTO static_sites (`+staticSiteCols+`)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
site.WebhookSecret,
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
site.NotificationURL, site.NotificationSecret,
site.CreatedAt, site.UpdatedAt,
)
@@ -228,14 +229,14 @@ func (s *Store) DeleteStaticSite(id string) error {
// scanStaticSiteRow scans a static site from a *sql.Row.
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled int
var renderMarkdown, storageEnabled, requireSig int
err := row.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
@@ -244,20 +245,21 @@ func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
var site StaticSite
var renderMarkdown, storageEnabled int
var renderMarkdown, storageEnabled, requireSig int
err := rows.Scan(
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
&site.WebhookSecret,
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
&site.NotificationURL, &site.NotificationSecret,
&site.CreatedAt, &site.UpdatedAt,
)
@@ -266,9 +268,48 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
}
site.RenderMarkdown = renderMarkdown != 0
site.StorageEnabled = storageEnabled != 0
site.WebhookRequireSignature = requireSig != 0
return site, nil
}
// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret.
// Pass an empty string to clear it (also implicitly disables enforcement).
func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error {
result, err := s.db.Exec(
`UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
secret, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site webhook signing secret: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or
// invalidly-signed) inbound webhook requests are rejected with 401.
func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) error {
v := 0
if require {
v = 1
}
result, err := s.db.Exec(
`UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`,
v, Now(), id,
)
if err != nil {
return fmt.Errorf("set static site webhook require_signature: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
}
return nil
}
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
// signing secret. Empty string disables HMAC signing for this site
// (notifications still send unsigned, falling through to global resolution).