831b5c1a43
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)
458 lines
13 KiB
Go
458 lines
13 KiB
Go
package webhook_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
"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
|
|
calls int
|
|
lastProj string
|
|
lastStg string
|
|
lastTag string
|
|
err error
|
|
}
|
|
|
|
func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls++
|
|
f.lastProj = projectID
|
|
f.lastStg = stageID
|
|
f.lastTag = tag
|
|
return f.err
|
|
}
|
|
|
|
// fakeSiteTriggerer records Deploy calls.
|
|
type fakeSiteTriggerer struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
done chan struct{}
|
|
}
|
|
|
|
func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error {
|
|
f.mu.Lock()
|
|
f.calls++
|
|
ch := f.done
|
|
f.mu.Unlock()
|
|
if ch != nil {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newRouter(t *testing.T, h *webhook.Handler) chi.Router {
|
|
t.Helper()
|
|
r := chi.NewRouter()
|
|
r.Mount("/api/webhook", h.Route())
|
|
return r
|
|
}
|
|
|
|
func newStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
s, err := store.New(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("create store: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
resp := w.Result()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return resp, string(b)
|
|
}
|
|
|
|
func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
h := webhook.NewHandler(st, &fakeDeployer{}, nil)
|
|
r := newRouter(t, h)
|
|
|
|
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`)
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
|
|
p, err := st.CreateProject(store.Project{
|
|
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
stage, err := st.CreateStage(store.Stage{
|
|
ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create stage: %v", err)
|
|
}
|
|
|
|
dep := &fakeDeployer{}
|
|
h := webhook.NewHandler(st, dep, nil)
|
|
r := newRouter(t, h)
|
|
|
|
path := "/api/webhook/" + p.WebhookSecret
|
|
resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
if dep.calls != 1 {
|
|
t.Fatalf("expected 1 deploy call, got %d", dep.calls)
|
|
}
|
|
if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" {
|
|
t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s",
|
|
dep.lastProj, dep.lastStg, dep.lastTag)
|
|
}
|
|
}
|
|
|
|
func TestProjectWebhook_ImageMismatchRejected(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
p, err := st.CreateProject(store.Project{
|
|
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
if _, err := st.CreateStage(store.Stage{
|
|
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create stage: %v", err)
|
|
}
|
|
|
|
dep := &fakeDeployer{}
|
|
h := webhook.NewHandler(st, dep, nil)
|
|
r := newRouter(t, h)
|
|
|
|
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
`{"image":"otheruser/other:dev"}`)
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode)
|
|
}
|
|
if dep.calls != 0 {
|
|
t.Errorf("deploy should not have been triggered on image mismatch")
|
|
}
|
|
}
|
|
|
|
func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
p, err := st.CreateProject(store.Project{
|
|
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
if _, err := st.CreateStage(store.Stage{
|
|
ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1,
|
|
}); err != nil {
|
|
t.Fatalf("create stage: %v", err)
|
|
}
|
|
|
|
dep := &fakeDeployer{}
|
|
h := webhook.NewHandler(st, dep, nil)
|
|
r := newRouter(t, h)
|
|
|
|
resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
`{"image":"alexei/app:dev-abc"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
if dep.calls != 0 {
|
|
t.Errorf("expected no deploy call, got %d", dep.calls)
|
|
}
|
|
var parsed map[string]any
|
|
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
t.Fatalf("response is not JSON: %v", err)
|
|
}
|
|
if parsed["deploy"] != false {
|
|
t.Errorf("expected deploy=false, got %v", parsed["deploy"])
|
|
}
|
|
}
|
|
|
|
func TestProjectWebhook_AutoDeployDisabled(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"})
|
|
_, _ = st.CreateStage(store.Stage{
|
|
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1,
|
|
})
|
|
|
|
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-1"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if dep.calls != 0 {
|
|
t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls)
|
|
}
|
|
}
|
|
|
|
func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{})
|
|
r := newRouter(t, h)
|
|
|
|
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}")
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
site, err := st.CreateStaticSite(store.StaticSite{
|
|
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
Branch: "main", SyncTrigger: "manual", Status: "idle",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create site: %v", err)
|
|
}
|
|
|
|
ft := &fakeSiteTriggerer{}
|
|
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
r := newRouter(t, h)
|
|
|
|
resp, _ := doJSON(t, r, http.MethodPost,
|
|
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if ft.calls != 0 {
|
|
t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls)
|
|
}
|
|
}
|
|
|
|
func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
site, err := st.CreateStaticSite(store.StaticSite{
|
|
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
Branch: "main", SyncTrigger: "push", Status: "idle",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create site: %v", err)
|
|
}
|
|
|
|
ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)}
|
|
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
r := newRouter(t, h)
|
|
|
|
resp, body := doJSON(t, r, http.MethodPost,
|
|
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
}
|
|
|
|
// Sync runs in a goroutine — wait for the signal.
|
|
<-ft.done
|
|
ft.mu.Lock()
|
|
calls := ft.calls
|
|
ft.mu.Unlock()
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 sync call, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) {
|
|
t.Parallel()
|
|
st := newStore(t)
|
|
site, _ := st.CreateStaticSite(store.StaticSite{
|
|
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
Branch: "main", SyncTrigger: "push", Status: "idle",
|
|
})
|
|
|
|
ft := &fakeSiteTriggerer{}
|
|
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
r := newRouter(t, h)
|
|
|
|
resp, _ := doJSON(t, r, http.MethodPost,
|
|
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
}
|
|
if ft.calls != 0 {
|
|
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)
|
|
}
|
|
}
|