From a1e3484fbad1b00238aabe8a92cc7ce3f642e85c Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 12:13:55 +0200 Subject: [PATCH] Verify webhooks against the secret of the mode we are in Co-Authored-By: Claude Opus 5 --- .env.example | 4 ++ .../Controllers/StripeWebhookController.php | 3 +- app/Support/StripeWebhookSecret.php | 33 +++++++++++++ config/services.php | 5 ++ phpunit.xml | 10 ++-- .../Feature/StripeWebhookSecretByModeTest.php | 46 +++++++++++++++++++ 6 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 app/Support/StripeWebhookSecret.php create mode 100644 tests/Feature/StripeWebhookSecretByModeTest.php diff --git a/.env.example b/.env.example index 7318d2d..b7dd576 100644 --- a/.env.example +++ b/.env.example @@ -94,7 +94,11 @@ MAIL_FROM_NAME=CluPilot # ── Stripe (payments) ──────────────────────────────────────────────────── # WEBHOOK_SECRET is READ BY CODE today (signature check on /webhooks/stripe). # Outside local/testing a missing secret makes the webhook fail closed. +# Stripe hands out a SEPARATE signing secret per operating mode (test/live) — +# fill in the one matching each. Which one applies follows platform.mode +# (App\Support\StripeWebhookSecret), not this file. STRIPE_WEBHOOK_SECRET= +STRIPE_WEBHOOK_SECRET_TEST= # Not read yet — needed once the Checkout flow is built (currently the portal # only records a purchase intent): STRIPE_KEY= diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index fabe785..b1f4c19 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Actions\ApplyStripeBillingEvent; use App\Actions\StartCustomerProvisioning; +use App\Support\StripeWebhookSecret; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,7 +21,7 @@ class StripeWebhookController extends Controller ApplyStripeBillingEvent $billing, ): JsonResponse { $payload = $request->getContent(); - $secret = (string) config('services.stripe.webhook_secret'); + $secret = StripeWebhookSecret::current(); if (blank($secret)) { // Fail closed: an unconfigured secret must not authorize provisioning diff --git a/app/Support/StripeWebhookSecret.php b/app/Support/StripeWebhookSecret.php new file mode 100644 index 0000000..e1c7acb --- /dev/null +++ b/app/Support/StripeWebhookSecret.php @@ -0,0 +1,33 @@ +isTest() + ? (string) config('services.stripe.webhook_secret_test') + : (string) config('services.stripe.webhook_secret'); + } +} diff --git a/config/services.php b/config/services.php index 84462ab..e8cec2a 100644 --- a/config/services.php +++ b/config/services.php @@ -37,6 +37,11 @@ return [ 'stripe' => [ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + // Stripe issues a SEPARATE signing secret per operating mode. Which of + // the two applies is decided by App\Support\StripeWebhookSecret, not + // here — see its header comment for why that lookup still touches the + // database despite both values living in the .env. + 'webhook_secret_test' => env('STRIPE_WEBHOOK_SECRET_TEST'), // Secret key for the REST API. Blank means "not connected": the // catalogue sync says so and does nothing, rather than half-creating // products against an account that is not there. diff --git a/phpunit.xml b/phpunit.xml index bbd3a88..9a33a84 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -30,11 +30,15 @@ - + an invalid signature. The tests that DO exercise verification set + the secret(s) themselves. _TEST is the live secret's sibling + (StripeWebhookSecret picks between them by operating mode) — same + variable class, same risk, so it gets the same treatment even + though no current test happens to need it. --> +