Files
tiny-forge/internal/webhook/handler.go
T
alexei.dolgolyov 831b5c1a43 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)
2026-05-07 02:34:40 +03:00

474 lines
15 KiB
Go

package webhook
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"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
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
// /api routes already wrap the body with MaxBytesReader, but the webhook
// router relies on its own limit so changes to the parent middleware can't
// silently increase the cap.
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
// DeployTriggerer is called when a webhook determines a deploy should happen.
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
type DeployTriggerer interface {
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
}
// SiteSyncTriggerer is called when a static-site webhook determines a sync
// should happen. The manager handles the actual git-pull + redeploy.
type SiteSyncTriggerer interface {
Deploy(ctx context.Context, siteID string, force bool) error
}
// Payload is the expected JSON body for a project webhook request.
type Payload struct {
// Image is the full image reference including tag, e.g.
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
Image string `json:"image"`
}
// SitePayload is the expected JSON body for a static-site webhook request.
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
// matters for branch filtering. Body is optional — an empty body triggers
// a sync using the site's configured branch.
type SitePayload struct {
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
}
// ParsedImage holds the components extracted from a full image reference string.
type ParsedImage struct {
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
Registry string
// Owner is the namespace/org, e.g. "alexei".
Owner string
// Name is the repository name, e.g. "web-app-launcher".
Name string
// Tag is the image tag, e.g. "dev-abc123". Empty string means "latest".
Tag string
}
// FullName returns "owner/name" (the image path without registry and tag).
func (p ParsedImage) FullName() string {
if p.Owner != "" {
return p.Owner + "/" + p.Name
}
return p.Name
}
// ParseImageRef splits a full image reference into its components.
// Accepted formats:
//
// registry.example.com/owner/name:tag
// registry.example.com/owner/name
// owner/name:tag
// name:tag
func ParseImageRef(ref string) (ParsedImage, error) {
ref = strings.TrimSpace(ref)
if ref == "" {
return ParsedImage{}, fmt.Errorf("empty image reference")
}
var parsed ParsedImage
// Split off tag.
if idx := strings.LastIndex(ref, ":"); idx != -1 {
// Make sure the colon is not inside the registry host (e.g. "localhost:5000/img").
afterColon := ref[idx+1:]
if !strings.Contains(afterColon, "/") {
parsed.Tag = afterColon
ref = ref[:idx]
}
}
parts := strings.Split(ref, "/")
switch len(parts) {
case 1:
// "name"
parsed.Name = parts[0]
case 2:
// "owner/name"
parsed.Owner = parts[0]
parsed.Name = parts[1]
default:
// "registry/owner/name" or "registry/owner/sub/name" — first segment is registry.
parsed.Registry = parts[0]
parsed.Owner = strings.Join(parts[1:len(parts)-1], "/")
parsed.Name = parts[len(parts)-1]
}
if parsed.Name == "" {
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
}
return parsed, nil
}
// Handler is the HTTP handler for webhook requests.
type Handler struct {
store *store.Store
deployer DeployTriggerer
sites SiteSyncTriggerer
// Site sync coordination — webhooks fire syncs in the background; Drain
// blocks until those goroutines finish, so a graceful shutdown does not
// kill an in-flight git fetch + container rebuild.
siteSyncCtx context.Context
siteSyncCancel context.CancelFunc
siteSyncWG sync.WaitGroup
siteSyncSem chan struct{}
}
// NewHandler creates a new webhook Handler. The sites triggerer is optional
// and may be nil (site webhooks will return 404).
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
ctx, cancel := context.WithCancel(context.Background())
return &Handler{
store: st,
deployer: deployer,
sites: sites,
siteSyncCtx: ctx,
siteSyncCancel: cancel,
siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs),
}
}
// SetSiteSyncTriggerer injects the static-site manager after construction.
// The site manager depends on the store + docker client, which are wired up
// in the same startup path as the handler; this setter lets callers defer the
// dependency if needed.
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
h.sites = s
}
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
// Safe to call from a graceful-shutdown path.
func (h *Handler) Drain() {
h.siteSyncCancel()
h.siteSyncWG.Wait()
}
// Route returns a chi router with the webhook endpoints mounted.
//
// Routes:
//
// POST /{secret} — per-project deploy trigger
// POST /sites/{secret} — per-site sync trigger
func (h *Handler) Route() chi.Router {
r := chi.NewRouter()
r.Post("/sites/{secret}", h.handleSiteWebhook)
r.Post("/{secret}", h.handleWebhook)
return r
}
// respondWebhookJSON writes a JSON response for webhook handlers.
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data) //nolint:errcheck
}
// respondWebhookError writes a JSON error response for webhook handlers.
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}
// handleWebhook processes an incoming project webhook request.
//
// URL: POST /api/webhook/{secret}
//
// The secret identifies exactly one project. Stage routing is delegated to
// the project's configured stages (tag_pattern match). Returns 404 for
// unknown secrets (no information leak).
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
secret := chi.URLParam(r, "secret")
if secret == "" {
http.NotFound(w, r)
return
}
project, err := h.store.GetProjectByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
slog.Error("webhook: project lookup failed", "error", err)
http.NotFound(w, r)
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
if err := json.Unmarshal(body, &payload); err != nil {
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
if payload.Image == "" {
respondWebhookError(w, http.StatusBadRequest, "missing image field")
return
}
parsed, err := ParseImageRef(payload.Image)
if err != nil {
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return
}
if parsed.Tag == "" {
parsed.Tag = "latest"
}
// Guardrail: refuse payloads whose image doesn't match the project's
// configured image. Not a security control (the secret already scopes
// access) — just a misconfiguration check that prevents accidental
// cross-project deploys from a misaimed CI pipeline.
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
slog.Warn("webhook: image mismatch",
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
respondWebhookError(w, http.StatusBadRequest,
fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image))
return
}
slog.Info("webhook: received push",
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
if err != nil {
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
slog.Info("webhook: no stage matches tag",
"project", project.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false, "project": project.Name,
"reason": "no stage pattern matched tag",
})
return
}
if !stage.AutoDeploy {
slog.Info("webhook: auto_deploy disabled, skipping",
"project", project.Name, "stage", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": false,
"project": project.Name, "stage": stage.Name,
})
return
}
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
slog.Error("webhook: deploy trigger failed", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return
}
slog.Info("webhook: triggered deploy",
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "deploy": true,
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
})
}
// handleSiteWebhook processes an incoming static-site webhook request.
//
// URL: POST /api/webhook/sites/{secret}
//
// The secret identifies exactly one static site. If the payload includes a
// ref (Git push event), it must match the site's configured branch (when the
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.sites == nil {
http.NotFound(w, r)
return
}
secret := chi.URLParam(r, "secret")
if secret == "" {
http.NotFound(w, r)
return
}
site, err := h.store.GetStaticSiteByWebhookSecret(secret)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
slog.Error("webhook: site lookup failed", "error", err)
http.NotFound(w, r)
return
}
// Manual sites do not auto-sync via webhook. Return success but skip.
if site.SyncTrigger == "manual" {
slog.Info("webhook: site sync_trigger=manual, skipping",
"site", site.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "sync_trigger is manual",
})
return
}
// Body is optional. We attempt to decode but accept an empty body (no Ref
// filter); a malformed non-empty body is treated as bad-request to avoid
// silently bypassing the branch/tag filter.
var payload SitePayload
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
if err != nil {
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")
return
}
}
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
slog.Info("webhook: site ref does not match configured branch/tag",
"site", site.Name, "ref", payload.Ref,
"branch", site.Branch, "tag_pattern", site.TagPattern,
"trigger", site.SyncTrigger)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": false, "site": site.Name,
"reason": "ref does not match configured branch or tag pattern",
})
return
}
// Cap concurrent syncs so a runaway CI cannot fan out unbounded
// git-clone goroutines.
select {
case h.siteSyncSem <- struct{}{}:
default:
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
return
}
h.siteSyncWG.Add(1)
go func(siteID, siteName string) {
defer h.siteSyncWG.Done()
defer func() { <-h.siteSyncSem }()
if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil {
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
}
}(site.ID, site.Name)
_ = ctx
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
respondWebhookJSON(w, http.StatusOK, map[string]any{
"success": true, "sync": true, "site": site.Name,
})
}