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:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user