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.
|
// Per-project webhook URL management.
|
||||||
r.Get("/webhook", s.getProjectWebhook)
|
r.Get("/webhook", s.getProjectWebhook)
|
||||||
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
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.
|
// Per-project outgoing-webhook signing & test.
|
||||||
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
||||||
@@ -325,6 +329,9 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Post("/start", s.startStaticSite)
|
r.Post("/start", s.startStaticSite)
|
||||||
r.Get("/webhook", s.getStaticSiteWebhook)
|
r.Get("/webhook", s.getStaticSiteWebhook)
|
||||||
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
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.
|
// Per-site outgoing-webhook signing & test.
|
||||||
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
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;
|
// 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.
|
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
|
||||||
type webhookURLResponse struct {
|
type webhookURLResponse struct {
|
||||||
WebhookURL string `json:"webhook_url"`
|
WebhookURL string `json:"webhook_url"`
|
||||||
WebhookSecret string `json:"webhook_secret"`
|
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.
|
// getProjectWebhook handles GET /api/projects/{id}/webhook.
|
||||||
@@ -48,12 +60,97 @@ func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
project, err := s.store.GetProjectByID(id)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||||
WebhookURL: "/api/webhook/" + secret,
|
WebhookURL: "/api/webhook/" + secret,
|
||||||
WebhookSecret: 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.
|
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
|
||||||
// Rotates the project's webhook secret, invalidating the old URL.
|
// Rotates the project's webhook secret, invalidating the old URL.
|
||||||
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
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
|
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{
|
respondJSON(w, http.StatusOK, webhookURLResponse{
|
||||||
WebhookURL: "/api/webhook/sites/" + secret,
|
WebhookURL: "/api/webhook/sites/" + secret,
|
||||||
WebhookSecret: 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.
|
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
|
||||||
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ type Project struct {
|
|||||||
Env string `json:"env"` // JSON-encoded map
|
Env string `json:"env"` // JSON-encoded map
|
||||||
Volumes string `json:"volumes"` // JSON-encoded map
|
Volumes string `json:"volumes"` // JSON-encoded map
|
||||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
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
|
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
@@ -258,7 +260,9 @@ type StaticSite struct {
|
|||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
StorageEnabled bool `json:"storage_enabled"`
|
StorageEnabled bool `json:"storage_enabled"`
|
||||||
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
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
|
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
||||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
|||||||
+75
-19
@@ -31,7 +31,27 @@ func generateWebhookSecret() string {
|
|||||||
|
|
||||||
// projectCols is the canonical column list for projects queries.
|
// projectCols is the canonical column list for projects queries.
|
||||||
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
|
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
|
// CreateProject inserts a new project and returns it. A webhook secret is
|
||||||
// generated automatically if one is not already set on the input.
|
// 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)
|
return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requireSig := 0
|
||||||
|
if p.WebhookRequireSignature {
|
||||||
|
requireSig = 1
|
||||||
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO projects (`+projectCols+`)
|
`INSERT INTO projects (`+projectCols+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
|
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 {
|
if err != nil {
|
||||||
return Project{}, fmt.Errorf("insert project: %w", err)
|
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.
|
// GetProjectByID returns a single project by its ID.
|
||||||
func (s *Store) GetProjectByID(id string) (Project, error) {
|
func (s *Store) GetProjectByID(id string) (Project, error) {
|
||||||
var p Project
|
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
|
||||||
err := s.db.QueryRow(
|
p, err := scanProject(row)
|
||||||
`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)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
||||||
}
|
}
|
||||||
@@ -79,11 +101,8 @@ func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
|
|||||||
if secret == "" {
|
if secret == "" {
|
||||||
return Project{}, ErrNotFound
|
return Project{}, ErrNotFound
|
||||||
}
|
}
|
||||||
var p Project
|
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret)
|
||||||
err := s.db.QueryRow(
|
p, err := scanProject(row)
|
||||||
`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)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Project{}, ErrNotFound
|
return Project{}, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -105,9 +124,8 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
|||||||
|
|
||||||
projects := []Project{}
|
projects := []Project{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
p, err := scanProject(rows)
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
if err != nil {
|
||||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan project: %w", err)
|
return nil, fmt.Errorf("scan project: %w", err)
|
||||||
}
|
}
|
||||||
projects = append(projects, p)
|
projects = append(projects, p)
|
||||||
@@ -127,9 +145,8 @@ func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
|||||||
|
|
||||||
projects := []Project{}
|
projects := []Project{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
p, err := scanProject(rows)
|
||||||
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
if err != nil {
|
||||||
&p.NpmAccessListID, &p.WebhookSecret, &p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan project: %w", err)
|
return nil, fmt.Errorf("scan project: %w", err)
|
||||||
}
|
}
|
||||||
projects = append(projects, p)
|
projects = append(projects, p)
|
||||||
@@ -176,6 +193,45 @@ func (s *Store) SetProjectWebhookSecret(id, secret string) error {
|
|||||||
return nil
|
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,
|
// EnsureProjectWebhookSecret returns the current webhook secret for a project,
|
||||||
// generating one on the fly if the stored value is empty (lazy backfill for
|
// generating one on the fly if the stored value is empty (lazy backfill for
|
||||||
// projects created before the per-project webhook migration).
|
// projects created before the per-project webhook migration).
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import (
|
|||||||
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
||||||
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
||||||
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
|
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,
|
notification_url, notification_secret,
|
||||||
created_at, updated_at`
|
created_at, updated_at`
|
||||||
|
|
||||||
@@ -31,13 +32,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
|||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO static_sites (`+staticSiteCols+`)
|
`INSERT INTO static_sites (`+staticSiteCols+`)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
||||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
||||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||||
site.WebhookSecret,
|
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
|
||||||
site.NotificationURL, site.NotificationSecret,
|
site.NotificationURL, site.NotificationSecret,
|
||||||
site.CreatedAt, site.UpdatedAt,
|
site.CreatedAt, site.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -228,14 +229,14 @@ func (s *Store) DeleteStaticSite(id string) error {
|
|||||||
// scanStaticSiteRow scans a static site from a *sql.Row.
|
// scanStaticSiteRow scans a static site from a *sql.Row.
|
||||||
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
||||||
var site StaticSite
|
var site StaticSite
|
||||||
var renderMarkdown, storageEnabled int
|
var renderMarkdown, storageEnabled, requireSig int
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
||||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
||||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||||
&site.WebhookSecret,
|
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
|
||||||
&site.NotificationURL, &site.NotificationSecret,
|
&site.NotificationURL, &site.NotificationSecret,
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
&site.CreatedAt, &site.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -244,20 +245,21 @@ func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
|||||||
}
|
}
|
||||||
site.RenderMarkdown = renderMarkdown != 0
|
site.RenderMarkdown = renderMarkdown != 0
|
||||||
site.StorageEnabled = storageEnabled != 0
|
site.StorageEnabled = storageEnabled != 0
|
||||||
|
site.WebhookRequireSignature = requireSig != 0
|
||||||
return site, nil
|
return site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
|
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
|
||||||
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
||||||
var site StaticSite
|
var site StaticSite
|
||||||
var renderMarkdown, storageEnabled int
|
var renderMarkdown, storageEnabled, requireSig int
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
||||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
||||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||||
&site.WebhookSecret,
|
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
|
||||||
&site.NotificationURL, &site.NotificationSecret,
|
&site.NotificationURL, &site.NotificationSecret,
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
&site.CreatedAt, &site.UpdatedAt,
|
||||||
)
|
)
|
||||||
@@ -266,9 +268,48 @@ func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
|||||||
}
|
}
|
||||||
site.RenderMarkdown = renderMarkdown != 0
|
site.RenderMarkdown = renderMarkdown != 0
|
||||||
site.StorageEnabled = storageEnabled != 0
|
site.StorageEnabled = storageEnabled != 0
|
||||||
|
site.WebhookRequireSignature = requireSig != 0
|
||||||
return site, nil
|
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
|
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
|
||||||
// signing secret. Empty string disables HMAC signing for this site
|
// signing secret. Empty string disables HMAC signing for this site
|
||||||
// (notifications still send unsigned, falling through to global resolution).
|
// (notifications still send unsigned, falling through to global resolution).
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ func (s *Store) runMigrations() error {
|
|||||||
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
|
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
|
||||||
// so a corrupted deploy is recoverable without data loss.
|
// so a corrupted deploy is recoverable without data loss.
|
||||||
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
|
`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
|
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package webhook
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,6 +19,47 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/store"
|
"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
|
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
|
||||||
// webhooks. Above this limit, requests are rejected with 503.
|
// webhooks. Above this limit, requests are rejected with 503.
|
||||||
const maxSiteConcurrentSyncs = 4
|
const maxSiteConcurrentSyncs = 4
|
||||||
@@ -217,9 +261,31 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
var payload Payload
|
||||||
dec := json.NewDecoder(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
if err := dec.Decode(&payload); err != nil {
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -347,6 +413,21 @@ func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
||||||
return
|
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 len(body) > 0 {
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package webhook_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -16,6 +19,29 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/webhook"
|
"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.
|
// fakeDeployer records the last trigger for assertion.
|
||||||
type fakeDeployer struct {
|
type fakeDeployer struct {
|
||||||
mu sync.Mutex
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -328,6 +328,12 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|||||||
export interface WebhookUrlResponse {
|
export interface WebhookUrlResponse {
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
|
has_signing_secret?: boolean;
|
||||||
|
webhook_require_signature?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SigningSecretResponse {
|
||||||
|
signing_secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
||||||
@@ -338,6 +344,18 @@ export function regenerateProjectWebhook(projectId: string): Promise<WebhookUrlR
|
|||||||
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
|
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function regenerateProjectSigningSecret(projectId: string): Promise<SigningSecretResponse> {
|
||||||
|
return post<SigningSecretResponse>(`/api/projects/${projectId}/webhook/signing-secret/regenerate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableProjectSigningSecret(projectId: string): Promise<void> {
|
||||||
|
await del<void>(`/api/projects/${projectId}/webhook/signing-secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setProjectRequireSignature(projectId: string, require: boolean): Promise<void> {
|
||||||
|
await put<void>(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require });
|
||||||
|
}
|
||||||
|
|
||||||
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
||||||
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
|
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
|
||||||
}
|
}
|
||||||
@@ -346,6 +364,18 @@ export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlR
|
|||||||
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function regenerateStaticSiteSigningSecret(siteId: string): Promise<SigningSecretResponse> {
|
||||||
|
return post<SigningSecretResponse>(`/api/sites/${siteId}/webhook/signing-secret/regenerate`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disableStaticSiteSigningSecret(siteId: string): Promise<void> {
|
||||||
|
await del<void>(`/api/sites/${siteId}/webhook/signing-secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise<void> {
|
||||||
|
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Outgoing-webhook signing & test ────────────────────────────────
|
// ── Outgoing-webhook signing & test ────────────────────────────────
|
||||||
|
|
||||||
export interface NotificationSecretResponse {
|
export interface NotificationSecretResponse {
|
||||||
|
|||||||
@@ -11,11 +11,18 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
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 {
|
interface WebhookUrlResponse {
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
webhook_secret: string;
|
webhook_secret: string;
|
||||||
|
has_signing_secret?: boolean;
|
||||||
|
webhook_require_signature?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SigningSecretResponse {
|
||||||
|
signing_secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,15 +30,35 @@
|
|||||||
description: string;
|
description: string;
|
||||||
fetchWebhook: () => Promise<WebhookUrlResponse>;
|
fetchWebhook: () => Promise<WebhookUrlResponse>;
|
||||||
regenerateWebhook: () => Promise<WebhookUrlResponse>;
|
regenerateWebhook: () => Promise<WebhookUrlResponse>;
|
||||||
|
// Inbound HMAC signing — optional; if omitted, the signing UI hides.
|
||||||
|
regenerateSigningSecret?: () => Promise<SigningSecretResponse>;
|
||||||
|
disableSigning?: () => Promise<void>;
|
||||||
|
setRequireSignature?: (require: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { title, description, fetchWebhook, regenerateWebhook }: Props = $props();
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
fetchWebhook,
|
||||||
|
regenerateWebhook,
|
||||||
|
regenerateSigningSecret,
|
||||||
|
disableSigning,
|
||||||
|
setRequireSignature
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let relativeUrl = $state('');
|
let relativeUrl = $state('');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let regenerating = $state(false);
|
let regenerating = $state(false);
|
||||||
let confirmOpen = $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(
|
const absoluteUrl = $derived(
|
||||||
relativeUrl && typeof window !== 'undefined' ? window.location.origin + relativeUrl : relativeUrl
|
relativeUrl && typeof window !== 'undefined' ? window.location.origin + relativeUrl : relativeUrl
|
||||||
);
|
);
|
||||||
@@ -41,6 +68,11 @@
|
|||||||
try {
|
try {
|
||||||
const res = await fetchWebhook();
|
const res = await fetchWebhook();
|
||||||
relativeUrl = res.webhook_url;
|
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) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.loadFailed'));
|
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -54,6 +86,8 @@
|
|||||||
try {
|
try {
|
||||||
const res = await regenerateWebhook();
|
const res = await regenerateWebhook();
|
||||||
relativeUrl = res.webhook_url;
|
relativeUrl = res.webhook_url;
|
||||||
|
hasSigningSecret = res.has_signing_secret ?? hasSigningSecret;
|
||||||
|
requireSignature = res.webhook_require_signature ?? requireSignature;
|
||||||
toasts.success($t('webhookPanel.regenerated'));
|
toasts.success($t('webhookPanel.regenerated'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toasts.error(err instanceof Error ? err.message : $t('webhookPanel.regenerateFailed'));
|
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(() => {
|
onMount(() => {
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@@ -133,4 +220,111 @@
|
|||||||
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.regenerateWarning')}</p>
|
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.regenerateWarning')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- HMAC signing section (rendered only if the parent wired the handlers). -->
|
||||||
|
{#if regenerateSigningSecret && setRequireSignature && disableSigning}
|
||||||
|
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<IconShield size={18} />
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('webhookPanel.signingTitle')}</h3>
|
||||||
|
<p class="mt-1 text-xs text-[var(--text-secondary)]">{$t('webhookPanel.signingDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if issuedSigningSecret}
|
||||||
|
<!-- One-shot display of the freshly issued secret. -->
|
||||||
|
<div class="mt-3 rounded-lg border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-950/30">
|
||||||
|
<p class="text-xs font-medium text-amber-900 dark:text-amber-200">{$t('webhookPanel.signingShownOnce')}</p>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<code class="flex-1 break-all rounded bg-[var(--surface-card)] px-2 py-1.5 font-mono text-xs text-[var(--text-primary)]">
|
||||||
|
{issuedSigningSecret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={copyIssuedSecret}
|
||||||
|
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
<IconCopy size={12} />
|
||||||
|
{$t('webhookPanel.copy')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (issuedSigningSecret = '')}
|
||||||
|
title={$t('webhookPanel.signingDismiss')}
|
||||||
|
aria-label={$t('webhookPanel.signingDismiss')}
|
||||||
|
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)]"
|
||||||
|
>
|
||||||
|
<IconX size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-amber-900 dark:text-amber-200">
|
||||||
|
{$t('webhookPanel.signingHint', { header: 'X-Hub-Signature-256' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm text-[var(--text-secondary)]">
|
||||||
|
{#if hasSigningSecret}
|
||||||
|
{$t('webhookPanel.signingActive')}
|
||||||
|
{:else}
|
||||||
|
{$t('webhookPanel.signingInactive')}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleIssueSigning}
|
||||||
|
disabled={signingBusy || loading}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{#if signingBusy}<IconLoader size={14} />{/if}
|
||||||
|
{hasSigningSecret ? $t('webhookPanel.signingRotate') : $t('webhookPanel.signingIssue')}
|
||||||
|
</button>
|
||||||
|
{#if hasSigningSecret}
|
||||||
|
{#if confirmDisableSigning}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleDisableSigning}
|
||||||
|
disabled={signingBusy}
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{$t('webhookPanel.signingDisableConfirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDisableSigning = false)}
|
||||||
|
class="rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]"
|
||||||
|
>
|
||||||
|
{$t('webhookPanel.confirmNo')}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (confirmDisableSigning = true)}
|
||||||
|
disabled={signingBusy}
|
||||||
|
class="inline-flex items-center gap-1 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{$t('webhookPanel.signingDisable')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={requireSignature}
|
||||||
|
disabled={!hasSigningSecret || signingBusy}
|
||||||
|
onchange={handleToggleRequire}
|
||||||
|
label={$t('webhookPanel.requireSignature')}
|
||||||
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('webhookPanel.requireSignature')}</span>
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('webhookPanel.requireSignatureHelp')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1184,7 +1184,26 @@
|
|||||||
"regenerateWarning": "Regenerating invalidates the current URL. Update any CI pipeline or Git webhook that uses it.",
|
"regenerateWarning": "Regenerating invalidates the current URL. Update any CI pipeline or Git webhook that uses it.",
|
||||||
"confirmRegenerate": "Replace the current URL?",
|
"confirmRegenerate": "Replace the current URL?",
|
||||||
"confirmYes": "Regenerate",
|
"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": {
|
"outgoingWebhook": {
|
||||||
"signingOn": "Signed",
|
"signingOn": "Signed",
|
||||||
|
|||||||
@@ -1184,7 +1184,26 @@
|
|||||||
"regenerateWarning": "Перегенерация инвалидирует текущий URL. Обновите CI-пайплайны и Git-вебхуки, использующие его.",
|
"regenerateWarning": "Перегенерация инвалидирует текущий URL. Обновите CI-пайплайны и Git-вебхуки, использующие его.",
|
||||||
"confirmRegenerate": "Заменить текущий URL?",
|
"confirmRegenerate": "Заменить текущий URL?",
|
||||||
"confirmYes": "Перегенерировать",
|
"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": {
|
"outgoingWebhook": {
|
||||||
"signingOn": "Подпись включена",
|
"signingOn": "Подпись включена",
|
||||||
|
|||||||
@@ -806,6 +806,9 @@
|
|||||||
description={$t('projectDetail.webhookDesc')}
|
description={$t('projectDetail.webhookDesc')}
|
||||||
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
||||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
||||||
|
regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)}
|
||||||
|
disableSigning={() => api.disableProjectSigningSecret(projectId)}
|
||||||
|
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
||||||
|
|||||||
@@ -312,6 +312,9 @@
|
|||||||
description={$t('sites.webhookDesc')}
|
description={$t('sites.webhookDesc')}
|
||||||
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
||||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
||||||
|
regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)}
|
||||||
|
disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)}
|
||||||
|
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
||||||
|
|||||||
Reference in New Issue
Block a user