diff --git a/services/core/internal/controlplane/router.go b/services/core/internal/controlplane/router.go index 83625dc..9c40fbe 100644 --- a/services/core/internal/controlplane/router.go +++ b/services/core/internal/controlplane/router.go @@ -33,6 +33,7 @@ func newRouterWithStore(cfg config.Config, logger *slog.Logger, store storage.St HeartbeatWaitSec: cfg.ProtoSocketHeartbeatWait, }, logger) runtime := NewRuntimeWithStore(protoServer, store) + runtime.WithSecretResolver(EnvWebhookSecretResolver{}) registerProtoSocketHandlers(protoServer.Dispatcher(), runtime, protoServer) mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { diff --git a/services/core/internal/controlplane/router_test.go b/services/core/internal/controlplane/router_test.go index 3a09947..23473cd 100644 --- a/services/core/internal/controlplane/router_test.go +++ b/services/core/internal/controlplane/router_test.go @@ -2,6 +2,9 @@ package controlplane import ( "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "log/slog" "net/http" @@ -321,6 +324,64 @@ func TestWebhookSubscriptionRegistersFilterWithoutLeakingSecretRef(t *testing.T) } } +func TestWebhookSubscriptionEnvSecretRefSignsDelivery(t *testing.T) { + const secret = "router-secret" + t.Setenv("GITO_WEBHOOK_SECRET", secret) + + consumer := &fakeWebhookConsumer{} + server := fakeWebhookServer(t, consumer) + defer server.Close() + + router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) + registerBranchListener(t, router, `{"repo_id":"nomadcode","branch":"develop","provider":"forgejo"}`) + + subReq := httptest.NewRequest(http.MethodPost, "/api/webhook-subscriptions", bytes.NewBufferString(`{ + "name": "nomadcode-develop-wakeup", + "target_url": "`+server.URL+`", + "events": ["branch.updated"], + "repo_id": "nomadcode", + "branch": "develop", + "secret_ref": "env:GITO_WEBHOOK_SECRET" + }`)) + subRec := httptest.NewRecorder() + router.ServeHTTP(subRec, subReq) + if subRec.Code != http.StatusCreated { + t.Fatalf("subscription status: got %d body=%s", subRec.Code, subRec.Body.String()) + } + + pushReq := httptest.NewRequest(http.MethodPost, "/callbacks/forgejo/push?repo_id=nomadcode", bytes.NewBufferString(`{ + "ref": "refs/heads/develop", + "before": "111", + "after": "222", + "repository": {"name": "nomadcode", "full_name": "toki/nomadcode"}, + "commits": [{"id": "222", "modified": ["agent-roadmap/phase/a/milestones/b.md"]}] + }`)) + pushReq.Header.Set("X-Forgejo-Event", "push") + pushReq.Header.Set("X-Forgejo-Delivery", "delivery-1") + pushRec := httptest.NewRecorder() + router.ServeHTTP(pushRec, pushReq) + if pushRec.Code != http.StatusAccepted { + t.Fatalf("push status: got %d body=%s", pushRec.Code, pushRec.Body.String()) + } + + consumer.mu.Lock() + received := append([]webhookReception(nil), consumer.received...) + consumer.mu.Unlock() + if len(received) != 1 { + t.Fatalf("expected one webhook delivery, got %d", len(received)) + } + rec := received[0] + if rec.eventType != "branch.updated" || rec.deliveryID == "" { + t.Fatalf("unexpected delivery headers: event=%q delivery=%q", rec.eventType, rec.deliveryID) + } + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write(rec.bodyRaw) + wantSignature := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if rec.signature != wantSignature { + t.Fatalf("signature: got %q want %q", rec.signature, wantSignature) + } +} + func TestWebhookSubscriptionValidatesTargetAndEvents(t *testing.T) { router := NewRouter(config.Config{AppEnv: "test", ProtoSocketPath: "/proto-socket"}, slog.Default()) diff --git a/services/core/internal/controlplane/runtime.go b/services/core/internal/controlplane/runtime.go index 3cc20ae..b2f962a 100644 --- a/services/core/internal/controlplane/runtime.go +++ b/services/core/internal/controlplane/runtime.go @@ -11,6 +11,7 @@ import ( "fmt" "net/http" "net/url" + "os" "sort" "strings" "sync" @@ -34,6 +35,29 @@ type WebhookSecretResolver interface { ResolveWebhookSecret(ctx context.Context, secretRef string) (string, bool, error) } +// EnvWebhookSecretResolver resolves subscription secret refs of the form +// `env:VAR_NAME` from the process environment. +type EnvWebhookSecretResolver struct{} + +func (EnvWebhookSecretResolver) ResolveWebhookSecret(_ context.Context, secretRef string) (string, bool, error) { + secretRef = strings.TrimSpace(secretRef) + if !strings.HasPrefix(secretRef, "env:") { + return "", false, nil + } + name := strings.TrimSpace(strings.TrimPrefix(secretRef, "env:")) + if name == "" { + return "", false, fmt.Errorf("env secret ref missing variable name") + } + if strings.ContainsAny(name, " \t\r\n=") { + return "", false, fmt.Errorf("invalid env secret ref %q", secretRef) + } + secret, ok := os.LookupEnv(name) + if !ok || secret == "" { + return "", false, nil + } + return secret, true, nil +} + type Runtime struct { mu sync.Mutex watches map[string]BranchWatch