From 831b5c1a437d62e6d028400e238c63be0d15199c Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 7 May 2026 02:34:40 +0300 Subject: [PATCH] feat(webhook): HMAC-SHA256 signature verification on inbound webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/router.go | 7 + internal/api/webhooks.go | 188 ++++++++++++++++++- internal/store/models.go | 8 +- internal/store/projects.go | 94 ++++++++-- internal/store/static_sites.go | 55 +++++- internal/store/store.go | 8 + internal/webhook/handler.go | 85 ++++++++- internal/webhook/handler_test.go | 146 +++++++++++++++ web/src/lib/api.ts | 30 ++++ web/src/lib/components/WebhookPanel.svelte | 198 ++++++++++++++++++++- web/src/lib/i18n/en.json | 21 ++- web/src/lib/i18n/ru.json | 21 ++- web/src/routes/projects/[id]/+page.svelte | 3 + web/src/routes/sites/[id]/+page.svelte | 3 + 14 files changed, 827 insertions(+), 40 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 91b57dc..bc2f229 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/internal/api/webhooks.go b/internal/api/webhooks.go index 8307a56..b477ba3 100644 --- a/internal/api/webhooks.go +++ b/internal/api/webhooks.go @@ -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") diff --git a/internal/store/models.go b/internal/store/models.go index acd4248..6b81551 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -11,7 +11,9 @@ type Project struct { Env string `json:"env"` // JSON-encoded map Volumes string `json:"volumes"` // JSON-encoded map NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global - WebhookSecret string `json:"-"` // per-project webhook secret; never serialized directly + WebhookSecret string `json:"-"` // per-project webhook secret (URL identifier); never serialized + WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized + WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly CreatedAt string `json:"created_at"` @@ -258,7 +260,9 @@ type StaticSite struct { Error string `json:"error"` StorageEnabled bool `json:"storage_enabled"` StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited - WebhookSecret string `json:"-"` // per-site webhook secret; never serialized directly + WebhookSecret string `json:"-"` // per-site webhook secret (URL identifier); never serialized + WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized + WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly CreatedAt string `json:"created_at"` diff --git a/internal/store/projects.go b/internal/store/projects.go index c7caf21..d89c21e 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -31,7 +31,27 @@ func generateWebhookSecret() string { // projectCols is the canonical column list for projects queries. const projectCols = `id, name, registry, image, port, healthcheck, env, volumes, - npm_access_list_id, webhook_secret, notification_url, notification_secret, created_at, updated_at` + npm_access_list_id, webhook_secret, webhook_signing_secret, webhook_require_signature, + notification_url, notification_secret, created_at, updated_at` + +// rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject. +type rowScanner interface { + Scan(dest ...any) error +} + +// scanProject reads one row in projectCols order. webhook_require_signature +// is stored as INTEGER and converted to bool here. +func scanProject(r rowScanner) (Project, error) { + var p Project + var requireSig int + if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, + &p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig, + &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { + return Project{}, err + } + p.WebhookRequireSignature = requireSig != 0 + return p, nil +} // CreateProject inserts a new project and returns it. A webhook secret is // generated automatically if one is not already set on the input. @@ -45,11 +65,16 @@ func (s *Store) CreateProject(p Project) (Project, error) { return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength) } + requireSig := 0 + if p.WebhookRequireSignature { + requireSig = 1 + } _, err := s.db.Exec( `INSERT INTO projects (`+projectCols+`) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes, - p.NpmAccessListID, p.WebhookSecret, p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt, + p.NpmAccessListID, p.WebhookSecret, p.WebhookSigningSecret, requireSig, + p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt, ) if err != nil { return Project{}, fmt.Errorf("insert project: %w", err) @@ -59,11 +84,8 @@ func (s *Store) CreateProject(p Project) (Project, error) { // GetProjectByID returns a single project by its ID. func (s *Store) GetProjectByID(id string) (Project, error) { - var p Project - err := s.db.QueryRow( - `SELECT `+projectCols+` FROM projects WHERE id = ?`, id, - ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, - &p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt) + row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id) + p, err := scanProject(row) if errors.Is(err, sql.ErrNoRows) { return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound) } @@ -79,11 +101,8 @@ func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) { if secret == "" { return Project{}, ErrNotFound } - var p Project - err := s.db.QueryRow( - `SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret, - ).Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, - &p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt) + row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret) + p, err := scanProject(row) if errors.Is(err, sql.ErrNoRows) { return Project{}, ErrNotFound } @@ -105,9 +124,8 @@ func (s *Store) GetAllProjects() ([]Project, error) { projects := []Project{} for rows.Next() { - var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, - &p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { + p, err := scanProject(rows) + if err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -127,9 +145,8 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) { projects := []Project{} for rows.Next() { - var p Project - if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, - &p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil { + p, err := scanProject(rows) + if err != nil { return nil, fmt.Errorf("scan project: %w", err) } projects = append(projects, p) @@ -176,6 +193,45 @@ func (s *Store) SetProjectWebhookSecret(id, secret string) error { return nil } +// SetProjectWebhookSigningSecret assigns the HMAC signing secret used to +// verify inbound webhook payloads. Pass an empty string to clear it (which +// also implicitly disables signature enforcement on the next request). +func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error { + result, err := s.db.Exec( + `UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`, + secret, Now(), id, + ) + if err != nil { + return fmt.Errorf("set project webhook signing secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("project %s: %w", id, ErrNotFound) + } + return nil +} + +// SetProjectWebhookRequireSignature toggles whether unsigned (or +// invalidly-signed) webhook requests are rejected with 401. +func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error { + v := 0 + if require { + v = 1 + } + result, err := s.db.Exec( + `UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`, + v, Now(), id, + ) + if err != nil { + return fmt.Errorf("set project webhook require_signature: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("project %s: %w", id, ErrNotFound) + } + return nil +} + // EnsureProjectWebhookSecret returns the current webhook secret for a project, // generating one on the fly if the stored value is empty (lazy backfill for // projects created before the per-project webhook migration). diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index 8c0afcb..eaca341 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -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). diff --git a/internal/store/store.go b/internal/store/store.go index f8f3a6f..5a774f3 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -151,6 +151,14 @@ func (s *Store) runMigrations() error { // triggers a "pre-deploy" Tinyforge DB backup before any project deploy // so a corrupted deploy is recoverable without data loss. `ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`, + // Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret + // is the HMAC-SHA256 key separate from the URL secret so a leaked URL + // alone is not sufficient to forge a valid request. require_signature + // rejects unsigned requests when set (defense-in-depth opt-in). + `ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`, } // Additive stack tables (2026-04-16). Created here rather than in the diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 545d67e..eaa9acb 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -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") diff --git a/internal/webhook/handler_test.go b/internal/webhook/handler_test.go index 54b8f2b..8193944 100644 --- a/internal/webhook/handler_test.go +++ b/internal/webhook/handler_test.go @@ -2,6 +2,9 @@ package webhook_test import ( "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "io" "net/http" @@ -16,6 +19,29 @@ import ( "github.com/alexei/tinyforge/internal/webhook" ) +// signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header. +func signBody(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header. +func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if signingSecret != "" { + req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body)) + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + resp := w.Result() + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(b) +} + // fakeDeployer records the last trigger for assertion. type fakeDeployer struct { mu sync.Mutex @@ -309,3 +335,123 @@ func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) { t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls) } } + +// HMAC enforcement scenarios. + +func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) { + t.Parallel() + st := newStore(t) + p, _ := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatal(err) + } + const sig = "deadbeef-signing-secret-1234567890abcdef" + if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil { + t.Fatal(err) + } + if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { + t.Fatal(err) + } + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + body := `{"image":"alexei/app:dev-abc"}` + resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg) + } + if dep.calls != 1 { + t.Errorf("valid signed deploy should fire once, got %d", dep.calls) + } +} + +func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) { + t.Parallel() + st := newStore(t) + p, _ := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatal(err) + } + if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil { + t.Fatal(err) + } + if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil { + t.Fatal(err) + } + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode) + } + if dep.calls != 0 { + t.Errorf("deploy must not fire when required signature is missing") + } +} + +func TestProjectWebhook_HMACPresentButWrong(t *testing.T) { + t.Parallel() + st := newStore(t) + p, _ := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatal(err) + } + if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil { + t.Fatal(err) + } + // Note: require_signature stays false — but a wrong sig must still 401. + + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + + resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, + `{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("wrong signature must 401, got %d", resp.StatusCode) + } + if dep.calls != 0 { + t.Errorf("deploy must not fire on wrong signature") + } +} + +func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) { + // require_signature=false AND signing_secret="": unsigned requests pass. + t.Parallel() + st := newStore(t) + p, _ := st.CreateProject(store.Project{ + Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}", + }) + if _, err := st.CreateStage(store.Stage{ + ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1, + }); err != nil { + t.Fatal(err) + } + dep := &fakeDeployer{} + h := webhook.NewHandler(st, dep, nil) + r := newRouter(t, h) + resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`) + if resp.StatusCode != http.StatusOK { + t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode) + } + if dep.calls != 1 { + t.Errorf("expected 1 deploy, got %d", dep.calls) + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 59d5f80..291aea6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -328,6 +328,12 @@ export function updateSettings(data: Partial): Promise { export interface WebhookUrlResponse { webhook_url: string; webhook_secret: string; + has_signing_secret?: boolean; + webhook_require_signature?: boolean; +} + +export interface SigningSecretResponse { + signing_secret: string; } export function getProjectWebhook(projectId: string): Promise { @@ -338,6 +344,18 @@ export function regenerateProjectWebhook(projectId: string): Promise(`/api/projects/${projectId}/webhook/regenerate`); } +export function regenerateProjectSigningSecret(projectId: string): Promise { + return post(`/api/projects/${projectId}/webhook/signing-secret/regenerate`); +} + +export async function disableProjectSigningSecret(projectId: string): Promise { + await del(`/api/projects/${projectId}/webhook/signing-secret`); +} + +export async function setProjectRequireSignature(projectId: string, require: boolean): Promise { + await put(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require }); +} + export function getStaticSiteWebhook(siteId: string): Promise { return get(`/api/sites/${siteId}/webhook`); } @@ -346,6 +364,18 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise(`/api/sites/${siteId}/webhook/regenerate`); } +export function regenerateStaticSiteSigningSecret(siteId: string): Promise { + return post(`/api/sites/${siteId}/webhook/signing-secret/regenerate`); +} + +export async function disableStaticSiteSigningSecret(siteId: string): Promise { + await del(`/api/sites/${siteId}/webhook/signing-secret`); +} + +export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise { + await put(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require }); +} + // ── Outgoing-webhook signing & test ──────────────────────────────── export interface NotificationSecretResponse { diff --git a/web/src/lib/components/WebhookPanel.svelte b/web/src/lib/components/WebhookPanel.svelte index 2d71826..06c8f69 100644 --- a/web/src/lib/components/WebhookPanel.svelte +++ b/web/src/lib/components/WebhookPanel.svelte @@ -11,11 +11,18 @@ import { onMount } from 'svelte'; import { toasts } from '$lib/stores/toast'; import { t } from '$lib/i18n'; - import { IconCopy, IconRefresh, IconLoader } from '$lib/components/icons'; + import ToggleSwitch from './ToggleSwitch.svelte'; + import { IconCopy, IconRefresh, IconLoader, IconShield, IconX } from '$lib/components/icons'; interface WebhookUrlResponse { webhook_url: string; webhook_secret: string; + has_signing_secret?: boolean; + webhook_require_signature?: boolean; + } + + interface SigningSecretResponse { + signing_secret: string; } interface Props { @@ -23,15 +30,35 @@ description: string; fetchWebhook: () => Promise; regenerateWebhook: () => Promise; + // Inbound HMAC signing — optional; if omitted, the signing UI hides. + regenerateSigningSecret?: () => Promise; + disableSigning?: () => Promise; + setRequireSignature?: (require: boolean) => Promise; } - let { title, description, fetchWebhook, regenerateWebhook }: Props = $props(); + let { + title, + description, + fetchWebhook, + regenerateWebhook, + regenerateSigningSecret, + disableSigning, + setRequireSignature + }: Props = $props(); let relativeUrl = $state(''); let loading = $state(true); let regenerating = $state(false); let confirmOpen = $state(false); + // Signing state. + let hasSigningSecret = $state(false); + let requireSignature = $state(false); + // Newly issued signing secret — displayed once after rotate, hidden on next load. + let issuedSigningSecret = $state(''); + let signingBusy = $state(false); + let confirmDisableSigning = $state(false); + const absoluteUrl = $derived( relativeUrl && typeof window !== 'undefined' ? window.location.origin + relativeUrl : relativeUrl ); @@ -41,6 +68,11 @@ try { const res = await fetchWebhook(); relativeUrl = res.webhook_url; + hasSigningSecret = res.has_signing_secret ?? false; + requireSignature = res.webhook_require_signature ?? false; + // Hide any previously-displayed issued secret on reload — it + // must only ever be shown once at issue time. + issuedSigningSecret = ''; } catch (err) { toasts.error(err instanceof Error ? err.message : $t('webhookPanel.loadFailed')); } finally { @@ -54,6 +86,8 @@ try { const res = await regenerateWebhook(); relativeUrl = res.webhook_url; + hasSigningSecret = res.has_signing_secret ?? hasSigningSecret; + requireSignature = res.webhook_require_signature ?? requireSignature; toasts.success($t('webhookPanel.regenerated')); } catch (err) { toasts.error(err instanceof Error ? err.message : $t('webhookPanel.regenerateFailed')); @@ -70,6 +104,59 @@ ); } + function copyIssuedSecret() { + if (!issuedSigningSecret) return; + navigator.clipboard.writeText(issuedSigningSecret).then( + () => toasts.info($t('webhookPanel.signingCopied')), + () => toasts.error($t('webhookPanel.copyFailed')) + ); + } + + async function handleIssueSigning() { + if (!regenerateSigningSecret) return; + signingBusy = true; + try { + const res = await regenerateSigningSecret(); + issuedSigningSecret = res.signing_secret; + hasSigningSecret = true; + toasts.success($t('webhookPanel.signingIssued')); + } catch (err) { + toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingIssueFailed')); + } finally { + signingBusy = false; + } + } + + async function handleDisableSigning() { + if (!disableSigning) return; + confirmDisableSigning = false; + signingBusy = true; + try { + await disableSigning(); + hasSigningSecret = false; + requireSignature = false; + issuedSigningSecret = ''; + toasts.success($t('webhookPanel.signingDisabled')); + } catch (err) { + toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingDisableFailed')); + } finally { + signingBusy = false; + } + } + + async function handleToggleRequire(next: boolean) { + if (!setRequireSignature) return; + // Optimistic UI; revert on error. + const previous = requireSignature; + requireSignature = next; + try { + await setRequireSignature(next); + } catch (err) { + requireSignature = previous; + toasts.error(err instanceof Error ? err.message : $t('webhookPanel.signingRequireFailed')); + } + } + onMount(() => { load(); }); @@ -133,4 +220,111 @@

{$t('webhookPanel.regenerateWarning')}

{/if} + + + {#if regenerateSigningSecret && setRequireSignature && disableSigning} +
+
+ +
+

{$t('webhookPanel.signingTitle')}

+

{$t('webhookPanel.signingDesc')}

+
+
+ + {#if issuedSigningSecret} + +
+

{$t('webhookPanel.signingShownOnce')}

+
+ + {issuedSigningSecret} + + + +
+

+ {$t('webhookPanel.signingHint', { header: 'X-Hub-Signature-256' })} +

+
+ {/if} + +
+
+ {#if hasSigningSecret} + {$t('webhookPanel.signingActive')} + {:else} + {$t('webhookPanel.signingInactive')} + {/if} +
+
+ + {#if hasSigningSecret} + {#if confirmDisableSigning} + + + {:else} + + {/if} + {/if} +
+
+ +
+ +
+ {$t('webhookPanel.requireSignature')} +

{$t('webhookPanel.requireSignatureHelp')}

+
+
+
+ {/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index e685e7c..2716b6e 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1184,7 +1184,26 @@ "regenerateWarning": "Regenerating invalidates the current URL. Update any CI pipeline or Git webhook that uses it.", "confirmRegenerate": "Replace the current URL?", "confirmYes": "Regenerate", - "confirmNo": "Cancel" + "confirmNo": "Cancel", + "signingTitle": "Inbound HMAC signing", + "signingDesc": "Verify webhook payloads with an HMAC-SHA256 signature so a leaked URL alone cannot be used to forge requests. Compatible with Gitea/GitHub webhook secrets.", + "signingActive": "Signing secret configured.", + "signingInactive": "No signing secret — inbound requests are not authenticated beyond the URL.", + "signingIssue": "Issue signing secret", + "signingRotate": "Rotate signing secret", + "signingDisable": "Disable signing", + "signingDisableConfirm": "Disable signing", + "signingIssued": "New signing secret issued — copy it before leaving this page", + "signingIssueFailed": "Failed to issue signing secret", + "signingDisabled": "Signing disabled", + "signingDisableFailed": "Failed to disable signing", + "signingShownOnce": "Copy this secret now — it will not be shown again.", + "signingDismiss": "Dismiss", + "signingHint": "Set this as the webhook secret in Gitea/GitHub/GitLab. Tinyforge expects {header} on every request.", + "signingCopied": "Signing secret copied to clipboard", + "requireSignature": "Require signature", + "requireSignatureHelp": "Reject any request that lacks a valid signature. Issue a signing secret first.", + "signingRequireFailed": "Failed to update signature requirement" }, "outgoingWebhook": { "signingOn": "Signed", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 5399fec..1ebcff1 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1184,7 +1184,26 @@ "regenerateWarning": "Перегенерация инвалидирует текущий URL. Обновите CI-пайплайны и Git-вебхуки, использующие его.", "confirmRegenerate": "Заменить текущий URL?", "confirmYes": "Перегенерировать", - "confirmNo": "Отмена" + "confirmNo": "Отмена", + "signingTitle": "Подпись входящих вебхуков (HMAC)", + "signingDesc": "Проверка подписи HMAC-SHA256 — утечка только URL не позволит подделать запрос. Совместимо с секретами вебхуков Gitea/GitHub.", + "signingActive": "Секрет подписи настроен.", + "signingInactive": "Секрет подписи не задан — входящие запросы не проверяются помимо URL.", + "signingIssue": "Сгенерировать секрет", + "signingRotate": "Перевыпустить секрет", + "signingDisable": "Отключить подпись", + "signingDisableConfirm": "Отключить", + "signingIssued": "Новый секрет подписи выпущен — скопируйте его сейчас", + "signingIssueFailed": "Не удалось сгенерировать секрет подписи", + "signingDisabled": "Подпись отключена", + "signingDisableFailed": "Не удалось отключить подпись", + "signingShownOnce": "Скопируйте секрет сейчас — он больше не будет показан.", + "signingDismiss": "Скрыть", + "signingHint": "Используйте это значение как webhook-секрет в Gitea/GitHub/GitLab. Tinyforge ожидает заголовок {header}.", + "signingCopied": "Секрет подписи скопирован в буфер обмена", + "requireSignature": "Требовать подпись", + "requireSignatureHelp": "Отклонять запросы без действительной подписи. Сначала сгенерируйте секрет.", + "signingRequireFailed": "Не удалось обновить требование подписи" }, "outgoingWebhook": { "signingOn": "Подпись включена", diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index b7044fb..5c19289 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -806,6 +806,9 @@ description={$t('projectDetail.webhookDesc')} fetchWebhook={() => api.getProjectWebhook(projectId)} regenerateWebhook={() => api.regenerateProjectWebhook(projectId)} + regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)} + disableSigning={() => api.disableProjectSigningSecret(projectId)} + setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)} /> diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte index 363e2b2..d189d2a 100644 --- a/web/src/routes/sites/[id]/+page.svelte +++ b/web/src/routes/sites/[id]/+page.svelte @@ -312,6 +312,9 @@ description={$t('sites.webhookDesc')} fetchWebhook={() => api.getStaticSiteWebhook(siteId!)} regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)} + regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)} + disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)} + setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)} />