Verify webhooks against the secret of the mode we are in

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 12:13:55 +02:00
parent 32a8bd9a88
commit a1e3484fba
6 changed files with 97 additions and 4 deletions

View File

@ -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=

View File

@ -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

View File

@ -0,0 +1,33 @@
<?php
namespace App\Support;
/**
* Der Signaturschlüssel des aktiven Betriebsmodus.
*
* Beide Werte bleiben in der `.env`, nicht im Tresor (siehe Kopfkommentar von
* App\Services\Secrets\SecretVault): dieser Wert wird bei jedem eingehenden
* Zahlungsereignis gelesen, und ein Datenbankproblem würde die
* Signaturprüfung still fehlschlagen lassen die eine Fehlerart, die laut
* bleiben muss.
*
* Die AUSWAHL fragt allerdings die Datenbank (OperatingMode::current()), was
* diese Abhängigkeit auf einem Umweg wieder einführt. Entschärft durch die
* Richtung des Ausfalls: `Settings::get()` liefert bei unerreichbarer Tabelle
* seinen Vorgabewert `live`, also wird der Live-Schlüssel benutzt. Ein echtes
* Zahlungsereignis wird weiter korrekt geprüft, ein Testereignis scheitert
* laut. Andersherum wäre es die gefährliche Richtung.
*
* Eigene Klasse statt einer privaten Methode auf dem Controller: die Auswahl
* selbst ist der Prüfgegenstand (siehe StripeWebhookSecretByModeTest), und ein
* Test soll dafür nicht per Reflection ins Controller-Innere greifen müssen.
*/
final class StripeWebhookSecret
{
public static function current(): string
{
return OperatingMode::current()->isTest()
? (string) config('services.stripe.webhook_secret_test')
: (string) config('services.stripe.webhook_secret');
}
}

View File

@ -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.

View File

@ -30,11 +30,15 @@
<!-- Fixed test key: storing a VPN config must be exercised, and a
random one would make a decryption failure irreproducible. -->
<env name="VPN_CONFIG_KEY" value="Y2x1cGlsb3QtdGVzdC1vbmx5LXZwbi1rZXktMzJieXQ=" force="true"/>
<!-- Blank so the suite never picks up the operator's real webhook secret
<!-- Blank so the suite never picks up the operator's real webhook secrets
from .env: with one set, every unsigned test payload is rejected as
an invalid signature. The two tests that DO exercise verification
set the secret themselves. -->
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. -->
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
<env name="STRIPE_WEBHOOK_SECRET_TEST" value="" force="true"/>
<!-- Pinned so the suite stops depending on the operator's .env. It did:
HostStepsTest asserted `fsn-01.node.clupilot.com` and passed only
because this server's .env carried the wrong zone. Correcting the

View File

@ -0,0 +1,46 @@
<?php
use App\Support\OperatingMode;
use App\Support\StripeWebhookSecret;
use Illuminate\Support\Facades\Schema;
/**
* Zwei Signaturschlüssel, weil Stripe je Modus einen eigenen vergibt.
*
* Der dritte Test ist der eigentliche Grund für diese Datei: die Auswahl liest
* den Modus aus der Datenbank aus genau der Quelle, die für diesen Wert
* bewusst vermieden wurde. Fällt sie aus, muss die Auswahl auf LIVE fallen. Ein
* echtes Zahlungsereignis wird dann weiter korrekt geprüft; ein Testereignis
* scheitert laut. Die umgekehrte Vorgabe wäre die gefährliche.
*
* Ruft App\Support\StripeWebhookSecret::current() direkt auf statt über
* Reflection eine private Controller-Methode: die Auswahl sitzt in einer
* eigenen, öffentlich aufrufbaren Klasse (Begründung in deren Kopfkommentar),
* gerade damit dieser Test ohne ReflectionMethod auskommt.
*/
beforeEach(function () {
config()->set('services.stripe.webhook_secret', 'whsec_live');
config()->set('services.stripe.webhook_secret_test', 'whsec_test');
});
it('uses the live signing secret in live mode', function () {
OperatingMode::set(OperatingMode::Live);
expect(StripeWebhookSecret::current())->toBe('whsec_live');
});
it('uses the test signing secret in test mode', function () {
OperatingMode::set(OperatingMode::Test);
expect(StripeWebhookSecret::current())->toBe('whsec_test');
});
it('uses the live secret when the settings table cannot be read', function () {
OperatingMode::set(OperatingMode::Test);
// Der Ausfall, gegen den der Wert ursprünglich aus der Datenbank
// herausgehalten wurde.
Schema::drop('app_settings');
expect(StripeWebhookSecret::current())->toBe('whsec_live');
});