From abb52c7db283739ca0dc21111d3bdf17f3200e46 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Wed, 10 Jun 2026 11:01:56 +0200 Subject: [PATCH] fix(webhook): fail closed when stripe webhook secret is not set --- api/handlers/webhook.go | 28 ++++++++++++++++++++++++++-- api/handlers/webhook_test.go | 35 +++++++++++++++++++++++++++++++++++ pkg/config/config.go | 1 + 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index 88cc85e..a13eb92 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -7,6 +7,7 @@ import ( "io" "log/slog" "net/http" + "os" "strings" "time" @@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) { // Verify the Stripe signature. Reject anything that doesn't match. if h.webhookSecret == "" { - // No secret configured — log a loud warning but still process in dev. - webhookLog().Warn("stripe webhook secret not set; skipping signature verification") + // Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET + // in production, so the server should never boot into this state; this is + // defense in depth against a misconfigured deploy. Mirrors the Shippo and + // Slack handlers, which already refuse to run without their secret. + if os.Getenv("ENVIRONMENT") == "production" { + webhookLog().Error("stripe webhook secret not set in production; refusing to process event") + utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil) + return + } + // Development only; warn loudly but still process so local testing works. + webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)") } else { sig := r.Header.Get("Stripe-Signature") if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ @@ -124,6 +134,20 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event return } + // Defense in depth: even behind a valid signature, refuse to mark an order + // paid unless the PaymentIntent's amount and currency match what we charged. + // Under ConvertCart these always match (Amount == order.Total at creation), so + // a mismatch means a malformed or unexpected event we should not trust. + if pi.Amount != int64(order.Total) { + webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total) + return + } + if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD { + webhookLog().Error("payment intent currency mismatch; refusing to mark paid", + "order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency)) + return + } + txErr := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(&order).Updates(map[string]any{ "status": models.OrderStatusPaid, diff --git a/api/handlers/webhook_test.go b/api/handlers/webhook_test.go index 0d7de59..942c0a5 100644 --- a/api/handlers/webhook_test.go +++ b/api/handlers/webhook_test.go @@ -222,3 +222,38 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) { t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID) } } + +func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch") + + // PI claims 1 cent; seed order.Total is 500. + pi := map[string]any{ + "id": order.StripePaymentID, + "amount": 1, + "metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)}, + } + raw, err := json.Marshal(pi) + if err != nil { + t.Fatalf("marshal pi: %v", err) + } + evt := stripe.Event{ + Type: "payment_intent.succeeded", + Data: &stripe.EventData{Raw: raw}, + } + + h := NewWebhookHandler("", "") + h.handlePaymentIntentSucceeded(context.Background(), evt) + + db := database.GetDB() + var reloaded models.Order + if err := db.First(&reloaded, order.ID).Error; err != nil { + t.Fatalf("reloaded order: %v", err) + } + if reloaded.Status == models.OrderStatusPaid { + t.Fatalf("order marked paid despite amount mismatch") + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 3408faa..117e463 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -92,6 +92,7 @@ func (c *Config) validate() error { {"JWT_SECRET", c.JWTSecret}, {"STRIPE_SECRET_KEY", c.StripeSecretKey}, {"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey}, + {"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret}, } var missing []string