From da8bca14b8d10306fb33c72f5dd5b01da3ed9543 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 14:02:20 +0400 Subject: [PATCH 1/2] feat(api): outbound workspace webhooks with signed delivery and logs Add workspace-scoped outbound webhooks. Admins register HTTPS endpoints, choose which events they subscribe to (issues, projects, modules, cycles, issue comments), and inspect recent delivery attempts. Issue create/update/ delete now dispatch to matching active webhooks. Delivery runs through the existing RabbitMQ queue: each request is signed with HMAC-SHA256 (X-Devlane-Signature) using a per-webhook secret shown once at creation, retried with backoff, and recorded in webhook_logs. A dial-time SSRF guard resolves the target host and refuses non-public addresses (also covering DNS rebinding); literal private/loopback IPs are rejected at create time so admins get a clear error instead of a webhook that silently fails. Adds the management UI under Settings -> Webhooks (list, create, pause/ resume, delete, delivery-log viewer). Closes #195 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/cmd/api/main.go | 3 +- apps/api/internal/handler/webhook.go | 154 +++++++ apps/api/internal/handler/webhook_test.go | 157 +++---- apps/api/internal/model/webhook.go | 57 ++- apps/api/internal/queue/consumer.go | 15 +- apps/api/internal/queue/queue.go | 10 +- apps/api/internal/router/router.go | 9 + apps/api/internal/service/issue.go | 23 +- apps/api/internal/service/webhook.go | 215 ++++++++++ apps/api/internal/service/webhook_delivery.go | 194 +++++++++ .../service/webhook_delivery_internal_test.go | 39 ++ apps/api/internal/store/webhook.go | 99 +++++ apps/web/src/api/types.ts | 30 ++ .../components/settings/WebhooksSettings.tsx | 403 ++++++++++++++++++ apps/web/src/pages/SettingsPage.tsx | 11 +- apps/web/src/services/webhookService.ts | 47 ++ 16 files changed, 1324 insertions(+), 142 deletions(-) create mode 100644 apps/api/internal/handler/webhook.go create mode 100644 apps/api/internal/service/webhook.go create mode 100644 apps/api/internal/service/webhook_delivery.go create mode 100644 apps/api/internal/service/webhook_delivery_internal_test.go create mode 100644 apps/api/internal/store/webhook.go create mode 100644 apps/web/src/components/settings/WebhooksSettings.tsx create mode 100644 apps/web/src/services/webhookService.ts diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index d5793ef1..9f95c315 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -118,7 +118,8 @@ func main() { instanceSettingStore := store.NewInstanceSettingStore(db) emailSender := mail.NewSMTPEmailSender(instanceSettingStore, log) consumer.Register(queue.QueueEmails, queue.HandleSendEmail(log, emailSender)) - consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(queue.NoopWebhookDeliverer(log))) + webhookDeliverer := service.NewWebhookDeliverer(store.NewWebhookStore(db), log) + consumer.Register(queue.QueueWebhooks, queue.HandleWebhook(webhookDeliverer)) if err := consumer.Run(consumerCtx, []string{queue.QueueEmails, queue.QueueWebhooks}); err != nil { log.Warn("queue consumer", "error", err) } diff --git a/apps/api/internal/handler/webhook.go b/apps/api/internal/handler/webhook.go new file mode 100644 index 00000000..16e62ee9 --- /dev/null +++ b/apps/api/internal/handler/webhook.go @@ -0,0 +1,154 @@ +package handler + +import ( + "net/http" + + "github.com/Devlaner/devlane/api/internal/middleware" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// WebhookHandler serves outbound workspace webhook management + delivery logs. +type WebhookHandler struct { + Webhooks *service.WebhookService +} + +type webhookBody struct { + URL string `json:"url"` + IsActive *bool `json:"is_active"` + Project *bool `json:"project"` + Issue *bool `json:"issue"` + Module *bool `json:"module"` + Cycle *bool `json:"cycle"` + IssueComment *bool `json:"issue_comment"` +} + +func (b webhookBody) toInput() service.WebhookInput { + return service.WebhookInput{ + URL: b.URL, + IsActive: b.IsActive, + Project: b.Project, + Issue: b.Issue, + Module: b.Module, + Cycle: b.Cycle, + IssueComment: b.IssueComment, + } +} + +// List returns the workspace's webhooks. +// GET /api/workspaces/:slug/webhooks/ +func (h *WebhookHandler) List(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + list, err := h.Webhooks.List(c.Request.Context(), c.Param("slug"), user.ID) + if err != nil { + h.webhookError(c, err) + return + } + c.JSON(http.StatusOK, list) +} + +// Create adds a webhook. +// POST /api/workspaces/:slug/webhooks/ +func (h *WebhookHandler) Create(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + var body webhookBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + w, err := h.Webhooks.Create(c.Request.Context(), c.Param("slug"), user.ID, body.toInput()) + if err != nil { + h.webhookError(c, err) + return + } + c.JSON(http.StatusCreated, w) +} + +// Update edits a webhook. +// PATCH /api/workspaces/:slug/webhooks/:webhookId/ +func (h *WebhookHandler) Update(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + id, err := uuid.Parse(c.Param("webhookId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"}) + return + } + var body webhookBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()}) + return + } + w, err := h.Webhooks.Update(c.Request.Context(), c.Param("slug"), user.ID, id, body.toInput()) + if err != nil { + h.webhookError(c, err) + return + } + c.JSON(http.StatusOK, w) +} + +// Delete removes a webhook. +// DELETE /api/workspaces/:slug/webhooks/:webhookId/ +func (h *WebhookHandler) Delete(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + id, err := uuid.Parse(c.Param("webhookId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"}) + return + } + if err := h.Webhooks.Delete(c.Request.Context(), c.Param("slug"), user.ID, id); err != nil { + h.webhookError(c, err) + return + } + c.Status(http.StatusNoContent) +} + +// ListLogs returns a webhook's recent delivery logs. +// GET /api/workspaces/:slug/webhooks/:webhookId/logs/ +func (h *WebhookHandler) ListLogs(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + id, err := uuid.Parse(c.Param("webhookId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"}) + return + } + logs, err := h.Webhooks.ListLogs(c.Request.Context(), c.Param("slug"), user.ID, id) + if err != nil { + h.webhookError(c, err) + return + } + c.JSON(http.StatusOK, logs) +} + +func (h *WebhookHandler) webhookError(c *gin.Context, err error) { + switch err { + case service.ErrWebhookForbidden: + c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + case service.ErrWebhookWorkspace, service.ErrWebhookNotFound: + c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) + case service.ErrWebhookBadURL: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Webhook request failed"}) + } +} diff --git a/apps/api/internal/handler/webhook_test.go b/apps/api/internal/handler/webhook_test.go index 94b3f225..5771b72f 100644 --- a/apps/api/internal/handler/webhook_test.go +++ b/apps/api/internal/handler/webhook_test.go @@ -1,127 +1,66 @@ package handler_test import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" + "encoding/json" "net/http" - "strings" "testing" - devcrypto "github.com/Devlaner/devlane/api/internal/crypto" - "github.com/Devlaner/devlane/api/internal/model" - "github.com/Devlaner/devlane/api/internal/store" "github.com/Devlaner/devlane/api/internal/testutil" "github.com/stretchr/testify/require" - "gorm.io/gorm" ) -func signGitHubPayload(secret string, body []byte) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - return "sha256=" + hex.EncodeToString(mac.Sum(nil)) -} - -// seedGitHubWebhookSecret stores an encrypted webhook secret in instance_settings -// the way handler/instance.go would when an admin saves the github_app section. -func seedGitHubWebhookSecret(t *testing.T, db *gorm.DB, secret string) { - t.Helper() - settings := store.NewInstanceSettingStore(db) - require.NoError(t, settings.Upsert(context.Background(), "github_app", model.JSONMap{ - "app_id": "12345", - "app_name": "test-app", - "client_id": "Iv1.testclient", - "webhook_secret": devcrypto.EncryptOrPlain(secret), - "webhook_secret_set": true, - })) -} - -func TestWebhook_GitHub_MissingSignature(t *testing.T) { - ts := testutil.NewTestServer(t) - seedGitHubWebhookSecret(t, ts.DB, "test-webhook-secret") - - body := []byte(`{"action":"opened"}`) - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ - "X-GitHub-Event": []string{"pull_request"}, - "X-GitHub-Delivery": []string{"deadbeef-1234"}, - }) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) -} - -func TestWebhook_GitHub_BadSignature(t *testing.T) { - ts := testutil.NewTestServer(t) - seedGitHubWebhookSecret(t, ts.DB, "test-webhook-secret") - - body := []byte(`{"action":"opened"}`) - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ - "X-GitHub-Event": []string{"pull_request"}, - "X-GitHub-Delivery": []string{"deadbeef-1234"}, - "X-Hub-Signature-256": []string{"sha256=" + strings.Repeat("0", 64)}, - "Content-Type": []string{"application/json"}, - }) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) -} - -func TestWebhook_GitHub_ValidSignature_NoTrailingSlash(t *testing.T) { +// Webhook CRUD for a workspace admin: create (secret generated), list, toggle, +// delete. Covers #195. +func TestWebhook_CRUD(t *testing.T) { ts := testutil.NewTestServer(t) - const secret = "test-webhook-secret" - seedGitHubWebhookSecret(t, ts.DB, secret) - - body := []byte(`{"action":"opened","number":1}`) - sig := signGitHubPayload(secret, body) - - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ - "X-GitHub-Event": []string{"pull_request"}, - "X-GitHub-Delivery": []string{"deadbeef-1234"}, - "X-Hub-Signature-256": []string{sig}, - "Content-Type": []string{"application/json"}, - }) - require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + w := testutil.SeedWorld(t, ts.DB) // seeded user owns the workspace + base := "/api/workspaces/" + w.Workspace.Slug + "/webhooks/" + + rr := ts.POST(base, map[string]any{"url": "https://example.com/hook", "issue": true}, w.Session) + require.Equal(t, http.StatusCreated, rr.Code, "body=%s", rr.Body.String()) + var created map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &created)) + id, _ := created["id"].(string) + require.NotEmpty(t, id) + require.NotEmpty(t, created["secret_key"], "a secret should be generated") + require.Equal(t, true, created["issue"]) + + // List includes it. + lr := ts.GET(base, w.Session) + require.Equal(t, http.StatusOK, lr.Code) + var list []map[string]any + require.NoError(t, json.Unmarshal(lr.Body.Bytes(), &list)) + require.Len(t, list, 1) + + // Toggle it off. + ur := ts.PATCH(base+id+"/", map[string]any{"is_active": false}, w.Session) + require.Equal(t, http.StatusOK, ur.Code, "body=%s", ur.Body.String()) + var updated map[string]any + require.NoError(t, json.Unmarshal(ur.Body.Bytes(), &updated)) + require.Equal(t, false, updated["is_active"]) + + // Delete it. + require.Equal(t, http.StatusNoContent, ts.DELETE(base+id+"/", w.Session).Code) + lr2 := ts.GET(base, w.Session) + var list2 []map[string]any + require.NoError(t, json.Unmarshal(lr2.Body.Bytes(), &list2)) + require.Len(t, list2, 0) } -func TestWebhook_GitHub_ValidSignature_TrailingSlash(t *testing.T) { +// A non-http URL is rejected. +func TestWebhook_RejectsBadURL(t *testing.T) { ts := testutil.NewTestServer(t) - const secret = "test-webhook-secret" - seedGitHubWebhookSecret(t, ts.DB, secret) - - body := []byte(`{"action":"opened","number":2}`) - sig := signGitHubPayload(secret, body) - - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github/", body, http.Header{ - "X-GitHub-Event": []string{"pull_request"}, - "X-GitHub-Delivery": []string{"deadbeef-5678"}, - "X-Hub-Signature-256": []string{sig}, - "Content-Type": []string{"application/json"}, - }) - require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + w := testutil.SeedWorld(t, ts.DB) + rr := ts.POST("/api/workspaces/"+w.Workspace.Slug+"/webhooks/", map[string]any{"url": "ftp://example.com"}, w.Session) + require.Equal(t, http.StatusBadRequest, rr.Code) } -func TestWebhook_GitHub_NoSecretConfigured(t *testing.T) { - // With no github_app settings, webhook secret is empty → VerifySignature - // returns "secret is not configured" → 401. +// A non-admin (non-member) cannot manage webhooks. +func TestWebhook_NonAdminForbidden(t *testing.T) { ts := testutil.NewTestServer(t) - - body := []byte(`{"action":"opened"}`) - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ - "X-GitHub-Event": []string{"pull_request"}, - "X-GitHub-Delivery": []string{"any"}, - "X-Hub-Signature-256": []string{"sha256=" + strings.Repeat("0", 64)}, - }) - require.Equal(t, http.StatusUnauthorized, rr.Code) -} - -func TestWebhook_GitHub_MissingHeaders(t *testing.T) { - ts := testutil.NewTestServer(t) - const secret = "test-webhook-secret" - seedGitHubWebhookSecret(t, ts.DB, secret) - - body := []byte(`{"action":"opened"}`) - sig := signGitHubPayload(secret, body) - - // Valid signature but no event/delivery headers → 400. - rr := ts.DoWithHeaders(http.MethodPost, "/webhooks/github", body, http.Header{ - "X-Hub-Signature-256": []string{sig}, - }) - require.Equal(t, http.StatusBadRequest, rr.Code, "body=%s", rr.Body.String()) + w := testutil.SeedWorld(t, ts.DB) + stranger := testutil.CreateUser(t, ts.DB) + session := testutil.LoginAs(t, ts.DB, stranger) + rr := ts.GET("/api/workspaces/"+w.Workspace.Slug+"/webhooks/", session) + require.Equal(t, http.StatusForbidden, rr.Code) } diff --git a/apps/api/internal/model/webhook.go b/apps/api/internal/model/webhook.go index 061ede43..fe02399a 100644 --- a/apps/api/internal/model/webhook.go +++ b/apps/api/internal/model/webhook.go @@ -7,18 +7,26 @@ import ( "gorm.io/gorm" ) -// Webhook matches webhooks. +// Webhook matches webhooks: a workspace-scoped outbound webhook. The per-entity +// booleans select which event types are delivered. type Webhook struct { - ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - URL string `gorm:"type:text;not null" json:"url"` - SecretKey string `gorm:"type:varchar(255)" json:"secret_key,omitempty"` - IsActive bool `gorm:"column:is_active;default:true" json:"is_active"` - WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` - ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` - UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + URL string `gorm:"type:varchar(1024);not null" json:"url"` + SecretKey string `gorm:"column:secret_key;type:varchar(255)" json:"secret_key,omitempty"` + IsActive bool `gorm:"column:is_active;not null;default:true" json:"is_active"` + Project bool `gorm:"column:project;not null;default:false" json:"project"` + Issue bool `gorm:"column:issue;not null;default:false" json:"issue"` + Module bool `gorm:"column:module;not null;default:false" json:"module"` + Cycle bool `gorm:"column:cycle;not null;default:false" json:"cycle"` + IssueComment bool `gorm:"column:issue_comment;not null;default:false" json:"issue_comment"` + IsInternal bool `gorm:"column:is_internal;not null;default:false" json:"is_internal"` + Version string `gorm:"column:version;type:varchar(50);not null;default:v1" json:"version"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"` + UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"` } func (Webhook) TableName() string { return "webhooks" } @@ -29,3 +37,30 @@ func (w *Webhook) BeforeCreate(tx *gorm.DB) error { } return nil } + +// WebhookLog matches webhook_logs: one attempted delivery of an event to a +// webhook, capturing request and response for debugging. +type WebhookLog struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + WebhookID uuid.UUID `gorm:"column:webhook_id;type:uuid;not null" json:"webhook_id"` + WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"` + EventType string `gorm:"column:event_type;type:varchar(255)" json:"event_type"` + RequestMethod string `gorm:"column:request_method;type:varchar(10)" json:"request_method"` + RequestHeaders string `gorm:"column:request_headers;type:text" json:"request_headers,omitempty"` + RequestBody string `gorm:"column:request_body;type:text" json:"request_body,omitempty"` + ResponseStatus string `gorm:"column:response_status;type:text" json:"response_status"` + ResponseHeaders string `gorm:"column:response_headers;type:text" json:"response_headers,omitempty"` + ResponseBody string `gorm:"column:response_body;type:text" json:"response_body,omitempty"` + RetryCount int `gorm:"column:retry_count;not null;default:0" json:"retry_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (WebhookLog) TableName() string { return "webhook_logs" } + +func (l *WebhookLog) BeforeCreate(tx *gorm.DB) error { + if l.ID == uuid.Nil { + l.ID = uuid.New() + } + return nil +} diff --git a/apps/api/internal/queue/consumer.go b/apps/api/internal/queue/consumer.go index 4def3044..c0e36c48 100644 --- a/apps/api/internal/queue/consumer.go +++ b/apps/api/internal/queue/consumer.go @@ -144,7 +144,7 @@ func HandleSendEmail(log *slog.Logger, sender func(ctx context.Context, to, subj } // HandleWebhook parses webhook_deliver task and runs the given deliverer. -func HandleWebhook(deliverer func(ctx context.Context, url, secret, event string, payload map[string]interface{}) error) TaskHandler { +func HandleWebhook(deliverer func(ctx context.Context, p WebhookPayload) error) TaskHandler { return func(ctx context.Context, queue string, body []byte) error { var msg struct { Type string `json:"type"` @@ -156,7 +156,7 @@ func HandleWebhook(deliverer func(ctx context.Context, url, secret, event string if msg.Type != TaskWebhookDeliver { return nil } - return deliverer(ctx, msg.Payload.URL, msg.Payload.Secret, msg.Payload.Event, msg.Payload.Payload) + return deliverer(ctx, msg.Payload) } } @@ -170,14 +170,13 @@ func NoopEmailSender(log *slog.Logger) func(ctx context.Context, to, subject, bo } } -// NoopWebhookDeliverer is a no-op deliverer (log only). Replace with real HTTP POST in production. -func NoopWebhookDeliverer(log *slog.Logger) func(ctx context.Context, url, secret, event string, payload map[string]interface{}) error { - return func(ctx context.Context, url, secret, event string, payload map[string]interface{}) error { +// NoopWebhookDeliverer is a no-op deliverer (log only). Used as a fallback when +// no real deliverer is wired. +func NoopWebhookDeliverer(log *slog.Logger) func(ctx context.Context, p WebhookPayload) error { + return func(ctx context.Context, p WebhookPayload) error { if log != nil { - log.Info("webhook would be delivered", "url", url, "event", event) + log.Info("webhook would be delivered", "url", p.URL, "event", p.Event) } - _ = secret - _ = payload return nil } } diff --git a/apps/api/internal/queue/queue.go b/apps/api/internal/queue/queue.go index 93361534..ce1a2e34 100644 --- a/apps/api/internal/queue/queue.go +++ b/apps/api/internal/queue/queue.go @@ -35,10 +35,12 @@ type SendEmailPayload struct { // WebhookPayload is the payload for webhook_deliver task. type WebhookPayload struct { - URL string `json:"url"` - Secret string `json:"secret,omitempty"` - Event string `json:"event"` - Payload map[string]interface{} `json:"payload"` + WebhookID string `json:"webhook_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + URL string `json:"url"` + Secret string `json:"secret,omitempty"` + Event string `json:"event"` + Payload map[string]interface{} `json:"payload"` } // Publisher publishes tasks to RabbitMQ. diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index a5d6ae2c..52e3d22a 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -74,6 +74,7 @@ func New(cfg Config) *gin.Engine { searchStore := store.NewSearchStore(cfg.DB) estimateStore := store.NewEstimateStore(cfg.DB) intakeStore := store.NewIntakeStore(cfg.DB) + webhookStore := store.NewWebhookStore(cfg.DB) pageStore := store.NewPageStore(cfg.DB) notificationStore := store.NewNotificationStore(cfg.DB) issueSubscriberStore := store.NewIssueSubscriberStore(cfg.DB) @@ -158,6 +159,8 @@ func New(cfg Config) *gin.Engine { searchSvc := service.NewSearchService(searchStore, workspaceStore) estimateSvc := service.NewEstimateService(estimateStore, projectStore, workspaceStore) intakeSvc := service.NewIntakeService(intakeStore, issueStore, projectStore, workspaceStore) + webhookSvc := service.NewWebhookService(webhookStore, workspaceStore, cfg.Queue) + issueSvc.SetWebhookService(webhookSvc) pageSvc := service.NewPageService(pageStore, projectStore, workspaceStore) pageSvc.SetFavoriteStore(userFavoriteStore) notificationSvc := service.NewNotificationService(notificationStore, workspaceStore, issueStore, projectStore, userStore, stateStore) @@ -246,6 +249,7 @@ func New(cfg Config) *gin.Engine { searchHandler := &handler.SearchHandler{Svc: searchSvc} estimateHandler := &handler.EstimateHandler{Estimate: estimateSvc} intakeHandler := &handler.IntakeHandler{Intake: intakeSvc} + webhookHandler := &handler.WebhookHandler{Webhooks: webhookSvc} issueHandler := &handler.IssueHandler{Issue: issueSvc} issueLinkHandler := &handler.IssueLinkHandler{Issue: issueSvc} attachmentHandler := &handler.AttachmentHandler{Attachment: attachmentSvc} @@ -328,6 +332,11 @@ func New(cfg Config) *gin.Engine { api.GET("/workspaces/:slug/draft-issues/", issueHandler.ListWorkspaceDrafts) api.GET("/workspaces/:slug/archived-issues/", issueHandler.ListWorkspaceArchived) api.GET("/workspaces/:slug/archived-projects/", projectHandler.ListArchived) + api.GET("/workspaces/:slug/webhooks/", webhookHandler.List) + api.POST("/workspaces/:slug/webhooks/", webhookHandler.Create) + api.PATCH("/workspaces/:slug/webhooks/:webhookId/", webhookHandler.Update) + api.DELETE("/workspaces/:slug/webhooks/:webhookId/", webhookHandler.Delete) + api.GET("/workspaces/:slug/webhooks/:webhookId/logs/", webhookHandler.ListLogs) api.GET("/workspaces/:slug/search/", searchHandler.Search) api.GET("/workspaces/:slug/projects/", projectHandler.List) diff --git a/apps/api/internal/service/issue.go b/apps/api/internal/service/issue.go index 988df4b5..5d651e82 100644 --- a/apps/api/internal/service/issue.go +++ b/apps/api/internal/service/issue.go @@ -48,6 +48,7 @@ type IssueService struct { reactions *store.IssueReactionStore // optional — per-issue emoji reactions states *store.StateStore // optional — validates state ownership labels *store.LabelStore // optional — validates label ownership + webhooks *WebhookService // optional — dispatches issue events to webhooks } func NewIssueService(is *store.IssueStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *IssueService { @@ -75,6 +76,23 @@ func (s *IssueService) SetStateStore(st *store.StateStore) { s.states = st } // SetLabelStore wires label-ownership validation. Optional. func (s *IssueService) SetLabelStore(l *store.LabelStore) { s.labels = l } +// SetWebhookService wires outbound webhook dispatch for issue events. Optional. +func (s *IssueService) SetWebhookService(w *WebhookService) { s.webhooks = w } + +// dispatchIssueWebhook fires an "issue" webhook event (best-effort). +func (s *IssueService) dispatchIssueWebhook(ctx context.Context, issue *model.Issue, action string) { + if s.webhooks == nil || issue == nil || issue.IsDraft { + return + } + s.webhooks.Dispatch(ctx, issue.WorkspaceID, "issue", map[string]interface{}{ + "action": action, + "event": "issue", + "workspace_id": issue.WorkspaceID.String(), + "project_id": issue.ProjectID.String(), + "data": issue, + }) +} + // validateRelations rejects related ids that fall outside the allowed scope: // state and labels must belong to the same project, a parent must be another // issue in the same project (and never the issue itself — pass selfID on @@ -628,6 +646,7 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project } } } + s.dispatchIssueWebhook(ctx, issue, "created") return issue, nil } @@ -820,6 +839,7 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project } } } + s.dispatchIssueWebhook(ctx, issue, "updated") return issue, nil } @@ -846,7 +866,7 @@ func uuidSet(ids []uuid.UUID) map[uuid.UUID]bool { } func (s *IssueService) Delete(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) error { - _, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID) + issue, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID) if err != nil { return err } @@ -856,6 +876,7 @@ func (s *IssueService) Delete(ctx context.Context, workspaceSlug string, project if s.notify != nil { s.notify.IssueDeleted(ctx, issueID) } + s.dispatchIssueWebhook(ctx, issue, "deleted") return nil } diff --git a/apps/api/internal/service/webhook.go b/apps/api/internal/service/webhook.go new file mode 100644 index 00000000..84526b45 --- /dev/null +++ b/apps/api/internal/service/webhook.go @@ -0,0 +1,215 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "strings" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/queue" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ( + ErrWebhookNotFound = errors.New("webhook not found") + ErrWebhookBadURL = errors.New("webhook url must be a public http(s) URL") + ErrWebhookWorkspace = errors.New("workspace not found") + ErrWebhookForbidden = errors.New("only workspace admins can manage webhooks") +) + +// WebhookInput carries the mutable fields of a webhook. +type WebhookInput struct { + URL string + IsActive *bool + Project *bool + Issue *bool + Module *bool + Cycle *bool + IssueComment *bool +} + +// WebhookService manages outbound webhooks and dispatches events to them. +type WebhookService struct { + webhooks *store.WebhookStore + ws *store.WorkspaceStore + queue *queue.Publisher // optional; when nil, dispatch is a no-op +} + +func NewWebhookService(webhooks *store.WebhookStore, ws *store.WorkspaceStore, q *queue.Publisher) *WebhookService { + return &WebhookService{webhooks: webhooks, ws: ws, queue: q} +} + +// requireAdmin resolves the workspace and confirms the caller is an admin/owner. +func (s *WebhookService) requireAdmin(ctx context.Context, slug string, userID uuid.UUID) (*model.Workspace, error) { + wrk, err := s.ws.GetBySlug(ctx, slug) + if err != nil { + return nil, ErrWebhookWorkspace + } + m, err := s.ws.GetMember(ctx, wrk.ID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrWebhookForbidden // not a member + } + return nil, err + } + if m == nil || m.Role < model.RoleAdmin { + return nil, ErrWebhookForbidden + } + return wrk, nil +} + +func (s *WebhookService) List(ctx context.Context, slug string, userID uuid.UUID) ([]model.Webhook, error) { + wrk, err := s.requireAdmin(ctx, slug, userID) + if err != nil { + return nil, err + } + return s.webhooks.ListByWorkspace(ctx, wrk.ID) +} + +func (s *WebhookService) Create(ctx context.Context, slug string, userID uuid.UUID, in WebhookInput) (*model.Webhook, error) { + wrk, err := s.requireAdmin(ctx, slug, userID) + if err != nil { + return nil, err + } + if !validWebhookURL(in.URL) { + return nil, ErrWebhookBadURL + } + secret, err := randomSecret() + if err != nil { + return nil, err + } + w := &model.Webhook{ + URL: strings.TrimSpace(in.URL), + SecretKey: secret, + IsActive: boolOr(in.IsActive, true), + Project: boolOr(in.Project, false), + Issue: boolOr(in.Issue, true), + Module: boolOr(in.Module, false), + Cycle: boolOr(in.Cycle, false), + IssueComment: boolOr(in.IssueComment, false), + Version: "v1", + WorkspaceID: wrk.ID, + CreatedByID: &userID, + UpdatedByID: &userID, + } + if err := s.webhooks.Create(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +func (s *WebhookService) Update(ctx context.Context, slug string, userID, id uuid.UUID, in WebhookInput) (*model.Webhook, error) { + wrk, err := s.requireAdmin(ctx, slug, userID) + if err != nil { + return nil, err + } + w, err := s.webhooks.GetByID(ctx, wrk.ID, id) + if err != nil { + return nil, err + } + if w == nil { + return nil, ErrWebhookNotFound + } + if strings.TrimSpace(in.URL) != "" { + if !validWebhookURL(in.URL) { + return nil, ErrWebhookBadURL + } + w.URL = strings.TrimSpace(in.URL) + } + if in.IsActive != nil { + w.IsActive = *in.IsActive + } + if in.Project != nil { + w.Project = *in.Project + } + if in.Issue != nil { + w.Issue = *in.Issue + } + if in.Module != nil { + w.Module = *in.Module + } + if in.Cycle != nil { + w.Cycle = *in.Cycle + } + if in.IssueComment != nil { + w.IssueComment = *in.IssueComment + } + w.UpdatedByID = &userID + if err := s.webhooks.Update(ctx, w); err != nil { + return nil, err + } + return w, nil +} + +func (s *WebhookService) Delete(ctx context.Context, slug string, userID, id uuid.UUID) error { + wrk, err := s.requireAdmin(ctx, slug, userID) + if err != nil { + return err + } + w, err := s.webhooks.GetByID(ctx, wrk.ID, id) + if err != nil { + return err + } + if w == nil { + return ErrWebhookNotFound + } + return s.webhooks.Delete(ctx, wrk.ID, id) +} + +// ListLogs returns recent delivery logs for a webhook the caller can manage. +func (s *WebhookService) ListLogs(ctx context.Context, slug string, userID, id uuid.UUID) ([]model.WebhookLog, error) { + wrk, err := s.requireAdmin(ctx, slug, userID) + if err != nil { + return nil, err + } + w, err := s.webhooks.GetByID(ctx, wrk.ID, id) + if err != nil { + return nil, err + } + if w == nil { + return nil, ErrWebhookNotFound + } + return s.webhooks.ListLogs(ctx, id, 50) +} + +// Dispatch enqueues an event to every active webhook in the workspace that +// subscribes to it. Best-effort: a nil queue or lookup error just skips +// delivery without failing the caller's action. +func (s *WebhookService) Dispatch(ctx context.Context, workspaceID uuid.UUID, event string, payload map[string]interface{}) { + if s == nil || s.queue == nil || !store.IsValidWebhookEvent(event) { + return + } + hooks, err := s.webhooks.ListActiveByWorkspaceAndEvent(ctx, workspaceID, event) + if err != nil { + return + } + for i := range hooks { + _ = s.queue.PublishWebhook(ctx, queue.WebhookPayload{ + WebhookID: hooks[i].ID.String(), + WorkspaceID: workspaceID.String(), + URL: hooks[i].URL, + Secret: hooks[i].SecretKey, + Event: event, + Payload: payload, + }) + } +} + +func boolOr(p *bool, def bool) bool { + if p != nil { + return *p + } + return def +} + +func randomSecret() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/apps/api/internal/service/webhook_delivery.go b/apps/api/internal/service/webhook_delivery.go new file mode 100644 index 00000000..60c71b83 --- /dev/null +++ b/apps/api/internal/service/webhook_delivery.go @@ -0,0 +1,194 @@ +package service + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/queue" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" +) + +const ( + webhookDeliverTimeout = 10 * time.Second + webhookMaxAttempts = 3 + webhookMaxResponseLog = 4096 +) + +// NewWebhookDeliverer returns a delivery function for the webhook queue: it +// signs the payload (HMAC-SHA256), POSTs it with retries to a public URL only +// (SSRF-guarded), and records the attempt in webhook_logs. +func NewWebhookDeliverer(webhooks *store.WebhookStore, log *slog.Logger) func(ctx context.Context, p queue.WebhookPayload) error { + client := &http.Client{ + Timeout: webhookDeliverTimeout, + Transport: &http.Transport{ + DialContext: safeDialContext, + DisableKeepAlives: true, + }, + // Don't follow redirects: a 3xx to an internal host would bypass the + // SSRF check on the original URL. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return func(ctx context.Context, p queue.WebhookPayload) error { + body, err := json.Marshal(p.Payload) + if err != nil { + return err + } + sig := "" + if strings.TrimSpace(p.Secret) != "" { + mac := hmac.New(sha256.New, []byte(p.Secret)) + _, _ = mac.Write(body) + sig = "sha256=" + hex.EncodeToString(mac.Sum(nil)) + } + + var lastErr error + var status, respHeaders, respBody string + attempts := 0 + for attempts < webhookMaxAttempts { + attempts++ + status, respHeaders, respBody, lastErr = deliverOnce(ctx, client, p, body, sig) + if lastErr == nil && strings.HasPrefix(status, "2") { + break + } + if attempts < webhookMaxAttempts { + time.Sleep(time.Duration(attempts) * 500 * time.Millisecond) + } + } + + writeWebhookLog(ctx, webhooks, log, p, body, sig, status, respHeaders, respBody, attempts-1, lastErr) + return nil // delivery is best-effort; failures are recorded, not retried by the queue + } +} + +func deliverOnce(ctx context.Context, client *http.Client, p queue.WebhookPayload, body []byte, sig string) (status, respHeaders, respBody string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body)) + if err != nil { + return "", "", "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Devlane-Webhook/"+"v1") + req.Header.Set("X-Devlane-Event", p.Event) + req.Header.Set("X-Devlane-Delivery", uuid.New().String()) + if sig != "" { + req.Header.Set("X-Devlane-Signature", sig) + } + resp, err := client.Do(req) + if err != nil { + return "", "", "", err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(io.LimitReader(resp.Body, webhookMaxResponseLog)) + return fmt.Sprintf("%d", resp.StatusCode), headerString(resp.Header), string(rb), nil +} + +func writeWebhookLog(ctx context.Context, webhooks *store.WebhookStore, log *slog.Logger, p queue.WebhookPayload, body []byte, sig, status, respHeaders, respBody string, retries int, deliverErr error) { + whID, err1 := uuid.Parse(p.WebhookID) + wsID, err2 := uuid.Parse(p.WorkspaceID) + if err1 != nil || err2 != nil || webhooks == nil { + return + } + if status == "" && deliverErr != nil { + status = "error: " + deliverErr.Error() + } + reqHeaders := "Content-Type: application/json\nX-Devlane-Event: " + p.Event + if sig != "" { + reqHeaders += "\nX-Devlane-Signature: " + sig + } + entry := &model.WebhookLog{ + WebhookID: whID, + WorkspaceID: wsID, + EventType: p.Event, + RequestMethod: http.MethodPost, + RequestHeaders: reqHeaders, + RequestBody: string(body), + ResponseStatus: status, + ResponseHeaders: respHeaders, + ResponseBody: respBody, + RetryCount: retries, + } + if err := webhooks.CreateLog(ctx, entry); err != nil && log != nil { + log.Warn("webhook log write", "error", err) + } +} + +func headerString(h http.Header) string { + var b strings.Builder + for k, vals := range h { + b.WriteString(k) + b.WriteString(": ") + b.WriteString(strings.Join(vals, ",")) + b.WriteString("\n") + } + return b.String() +} + +// safeDialContext resolves the target and refuses to connect to non-public +// addresses, blocking SSRF (including DNS rebinding, since it dials the very IP +// it validated). +func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if !isPublicIP(ip.IP) { + return nil, fmt.Errorf("webhook target %s resolves to a non-public address", host) + } + } + if len(ips) == 0 { + return nil, fmt.Errorf("webhook target %s did not resolve", host) + } + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port)) +} + +func isPublicIP(ip net.IP) bool { + if ip == nil || ip.IsLoopback() || ip.IsUnspecified() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsPrivate() { + return false + } + return true +} + +// validWebhookURL reports whether raw is an http(s) URL that isn't obviously +// internal. The connect-time SSRF guard is the real enforcement (it also covers +// DNS rebinding, which a create-time check cannot); this rejects bad input and +// literal private/loopback IPs early so admins get a clear error instead of a +// webhook that silently fails every delivery. +func validWebhookURL(raw string) bool { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return false + } + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + if u.Host == "" { + return false + } + // Reject literal IP hosts that are non-public. Hostnames are left to the + // connect-time resolver guard, since they can resolve differently later. + if ip := net.ParseIP(u.Hostname()); ip != nil && !isPublicIP(ip) { + return false + } + return true +} diff --git a/apps/api/internal/service/webhook_delivery_internal_test.go b/apps/api/internal/service/webhook_delivery_internal_test.go new file mode 100644 index 00000000..8180d044 --- /dev/null +++ b/apps/api/internal/service/webhook_delivery_internal_test.go @@ -0,0 +1,39 @@ +package service + +import ( + "net" + "testing" +) + +// The SSRF guard must reject loopback/private/link-local/unspecified addresses +// and accept public ones. Covers #195. +func TestIsPublicIP(t *testing.T) { + blocked := []string{"127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.169.254", "0.0.0.0"} + for _, s := range blocked { + if isPublicIP(net.ParseIP(s)) { + t.Errorf("%s should be blocked", s) + } + } + for _, s := range []string{"8.8.8.8", "1.1.1.1"} { + if !isPublicIP(net.ParseIP(s)) { + t.Errorf("%s should be allowed", s) + } + } +} + +func TestValidWebhookURL(t *testing.T) { + for _, ok := range []string{"https://example.com/hook", "http://hooks.example.org/x"} { + if !validWebhookURL(ok) { + t.Errorf("%s should be valid", ok) + } + } + for _, bad := range []string{ + "ftp://example.com", "not a url", "", "file:///etc/passwd", "//example.com", + "http://127.0.0.1/x", "http://10.0.0.5/hook", "https://192.168.1.1/y", + "http://169.254.169.254/latest/meta-data", "http://[::1]/x", + } { + if validWebhookURL(bad) { + t.Errorf("%s should be invalid", bad) + } + } +} diff --git a/apps/api/internal/store/webhook.go b/apps/api/internal/store/webhook.go new file mode 100644 index 00000000..4c1d4761 --- /dev/null +++ b/apps/api/internal/store/webhook.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "errors" + + "github.com/Devlaner/devlane/api/internal/model" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// WebhookStore handles outbound webhook + delivery-log persistence. +type WebhookStore struct{ db *gorm.DB } + +func NewWebhookStore(db *gorm.DB) *WebhookStore { return &WebhookStore{db: db} } + +// webhookEventColumns are the per-event boolean columns; keeping this whitelist +// here keeps the dynamic column name in ListActiveByWorkspaceAndEvent safe. +var webhookEventColumns = map[string]string{ + "project": "project", + "issue": "issue", + "module": "module", + "cycle": "cycle", + "issue_comment": "issue_comment", +} + +// IsValidWebhookEvent reports whether event is a known webhook event type. +func IsValidWebhookEvent(event string) bool { + _, ok := webhookEventColumns[event] + return ok +} + +func (s *WebhookStore) Create(ctx context.Context, w *model.Webhook) error { + return s.db.WithContext(ctx).Create(w).Error +} + +func (s *WebhookStore) ListByWorkspace(ctx context.Context, workspaceID uuid.UUID) ([]model.Webhook, error) { + var list []model.Webhook + err := s.db.WithContext(ctx). + Where("workspace_id = ? AND deleted_at IS NULL", workspaceID). + Order("created_at DESC").Find(&list).Error + return list, err +} + +// GetByID returns a webhook by id in the workspace, or nil when absent. +func (s *WebhookStore) GetByID(ctx context.Context, workspaceID, id uuid.UUID) (*model.Webhook, error) { + var w model.Webhook + err := s.db.WithContext(ctx). + Where("id = ? AND workspace_id = ? AND deleted_at IS NULL", id, workspaceID). + First(&w).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &w, nil +} + +func (s *WebhookStore) Update(ctx context.Context, w *model.Webhook) error { + return s.db.WithContext(ctx).Save(w).Error +} + +func (s *WebhookStore) Delete(ctx context.Context, workspaceID, id uuid.UUID) error { + return s.db.WithContext(ctx). + Where("id = ? AND workspace_id = ?", id, workspaceID). + Delete(&model.Webhook{}).Error +} + +// ListActiveByWorkspaceAndEvent returns the workspace's active webhooks that +// subscribe to the given event type. +func (s *WebhookStore) ListActiveByWorkspaceAndEvent(ctx context.Context, workspaceID uuid.UUID, event string) ([]model.Webhook, error) { + col, ok := webhookEventColumns[event] + if !ok { + return nil, nil + } + var list []model.Webhook + err := s.db.WithContext(ctx). + Where("workspace_id = ? AND deleted_at IS NULL AND is_active = TRUE AND "+col+" = TRUE", workspaceID). + Find(&list).Error + return list, err +} + +// CreateLog records one delivery attempt. +func (s *WebhookStore) CreateLog(ctx context.Context, l *model.WebhookLog) error { + return s.db.WithContext(ctx).Create(l).Error +} + +// ListLogs returns recent delivery logs for a webhook, newest first. +func (s *WebhookStore) ListLogs(ctx context.Context, webhookID uuid.UUID, limit int) ([]model.WebhookLog, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + var list []model.WebhookLog + err := s.db.WithContext(ctx). + Where("webhook_id = ?", webhookID). + Order("created_at DESC").Limit(limit).Find(&list).Error + return list, err +} diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index d344eab8..061c73f5 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -356,6 +356,36 @@ export interface FavoriteApiResponse { updated_at?: string; } +/** An outbound workspace webhook. */ +export interface WebhookApiResponse { + id: string; + url: string; + secret_key?: string; + is_active: boolean; + project: boolean; + issue: boolean; + module: boolean; + cycle: boolean; + issue_comment: boolean; + version: string; + workspace_id: string; + created_at?: string; + updated_at?: string; +} + +/** One webhook delivery attempt (request + response). */ +export interface WebhookLogApiResponse { + id: string; + webhook_id: string; + event_type: string; + request_method: string; + request_body?: string; + response_status: string; + response_body?: string; + retry_count: number; + created_at?: string; +} + export interface IntakeItemApiResponse { id: string; intake_id: string; diff --git a/apps/web/src/components/settings/WebhooksSettings.tsx b/apps/web/src/components/settings/WebhooksSettings.tsx new file mode 100644 index 00000000..b891205c --- /dev/null +++ b/apps/web/src/components/settings/WebhooksSettings.tsx @@ -0,0 +1,403 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Card, CardContent, Button, Modal } from '../ui'; +import { Badge } from '../ui/Badge'; +import { IconPlus, IconTrash, IconRefresh } from './icons'; +import { webhookService, type WebhookPayload } from '../../services/webhookService'; +import { formatRelativeTime } from '../../lib/settingsHelpers'; +import type { WebhookApiResponse, WebhookLogApiResponse } from '../../api/types'; + +interface WebhooksSettingsProps { + workspaceSlug: string; +} + +type EventKey = 'project' | 'issue' | 'module' | 'cycle' | 'issue_comment'; + +const EVENTS: { key: EventKey; label: string; hint: string }[] = [ + { key: 'issue', label: 'Issues', hint: 'Created, updated, or deleted issues' }, + { key: 'project', label: 'Projects', hint: 'Project lifecycle changes' }, + { key: 'module', label: 'Modules', hint: 'Module changes' }, + { key: 'cycle', label: 'Cycles', hint: 'Cycle changes' }, + { key: 'issue_comment', label: 'Issue comments', hint: 'New comments on issues' }, +]; + +const relTime = (iso?: string) => (iso ? formatRelativeTime(iso) : 'unknown'); + +const emptyForm = (): Record & { url: string } => ({ + url: '', + project: false, + issue: true, + module: false, + cycle: false, + issue_comment: false, +}); + +/** + * Outbound workspace webhooks (issue #195). Admins register HTTPS endpoints that + * receive signed POST payloads when subscribed events fire, toggle which events + * each delivers, and inspect recent delivery attempts. The signing secret is + * shown once at creation. Non-admins get a 403 from the API, surfaced here. + */ +export function WebhooksSettings({ workspaceSlug }: WebhooksSettingsProps) { + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [rowError, setRowError] = useState(null); + const [busyId, setBusyId] = useState(null); + + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [createdSecret, setCreatedSecret] = useState(null); + const [form, setForm] = useState(emptyForm()); + + const [logsFor, setLogsFor] = useState(null); + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [logsError, setLogsError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(null); + try { + setWebhooks(await webhookService.list(workspaceSlug)); + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status; + setWebhooks([]); + setLoadError( + status === 403 ? 'Only workspace admins can manage webhooks.' : 'Could not load webhooks.', + ); + } finally { + setLoading(false); + } + }, [workspaceSlug]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setLoadError(null); + webhookService + .list(workspaceSlug) + .then((res) => { + if (!cancelled) setWebhooks(res); + }) + .catch((err) => { + if (cancelled) return; + const status = (err as { response?: { status?: number } })?.response?.status; + setWebhooks([]); + setLoadError( + status === 403 + ? 'Only workspace admins can manage webhooks.' + : 'Could not load webhooks.', + ); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + + const openCreate = () => { + setForm(emptyForm()); + setCreatedSecret(null); + setCreateError(null); + setCreateOpen(true); + }; + + const closeCreate = () => { + setCreateOpen(false); + setCreatedSecret(null); + setCreateError(null); + }; + + const anyEventSelected = useMemo(() => EVENTS.some((e) => form[e.key]), [form]); + + const handleCreate = async () => { + setCreating(true); + setCreateError(null); + try { + const payload: WebhookPayload = { + url: form.url.trim(), + project: form.project, + issue: form.issue, + module: form.module, + cycle: form.cycle, + issue_comment: form.issue_comment, + }; + const created = await webhookService.create(workspaceSlug, payload); + setCreatedSecret(created.secret_key ?? null); + await load(); + } catch (err: unknown) { + const status = (err as { response?: { status?: number } })?.response?.status; + setCreateError( + status === 400 + ? 'Enter a valid public http(s) URL.' + : status === 403 + ? 'Only workspace admins can create webhooks.' + : 'Could not create the webhook. Please try again.', + ); + } finally { + setCreating(false); + } + }; + + const toggleActive = async (w: WebhookApiResponse) => { + setBusyId(w.id); + setRowError(null); + try { + const updated = await webhookService.update(workspaceSlug, w.id, { + is_active: !w.is_active, + }); + setWebhooks((prev) => prev.map((x) => (x.id === w.id ? updated : x))); + } catch { + setRowError('Could not update the webhook. Please try again.'); + } finally { + setBusyId(null); + } + }; + + const handleDelete = async (w: WebhookApiResponse) => { + setBusyId(w.id); + setRowError(null); + try { + await webhookService.remove(workspaceSlug, w.id); + setWebhooks((prev) => prev.filter((x) => x.id !== w.id)); + } catch { + setRowError('Could not delete the webhook. Please try again.'); + } finally { + setBusyId(null); + } + }; + + const openLogs = async (w: WebhookApiResponse) => { + setLogsFor(w); + setLogs([]); + setLogsError(null); + setLogsLoading(true); + try { + setLogs(await webhookService.logs(workspaceSlug, w.id)); + } catch { + setLogsError('Could not load delivery logs.'); + } finally { + setLogsLoading(false); + } + }; + + const subscribedEvents = (w: WebhookApiResponse) => + EVENTS.filter((e) => w[e.key]).map((e) => e.label); + + return ( +
+
+
+

Webhooks

+

+ Send signed HTTP POST payloads to your own endpoints when events happen in this + workspace. Each request carries an X-Devlane-Signature{' '} + HMAC-SHA256 header you can verify with the signing secret. +

+
+ +
+ + {loading ? ( +
Loading webhooks…
+ ) : loadError ? ( + + +

{loadError}

+
+
+ ) : webhooks.length === 0 ? ( + + +

No webhooks yet.

+
+
+ ) : ( +
+ {rowError &&

{rowError}

} + {webhooks.map((w) => { + const events = subscribedEvents(w); + return ( +
+
+
+

{w.url}

+ + {w.is_active ? 'Active' : 'Paused'} + +
+

+ {events.length ? events.join(', ') : 'No events'} · Created{' '} + {relTime(w.created_at)} +

+
+
+ + + +
+
+ ); + })} +
+ )} + + {/* Create webhook modal */} + + {createdSecret !== null ? ( +
+

+ Copy this signing secret now; it will not be shown again. Use it to verify the{' '} + X-Devlane-Signature header on incoming requests. +

+
+ {createdSecret || '(no secret returned)'} +
+
+ +
+
+ ) : ( +
+
+ + setForm((f) => ({ ...f, url: e.target.value }))} + placeholder="https://example.com/webhooks/devlane" + className="w-full rounded-(--radius-md) border border-(--border-subtle) bg-(--bg-surface-1) px-3 py-2 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none focus:border-(--border-strong)" + /> +
+
+

Events

+
+ {EVENTS.map((e) => ( + + ))} +
+
+ {createError &&

{createError}

} +
+ + +
+
+ )} +
+ + {/* Delivery logs modal */} + setLogsFor(null)} title="Delivery logs"> +
+
+

{logsFor?.url}

+ +
+ {logsLoading ? ( +

Loading…

+ ) : logsError ? ( +

{logsError}

+ ) : logs.length === 0 ? ( +

No deliveries yet.

+ ) : ( +
+ {logs.map((l) => { + const code = parseInt(l.response_status, 10); + const ok = !Number.isNaN(code) && code >= 200 && code < 300; + return ( +
+
+ + {l.event_type} + + + {l.response_status || 'no response'} + +
+

+ {relTime(l.created_at)} + {l.retry_count > 0 ? ` · ${l.retry_count} retries` : ''} +

+ {l.response_body && ( +

+ {l.response_body} +

+ )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 605945e6..ca72589b 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import { CoverImageModal } from '../components/CoverImageModal'; import { IntegrationsSection } from '../components/integrations/IntegrationsSection'; import { ProjectEstimatesSettings } from '../components/settings/ProjectEstimatesSettings'; import { NotificationPreferencesPanel } from '../components/settings/NotificationPreferencesPanel'; +import { WebhooksSettings } from '../components/settings/WebhooksSettings'; import { notificationPreferenceService } from '../services/notificationPreferenceService'; import { accountService } from '../services/accountService'; import { UploadImageModal } from '../components/UploadImageModal'; @@ -3361,14 +3362,8 @@ export function SettingsPage() { )} - {!isAccountTab && !isProjectsTab && section === 'webhooks' && ( - - -

- Webhooks settings will be available when the API is connected. -

-
-
+ {!isAccountTab && !isProjectsTab && section === 'webhooks' && workspaceSlug && ( + )} diff --git a/apps/web/src/services/webhookService.ts b/apps/web/src/services/webhookService.ts new file mode 100644 index 00000000..b2f5e1b8 --- /dev/null +++ b/apps/web/src/services/webhookService.ts @@ -0,0 +1,47 @@ +import { apiClient } from '../api/client'; +import type { WebhookApiResponse, WebhookLogApiResponse } from '../api/types'; + +const base = (workspaceSlug: string) => + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/webhooks/`; + +export interface WebhookPayload { + url?: string; + is_active?: boolean; + project?: boolean; + issue?: boolean; + module?: boolean; + cycle?: boolean; + issue_comment?: boolean; +} + +/** Outbound workspace webhooks (admin only). */ +export const webhookService = { + async list(workspaceSlug: string): Promise { + const { data } = await apiClient.get(base(workspaceSlug)); + return Array.isArray(data) ? data : []; + }, + async create(workspaceSlug: string, payload: WebhookPayload): Promise { + const { data } = await apiClient.post(base(workspaceSlug), payload); + return data; + }, + async update( + workspaceSlug: string, + webhookId: string, + payload: WebhookPayload, + ): Promise { + const { data } = await apiClient.patch( + `${base(workspaceSlug)}${encodeURIComponent(webhookId)}/`, + payload, + ); + return data; + }, + async remove(workspaceSlug: string, webhookId: string): Promise { + await apiClient.delete(`${base(workspaceSlug)}${encodeURIComponent(webhookId)}/`); + }, + async logs(workspaceSlug: string, webhookId: string): Promise { + const { data } = await apiClient.get( + `${base(workspaceSlug)}${encodeURIComponent(webhookId)}/logs/`, + ); + return Array.isArray(data) ? data : []; + }, +}; From c6e813f4b8315880571ec1af78e5627a188f11fa Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 14:17:58 +0400 Subject: [PATCH 2/2] fix(api): stop leaking webhook secret, stabilize delivery id, block CGNAT Address review findings on the webhooks feature: - The signing secret was serialized on every webhook response (secret_key json tag), so admins could read it on any list/update fetch. Mark it json:"-" and return it exactly once, from the create handler. - X-Devlane-Delivery was regenerated per retry attempt, so retries looked like distinct events to receivers doing idempotency dedup. Generate one delivery id per delivery, reuse it across attempts, and record it in the delivery log. - isPublicIP now also rejects CGNAT shared address space (RFC 6598, 100.64.0.0/10), which ip.IsPrivate() does not cover. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/internal/handler/webhook.go | 19 +++++++++++++++- apps/api/internal/handler/webhook_test.go | 4 +++- apps/api/internal/model/webhook.go | 2 +- apps/api/internal/service/webhook_delivery.go | 22 ++++++++++++++----- .../service/webhook_delivery_internal_test.go | 4 ++-- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/api/internal/handler/webhook.go b/apps/api/internal/handler/webhook.go index 16e62ee9..52fd5ca0 100644 --- a/apps/api/internal/handler/webhook.go +++ b/apps/api/internal/handler/webhook.go @@ -70,7 +70,24 @@ func (h *WebhookHandler) Create(c *gin.Context) { h.webhookError(c, err) return } - c.JSON(http.StatusCreated, w) + // The signing secret is returned exactly once, here at creation. Every other + // response omits it (model.Webhook.SecretKey is json:"-"), so it never leaks + // on subsequent list/update fetches. + c.JSON(http.StatusCreated, gin.H{ + "id": w.ID, + "url": w.URL, + "secret_key": w.SecretKey, + "is_active": w.IsActive, + "project": w.Project, + "issue": w.Issue, + "module": w.Module, + "cycle": w.Cycle, + "issue_comment": w.IssueComment, + "version": w.Version, + "workspace_id": w.WorkspaceID, + "created_at": w.CreatedAt, + "updated_at": w.UpdatedAt, + }) } // Update edits a webhook. diff --git a/apps/api/internal/handler/webhook_test.go b/apps/api/internal/handler/webhook_test.go index 5771b72f..5accf841 100644 --- a/apps/api/internal/handler/webhook_test.go +++ b/apps/api/internal/handler/webhook_test.go @@ -25,12 +25,14 @@ func TestWebhook_CRUD(t *testing.T) { require.NotEmpty(t, created["secret_key"], "a secret should be generated") require.Equal(t, true, created["issue"]) - // List includes it. + // List includes the webhook but never the secret: it is returned only once, + // at creation. lr := ts.GET(base, w.Session) require.Equal(t, http.StatusOK, lr.Code) var list []map[string]any require.NoError(t, json.Unmarshal(lr.Body.Bytes(), &list)) require.Len(t, list, 1) + require.NotContains(t, list[0], "secret_key", "the secret must not leak on list") // Toggle it off. ur := ts.PATCH(base+id+"/", map[string]any{"is_active": false}, w.Session) diff --git a/apps/api/internal/model/webhook.go b/apps/api/internal/model/webhook.go index fe02399a..3d9d7158 100644 --- a/apps/api/internal/model/webhook.go +++ b/apps/api/internal/model/webhook.go @@ -12,7 +12,7 @@ import ( type Webhook struct { ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` URL string `gorm:"type:varchar(1024);not null" json:"url"` - SecretKey string `gorm:"column:secret_key;type:varchar(255)" json:"secret_key,omitempty"` + SecretKey string `gorm:"column:secret_key;type:varchar(255)" json:"-"` IsActive bool `gorm:"column:is_active;not null;default:true" json:"is_active"` Project bool `gorm:"column:project;not null;default:false" json:"project"` Issue bool `gorm:"column:issue;not null;default:false" json:"issue"` diff --git a/apps/api/internal/service/webhook_delivery.go b/apps/api/internal/service/webhook_delivery.go index 60c71b83..4edb8ebb 100644 --- a/apps/api/internal/service/webhook_delivery.go +++ b/apps/api/internal/service/webhook_delivery.go @@ -56,12 +56,16 @@ func NewWebhookDeliverer(webhooks *store.WebhookStore, log *slog.Logger) func(ct sig = "sha256=" + hex.EncodeToString(mac.Sum(nil)) } + // One delivery ID for the whole delivery, stable across retries, so + // receivers can dedupe: retrying an attempt must not look like a new event. + deliveryID := uuid.New().String() + var lastErr error var status, respHeaders, respBody string attempts := 0 for attempts < webhookMaxAttempts { attempts++ - status, respHeaders, respBody, lastErr = deliverOnce(ctx, client, p, body, sig) + status, respHeaders, respBody, lastErr = deliverOnce(ctx, client, p, body, sig, deliveryID) if lastErr == nil && strings.HasPrefix(status, "2") { break } @@ -70,12 +74,12 @@ func NewWebhookDeliverer(webhooks *store.WebhookStore, log *slog.Logger) func(ct } } - writeWebhookLog(ctx, webhooks, log, p, body, sig, status, respHeaders, respBody, attempts-1, lastErr) + writeWebhookLog(ctx, webhooks, log, p, body, sig, deliveryID, status, respHeaders, respBody, attempts-1, lastErr) return nil // delivery is best-effort; failures are recorded, not retried by the queue } } -func deliverOnce(ctx context.Context, client *http.Client, p queue.WebhookPayload, body []byte, sig string) (status, respHeaders, respBody string, err error) { +func deliverOnce(ctx context.Context, client *http.Client, p queue.WebhookPayload, body []byte, sig, deliveryID string) (status, respHeaders, respBody string, err error) { req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.URL, bytes.NewReader(body)) if err != nil { return "", "", "", err @@ -83,7 +87,7 @@ func deliverOnce(ctx context.Context, client *http.Client, p queue.WebhookPayloa req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Devlane-Webhook/"+"v1") req.Header.Set("X-Devlane-Event", p.Event) - req.Header.Set("X-Devlane-Delivery", uuid.New().String()) + req.Header.Set("X-Devlane-Delivery", deliveryID) if sig != "" { req.Header.Set("X-Devlane-Signature", sig) } @@ -96,7 +100,7 @@ func deliverOnce(ctx context.Context, client *http.Client, p queue.WebhookPayloa return fmt.Sprintf("%d", resp.StatusCode), headerString(resp.Header), string(rb), nil } -func writeWebhookLog(ctx context.Context, webhooks *store.WebhookStore, log *slog.Logger, p queue.WebhookPayload, body []byte, sig, status, respHeaders, respBody string, retries int, deliverErr error) { +func writeWebhookLog(ctx context.Context, webhooks *store.WebhookStore, log *slog.Logger, p queue.WebhookPayload, body []byte, sig, deliveryID, status, respHeaders, respBody string, retries int, deliverErr error) { whID, err1 := uuid.Parse(p.WebhookID) wsID, err2 := uuid.Parse(p.WorkspaceID) if err1 != nil || err2 != nil || webhooks == nil { @@ -105,7 +109,8 @@ func writeWebhookLog(ctx context.Context, webhooks *store.WebhookStore, log *slo if status == "" && deliverErr != nil { status = "error: " + deliverErr.Error() } - reqHeaders := "Content-Type: application/json\nX-Devlane-Event: " + p.Event + reqHeaders := "Content-Type: application/json\nX-Devlane-Event: " + p.Event + + "\nX-Devlane-Delivery: " + deliveryID if sig != "" { reqHeaders += "\nX-Devlane-Signature: " + sig } @@ -166,6 +171,11 @@ func isPublicIP(ip net.IP) bool { ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsPrivate() { return false } + // Block CGNAT shared address space (RFC 6598, 100.64.0.0/10), which + // ip.IsPrivate() does not cover but some providers use for internal services. + if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 { + return false + } return true } diff --git a/apps/api/internal/service/webhook_delivery_internal_test.go b/apps/api/internal/service/webhook_delivery_internal_test.go index 8180d044..7a5e819b 100644 --- a/apps/api/internal/service/webhook_delivery_internal_test.go +++ b/apps/api/internal/service/webhook_delivery_internal_test.go @@ -8,7 +8,7 @@ import ( // The SSRF guard must reject loopback/private/link-local/unspecified addresses // and accept public ones. Covers #195. func TestIsPublicIP(t *testing.T) { - blocked := []string{"127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.169.254", "0.0.0.0"} + blocked := []string{"127.0.0.1", "::1", "10.0.0.5", "192.168.1.1", "172.16.0.1", "169.254.169.254", "0.0.0.0", "100.64.0.1", "100.127.255.255"} for _, s := range blocked { if isPublicIP(net.ParseIP(s)) { t.Errorf("%s should be blocked", s) @@ -30,7 +30,7 @@ func TestValidWebhookURL(t *testing.T) { for _, bad := range []string{ "ftp://example.com", "not a url", "", "file:///etc/passwd", "//example.com", "http://127.0.0.1/x", "http://10.0.0.5/hook", "https://192.168.1.1/y", - "http://169.254.169.254/latest/meta-data", "http://[::1]/x", + "http://169.254.169.254/latest/meta-data", "http://[::1]/x", "http://100.64.0.1/x", } { if validWebhookURL(bad) { t.Errorf("%s should be invalid", bad)