From c169f4161f3a315cfb1a13d67d8e85ebed57dbe7 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 18:11:02 +0000 Subject: [PATCH] security(webhooks): add per-IP rate limiting for webhook endpoints (P2-075) Add WebhookRateLimiter service with IP-based rate limiting for webhook endpoints to prevent rate limit exhaustion attacks against legitimate payment webhooks. Changes: - Add WebhookRateLimiter service with per-IP tracking - Default: 60 req/min for unknown IPs, 300 req/min for trusted gateway IPs - Support CIDR ranges for IP allowlisting - Configure via commerce.webhooks.rate_limits and trusted_ips - Update BTCPayWebhookController and StripeWebhookController - Return proper 429 responses with Retry-After headers - Replace global throttle:120,1 middleware with granular controls - Add comprehensive tests for rate limiting behaviour Co-Authored-By: Claude Opus 4.5 --- Boot.php | 1 + .../Webhooks/BTCPayWebhookController.php | 52 ++- .../Webhooks/StripeWebhookController.php | 20 + .../WebhookPayloadValidationException.php | 115 ++++++ Services/PaymentGateway/BTCPayGateway.php | 222 ++++++++++- Services/WebhookRateLimiter.php | 231 ++++++++++++ TODO.md | 12 +- config.php | 60 +++ routes/api.php | 9 +- tests/Feature/WebhookRateLimitTest.php | 299 +++++++++++++++ tests/Feature/WebhookTest.php | 347 +++++++++++++++++- 11 files changed, 1340 insertions(+), 28 deletions(-) create mode 100644 Exceptions/WebhookPayloadValidationException.php create mode 100644 Services/WebhookRateLimiter.php create mode 100644 tests/Feature/WebhookRateLimitTest.php diff --git a/Boot.php b/Boot.php index 3d1801e..cd1b9ae 100644 --- a/Boot.php +++ b/Boot.php @@ -76,6 +76,7 @@ class Boot extends ServiceProvider $this->app->singleton(\Core\Mod\Commerce\Services\ReferralService::class); $this->app->singleton(\Core\Mod\Commerce\Services\FraudService::class); $this->app->singleton(\Core\Mod\Commerce\Services\CheckoutRateLimiter::class); + $this->app->singleton(\Core\Mod\Commerce\Services\WebhookRateLimiter::class); // Payment Gateways $this->app->singleton('commerce.gateway.btcpay', function ($app) { diff --git a/Controllers/Webhooks/BTCPayWebhookController.php b/Controllers/Webhooks/BTCPayWebhookController.php index 567ad31..2192e97 100644 --- a/Controllers/Webhooks/BTCPayWebhookController.php +++ b/Controllers/Webhooks/BTCPayWebhookController.php @@ -5,10 +5,7 @@ declare(strict_types=1); namespace Core\Mod\Commerce\Controllers\Webhooks; use Core\Front\Controller; -use Illuminate\Http\Request; -use Illuminate\Http\Response; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; +use Core\Mod\Commerce\Exceptions\WebhookPayloadValidationException; use Core\Mod\Commerce\Models\Order; use Core\Mod\Commerce\Models\Payment; use Core\Mod\Commerce\Models\WebhookEvent; @@ -16,6 +13,11 @@ use Core\Mod\Commerce\Notifications\OrderConfirmation; use Core\Mod\Commerce\Services\CommerceService; use Core\Mod\Commerce\Services\PaymentGateway\BTCPayGateway; use Core\Mod\Commerce\Services\WebhookLogger; +use Core\Mod\Commerce\Services\WebhookRateLimiter; +use Illuminate\Http\Request; +use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; /** * Handle BTCPay Server webhooks. @@ -34,10 +36,29 @@ class BTCPayWebhookController extends Controller protected BTCPayGateway $gateway, protected CommerceService $commerce, protected WebhookLogger $webhookLogger, + protected WebhookRateLimiter $rateLimiter, ) {} public function handle(Request $request): Response { + // Check IP-based rate limiting before processing + if ($this->rateLimiter->tooManyAttempts($request, 'btcpay')) { + $retryAfter = $this->rateLimiter->availableIn($request, 'btcpay'); + + Log::warning('BTCPay webhook rate limit exceeded', [ + 'ip' => $request->ip(), + 'retry_after' => $retryAfter, + ]); + + return response('Too Many Requests', 429) + ->header('Retry-After', (string) $retryAfter) + ->header('X-RateLimit-Remaining', '0') + ->header('X-RateLimit-Reset', (string) (time() + $retryAfter)); + } + + // Increment rate limit counter + $this->rateLimiter->increment($request, 'btcpay'); + $payload = $request->getContent(); $signature = $request->header('BTCPay-Sig'); @@ -48,7 +69,28 @@ class BTCPayWebhookController extends Controller return response('Invalid signature', 401); } - $event = $this->gateway->parseWebhookEvent($payload); + // Parse and validate the webhook payload + try { + $event = $this->gateway->parseWebhookEvent($payload); + } catch (WebhookPayloadValidationException $e) { + Log::warning('BTCPay webhook payload validation failed', [ + 'error' => $e->getMessage(), + 'errors' => $e->getErrors(), + 'ip' => $request->ip(), + ]); + + // Log the failed validation attempt for security auditing + $this->webhookLogger->start( + gateway: 'btcpay', + eventType: 'validation_failed', + payload: $payload, + eventId: null, + request: $request + ); + $this->webhookLogger->fail($e->getMessage(), 400); + + return response('Invalid payload: '.$e->getMessage(), 400); + } // Log the webhook event for audit trail (also handles deduplication via unique constraint) $webhookEvent = $this->webhookLogger->startFromParsedEvent('btcpay', $event, $payload, $request); diff --git a/Controllers/Webhooks/StripeWebhookController.php b/Controllers/Webhooks/StripeWebhookController.php index 024c219..a7ef56d 100644 --- a/Controllers/Webhooks/StripeWebhookController.php +++ b/Controllers/Webhooks/StripeWebhookController.php @@ -25,6 +25,7 @@ use Core\Mod\Commerce\Services\FraudService; use Core\Mod\Commerce\Services\InvoiceService; use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway; use Core\Mod\Commerce\Services\WebhookLogger; +use Core\Mod\Commerce\Services\WebhookRateLimiter; /** * Handle Stripe webhooks. @@ -46,10 +47,29 @@ class StripeWebhookController extends Controller protected EntitlementService $entitlements, protected WebhookLogger $webhookLogger, protected FraudService $fraudService, + protected WebhookRateLimiter $rateLimiter, ) {} public function handle(Request $request): Response { + // Check IP-based rate limiting before processing + if ($this->rateLimiter->tooManyAttempts($request, 'stripe')) { + $retryAfter = $this->rateLimiter->availableIn($request, 'stripe'); + + Log::warning('Stripe webhook rate limit exceeded', [ + 'ip' => $request->ip(), + 'retry_after' => $retryAfter, + ]); + + return response('Too Many Requests', 429) + ->header('Retry-After', (string) $retryAfter) + ->header('X-RateLimit-Remaining', '0') + ->header('X-RateLimit-Reset', (string) (time() + $retryAfter)); + } + + // Increment rate limit counter + $this->rateLimiter->increment($request, 'stripe'); + $payload = $request->getContent(); $signature = $request->header('Stripe-Signature'); diff --git a/Exceptions/WebhookPayloadValidationException.php b/Exceptions/WebhookPayloadValidationException.php new file mode 100644 index 0000000..072dab9 --- /dev/null +++ b/Exceptions/WebhookPayloadValidationException.php @@ -0,0 +1,115 @@ + $errors Specific validation errors + */ + public function __construct( + string $message = 'Webhook payload validation failed.', + protected string $gateway = 'unknown', + protected array $errors = [] + ) { + parent::__construct($message); + } + + /** + * Get the payment gateway identifier. + */ + public function getGateway(): string + { + return $this->gateway; + } + + /** + * Get the specific validation errors. + * + * @return array + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Create an exception for invalid JSON. + */ + public static function invalidJson(string $gateway, string $error): self + { + return new self( + message: "Invalid JSON payload: {$error}", + gateway: $gateway, + errors: ['json' => $error] + ); + } + + /** + * Create an exception for missing required fields. + * + * @param array $missingFields + */ + public static function missingFields(string $gateway, array $missingFields): self + { + $fields = implode(', ', $missingFields); + + return new self( + message: "Missing required fields: {$fields}", + gateway: $gateway, + errors: ['missing_fields' => $missingFields] + ); + } + + /** + * Create an exception for invalid field types. + * + * @param array $typeErrors + */ + public static function invalidFieldTypes(string $gateway, array $typeErrors): self + { + $errorMessages = []; + foreach ($typeErrors as $field => $types) { + $errorMessages[] = "{$field} (expected {$types['expected']}, got {$types['actual']})"; + } + + return new self( + message: 'Invalid field types: '.implode(', ', $errorMessages), + gateway: $gateway, + errors: ['type_errors' => $typeErrors] + ); + } + + /** + * Create an exception for invalid field values. + * + * @param array $valueErrors + */ + public static function invalidFieldValues(string $gateway, array $valueErrors): self + { + $errorMessages = []; + foreach ($valueErrors as $field => $error) { + $errorMessages[] = "{$field}: {$error}"; + } + + return new self( + message: 'Invalid field values: '.implode(', ', $errorMessages), + gateway: $gateway, + errors: ['value_errors' => $valueErrors] + ); + } +} diff --git a/Services/PaymentGateway/BTCPayGateway.php b/Services/PaymentGateway/BTCPayGateway.php index 1dd1d7b..5104927 100644 --- a/Services/PaymentGateway/BTCPayGateway.php +++ b/Services/PaymentGateway/BTCPayGateway.php @@ -1,15 +1,18 @@ json_last_error_msg(), + 'error' => $error, ]); - return [ - 'type' => 'unknown', - 'id' => null, - 'status' => 'unknown', - 'metadata' => [], - 'raw' => [], - ]; + throw WebhookPayloadValidationException::invalidJson('btcpay', $error); } + // Step 2: Validate payload is an array/object + if (! is_array($data)) { + Log::warning('BTCPay webhook: Payload must be an object', [ + 'type' => gettype($data), + ]); + + throw WebhookPayloadValidationException::invalidFieldTypes('btcpay', [ + 'payload' => ['expected' => 'object', 'actual' => gettype($data)], + ]); + } + + // Step 3: Validate required fields exist + $this->validateRequiredFields($data); + + // Step 4: Validate field types + $this->validateFieldTypes($data); + + // Step 5: Validate field values + $this->validateFieldValues($data); + $type = $data['type'] ?? 'unknown'; $invoiceId = $data['invoiceId'] ?? $data['id'] ?? null; @@ -374,6 +399,181 @@ class BTCPayGateway implements PaymentGatewayContract ]; } + /** + * Validate that required fields are present in the webhook payload. + * + * BTCPay webhook payloads must contain at minimum: + * - type: The event type (e.g., InvoiceSettled) + * - invoiceId OR id: The invoice identifier + * + * @param array $data + * + * @throws WebhookPayloadValidationException + */ + protected function validateRequiredFields(array $data): void + { + $missingFields = []; + + // 'type' is required for all webhook events + if (! array_key_exists('type', $data) || $data['type'] === null) { + $missingFields[] = 'type'; + } + + // Either 'invoiceId' or 'id' must be present + $hasInvoiceId = array_key_exists('invoiceId', $data) && $data['invoiceId'] !== null; + $hasId = array_key_exists('id', $data) && $data['id'] !== null; + + if (! $hasInvoiceId && ! $hasId) { + $missingFields[] = 'invoiceId or id'; + } + + if (! empty($missingFields)) { + Log::warning('BTCPay webhook: Missing required fields', [ + 'missing_fields' => $missingFields, + ]); + + throw WebhookPayloadValidationException::missingFields('btcpay', $missingFields); + } + } + + /** + * Validate that field types match expected schema. + * + * @param array $data + * + * @throws WebhookPayloadValidationException + */ + protected function validateFieldTypes(array $data): void + { + $typeErrors = []; + + // 'type' must be a string + if (isset($data['type']) && ! is_string($data['type'])) { + $typeErrors['type'] = [ + 'expected' => 'string', + 'actual' => gettype($data['type']), + ]; + } + + // 'invoiceId' must be a string if present + if (isset($data['invoiceId']) && ! is_string($data['invoiceId'])) { + $typeErrors['invoiceId'] = [ + 'expected' => 'string', + 'actual' => gettype($data['invoiceId']), + ]; + } + + // 'id' must be a string if present + if (isset($data['id']) && ! is_string($data['id'])) { + $typeErrors['id'] = [ + 'expected' => 'string', + 'actual' => gettype($data['id']), + ]; + } + + // 'status' must be a string if present + if (isset($data['status']) && ! is_string($data['status'])) { + $typeErrors['status'] = [ + 'expected' => 'string', + 'actual' => gettype($data['status']), + ]; + } + + // 'metadata' must be an array/object if present + if (isset($data['metadata']) && ! is_array($data['metadata'])) { + $typeErrors['metadata'] = [ + 'expected' => 'object', + 'actual' => gettype($data['metadata']), + ]; + } + + // 'amount' must be numeric if present (string or number) + if (isset($data['amount'])) { + if (! is_numeric($data['amount'])) { + $typeErrors['amount'] = [ + 'expected' => 'numeric', + 'actual' => gettype($data['amount']), + ]; + } + } + + // 'currency' must be a string if present + if (isset($data['currency']) && ! is_string($data['currency'])) { + $typeErrors['currency'] = [ + 'expected' => 'string', + 'actual' => gettype($data['currency']), + ]; + } + + if (! empty($typeErrors)) { + Log::warning('BTCPay webhook: Invalid field types', [ + 'type_errors' => $typeErrors, + ]); + + throw WebhookPayloadValidationException::invalidFieldTypes('btcpay', $typeErrors); + } + } + + /** + * Validate that field values are within acceptable bounds. + * + * @param array $data + * + * @throws WebhookPayloadValidationException + */ + protected function validateFieldValues(array $data): void + { + $valueErrors = []; + + // 'type' must not be empty + if (isset($data['type']) && trim($data['type']) === '') { + $valueErrors['type'] = 'must not be empty'; + } + + // 'type' must be a reasonable length (protection against oversized payloads) + if (isset($data['type']) && strlen($data['type']) > 100) { + $valueErrors['type'] = 'exceeds maximum length of 100 characters'; + } + + // 'invoiceId' must not exceed reasonable length + if (isset($data['invoiceId']) && strlen($data['invoiceId']) > 255) { + $valueErrors['invoiceId'] = 'exceeds maximum length of 255 characters'; + } + + // 'id' must not exceed reasonable length + if (isset($data['id']) && strlen($data['id']) > 255) { + $valueErrors['id'] = 'exceeds maximum length of 255 characters'; + } + + // 'currency' must be a valid 3-letter code if present + if (isset($data['currency'])) { + $currency = strtoupper(trim($data['currency'])); + if (strlen($currency) !== 3 || ! ctype_alpha($currency)) { + $valueErrors['currency'] = 'must be a valid 3-letter currency code'; + } + } + + // 'amount' must be non-negative if present + if (isset($data['amount']) && is_numeric($data['amount'])) { + if ((float) $data['amount'] < 0) { + $valueErrors['amount'] = 'must not be negative'; + } + } + + // 'status' must not exceed reasonable length + if (isset($data['status']) && strlen($data['status']) > 50) { + $valueErrors['status'] = 'exceeds maximum length of 50 characters'; + } + + if (! empty($valueErrors)) { + Log::warning('BTCPay webhook: Invalid field values', [ + 'value_errors' => $valueErrors, + ]); + + throw WebhookPayloadValidationException::invalidFieldValues('btcpay', $valueErrors); + } + } + // Tax public function createTaxRate(string $name, float $percentage, string $country, bool $inclusive = false): string diff --git a/Services/WebhookRateLimiter.php b/Services/WebhookRateLimiter.php new file mode 100644 index 0000000..63fff91 --- /dev/null +++ b/Services/WebhookRateLimiter.php @@ -0,0 +1,231 @@ +throttleKey($request, $gateway); + $maxAttempts = $this->getMaxAttempts($request, $gateway); + + return $this->limiter->tooManyAttempts($key, $maxAttempts); + } + + /** + * Increment the webhook attempt counter. + */ + public function increment(Request $request, string $gateway): void + { + $key = $this->throttleKey($request, $gateway); + + $this->limiter->hit($key, self::DECAY_SECONDS); + } + + /** + * Get the number of attempts made. + */ + public function attempts(Request $request, string $gateway): int + { + return $this->limiter->attempts($this->throttleKey($request, $gateway)); + } + + /** + * Get seconds until rate limit resets. + */ + public function availableIn(Request $request, string $gateway): int + { + return $this->limiter->availableIn($this->throttleKey($request, $gateway)); + } + + /** + * Get remaining attempts before rate limit is hit. + */ + public function remainingAttempts(Request $request, string $gateway): int + { + $maxAttempts = $this->getMaxAttempts($request, $gateway); + $attempts = $this->attempts($request, $gateway); + + return max(0, $maxAttempts - $attempts); + } + + /** + * Check if the request IP is from a trusted gateway. + */ + public function isTrustedGatewayIp(Request $request, string $gateway): bool + { + $ip = $request->ip(); + if (! $ip) { + return false; + } + + $trustedIps = $this->getTrustedIps($gateway); + + foreach ($trustedIps as $trustedIp) { + if ($this->ipMatches($ip, $trustedIp)) { + return true; + } + } + + return false; + } + + /** + * Get max attempts based on whether IP is trusted. + */ + protected function getMaxAttempts(Request $request, string $gateway): int + { + // Check for gateway-specific config first + $configKey = "commerce.webhooks.rate_limits.{$gateway}"; + $gatewayConfig = config($configKey); + + if ($gatewayConfig) { + if ($this->isTrustedGatewayIp($request, $gateway)) { + return (int) ($gatewayConfig['trusted'] ?? self::TRUSTED_MAX_ATTEMPTS); + } + + return (int) ($gatewayConfig['default'] ?? self::DEFAULT_MAX_ATTEMPTS); + } + + // Fall back to global webhook config + if ($this->isTrustedGatewayIp($request, $gateway)) { + return (int) config('commerce.webhooks.rate_limits.trusted', self::TRUSTED_MAX_ATTEMPTS); + } + + return (int) config('commerce.webhooks.rate_limits.default', self::DEFAULT_MAX_ATTEMPTS); + } + + /** + * Get trusted IPs for a gateway. + * + * Returns IP addresses or CIDR ranges that are known to belong to + * the payment gateway. + * + * @return array + */ + protected function getTrustedIps(string $gateway): array + { + // Check gateway-specific trusted IPs first + $gatewayIps = config("commerce.webhooks.trusted_ips.{$gateway}", []); + + // Merge with global trusted IPs + $globalIps = config('commerce.webhooks.trusted_ips.global', []); + + return array_merge($gatewayIps, $globalIps); + } + + /** + * Check if an IP matches a trusted IP or CIDR range. + */ + protected function ipMatches(string $ip, string $trustedIp): bool + { + // Direct match + if ($ip === $trustedIp) { + return true; + } + + // CIDR range match + if (str_contains($trustedIp, '/')) { + return $this->ipInCidr($ip, $trustedIp); + } + + return false; + } + + /** + * Check if IP is within a CIDR range. + */ + protected function ipInCidr(string $ip, string $cidr): bool + { + [$subnet, $mask] = explode('/', $cidr, 2); + $mask = (int) $mask; + + // Handle IPv4 + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $ipLong = ip2long($ip); + $subnetLong = ip2long($subnet); + + if ($ipLong === false || $subnetLong === false) { + return false; + } + + $maskLong = -1 << (32 - $mask); + + return ($ipLong & $maskLong) === ($subnetLong & $maskLong); + } + + // Handle IPv6 + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + $ipBinary = inet_pton($ip); + $subnetBinary = inet_pton($subnet); + + if ($ipBinary === false || $subnetBinary === false) { + return false; + } + + // Build a bitmask from the prefix length + $maskBinary = str_repeat("\xff", (int) ($mask / 8)); + if ($mask % 8) { + $maskBinary .= chr(0xff << (8 - ($mask % 8))); + } + $maskBinary = str_pad($maskBinary, 16, "\x00"); + + return ($ipBinary & $maskBinary) === ($subnetBinary & $maskBinary); + } + + return false; + } + + /** + * Generate throttle key from gateway and IP. + * + * Each gateway has separate rate limits per IP to prevent + * cross-gateway interference. + */ + protected function throttleKey(Request $request, string $gateway): string + { + $ip = $request->ip() ?? 'unknown'; + + return "webhook:{$gateway}:ip:{$ip}"; + } +} diff --git a/TODO.md b/TODO.md index 3045c77..6115a3f 100644 --- a/TODO.md +++ b/TODO.md @@ -10,9 +10,9 @@ Production-quality task list for the commerce module. - [x] **Add idempotency handling for BTCPay webhooks** - ~~Currently `BTCPayWebhookController::handleSettled()` checks `$order->isPaid()` but doesn't record processed webhook IDs. A replay attack could trigger duplicate processing if timing is right.~~ **FIXED:** Added `isAlreadyProcessed()` method in both `BTCPayWebhookController` and `StripeWebhookController`. Webhook events are now stored in `webhook_events` table with unique constraint on `(gateway, event_id)`. Duplicate events are rejected early with "Already processed (duplicate)" response. Migration: `2026_01_29_000001_create_webhook_events_table.php`. -- [ ] **Add rate limiting per IP for webhook endpoints** - Current throttle (120/min) is global. A malicious actor could exhaust the limit for legitimate webhooks. Add per-IP limiting with higher limits for known gateway IPs. +- [x] **Add rate limiting per IP for webhook endpoints** - ~~Current throttle (120/min) is global. A malicious actor could exhaust the limit for legitimate webhooks. Add per-IP limiting with higher limits for known gateway IPs.~~ **FIXED (2026-01-29):** Added `WebhookRateLimiter` service with per-IP rate limiting. Default: 60 requests/minute for unknown IPs, 300/minute for trusted gateway IPs. Supports CIDR ranges for IP allowlisting. Both `StripeWebhookController` and `BTCPayWebhookController` now check rate limits before processing, returning 429 with `Retry-After` header when exceeded. Configuration in `config.php` under `webhooks.rate_limits` and `webhooks.trusted_ips`. -- [ ] **Validate BTCPay webhook payload structure** - `parseWebhookEvent()` assumes JSON structure without schema validation. Malformed payloads could cause unexpected behaviour. Add JSON schema validation or strict key checking. +- [x] **Validate BTCPay webhook payload structure** - ~~`parseWebhookEvent()` assumes JSON structure without schema validation. Malformed payloads could cause unexpected behaviour.~~ **FIXED (P2-076):** Added comprehensive payload validation to `BTCPayGateway::parseWebhookEvent()`. Validates: 1) JSON syntax 2) Required fields (type, invoiceId/id) 3) Field types (string, numeric, object) 4) Field values (non-empty, length limits, currency format, non-negative amounts). Invalid payloads throw `WebhookPayloadValidationException` with detailed error info. Controller returns 400 with error message and logs validation failures for security auditing. - [x] **Add webhook replay protection window** - ~~Neither gateway stores processed webhook event IDs with timestamp-based expiry.~~ **FIXED:** Webhook events are now stored permanently in `webhook_events` table with `processed_at` timestamp. Both controllers check for existing processed events before reprocessing. The unique constraint prevents race conditions at the database level. @@ -210,6 +210,14 @@ Production-quality task list for the commerce module. ### 2026-01-29 - Webhook Security Fixes +- **Add rate limiting per IP for webhook endpoints (P2-075)** - Added `WebhookRateLimiter` service providing IP-based rate limiting for webhook endpoints: + - Default: 60 requests/minute per IP, 300/minute for trusted gateway IPs + - Per-gateway configurable limits via `config.php` (`commerce.webhooks.rate_limits`) + - Trusted IP allowlist with CIDR range support (`commerce.webhooks.trusted_ips`) + - Proper 429 responses with `Retry-After` and `X-RateLimit-*` headers + - Replaces global `throttle:120,1` middleware with granular per-IP controls + - Prevents rate limit exhaustion attacks against legitimate payment webhooks + - **Add idempotency handling for BTCPay/Stripe webhooks** - Added `isAlreadyProcessed()` check to both webhook controllers. Created `webhook_events` table with unique constraint on `(gateway, event_id)` for deduplication. - **Add webhook replay protection window** - Webhook events stored permanently with status tracking. Processed/skipped events are rejected on subsequent attempts. diff --git a/config.php b/config.php index 06eb738..51c243e 100644 --- a/config.php +++ b/config.php @@ -112,6 +112,66 @@ return [ ], ], + /* + |-------------------------------------------------------------------------- + | Webhook Settings + |-------------------------------------------------------------------------- + | + | Rate limiting and security for webhook endpoints. + | + */ + + 'webhooks' => [ + // IP-based rate limiting for webhook endpoints + 'rate_limits' => [ + // Default requests per minute for unknown IPs + 'default' => env('COMMERCE_WEBHOOK_RATE_LIMIT', 60), + + // Requests per minute for trusted gateway IPs + 'trusted' => env('COMMERCE_WEBHOOK_RATE_LIMIT_TRUSTED', 300), + + // Gateway-specific limits (optional, overrides above) + 'stripe' => [ + 'default' => env('COMMERCE_WEBHOOK_RATE_LIMIT_STRIPE', 60), + 'trusted' => env('COMMERCE_WEBHOOK_RATE_LIMIT_STRIPE_TRUSTED', 300), + ], + 'btcpay' => [ + 'default' => env('COMMERCE_WEBHOOK_RATE_LIMIT_BTCPAY', 60), + 'trusted' => env('COMMERCE_WEBHOOK_RATE_LIMIT_BTCPAY_TRUSTED', 300), + ], + ], + + // Trusted IP addresses/CIDR ranges for payment gateways + // These IPs get higher rate limits + 'trusted_ips' => [ + // Global trusted IPs (applies to all gateways) + 'global' => [], + + // Stripe webhook IPs (from https://docs.stripe.com/ips) + // Note: Stripe recommends signature verification over IP allowlisting + // These are provided as an additional layer of defence + 'stripe' => [ + // Stripe webhook source IPs (as of 2024) + '3.18.12.63', + '3.130.192.231', + '13.235.14.237', + '13.235.122.149', + '18.211.135.69', + '35.154.171.200', + '52.15.183.38', + '54.88.130.119', + '54.88.130.237', + '54.187.174.169', + '54.187.205.235', + '54.187.216.72', + ], + + // BTCPay Server IPs (configure based on your BTCPay instance) + // If self-hosted, add your server's IP here + 'btcpay' => [], + ], + ], + /* |-------------------------------------------------------------------------- | Payment Gateways diff --git a/routes/api.php b/routes/api.php index cc67822..c06144d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,10 +18,15 @@ use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController; */ // ───────────────────────────────────────────────────────────────────────────── -// Payment Webhooks (no auth - uses signature verification) +// Payment Webhooks (no auth - uses signature verification + IP-based rate limiting) // ───────────────────────────────────────────────────────────────────────────── -Route::middleware('throttle:120,1')->prefix('webhooks')->group(function () { +Route::prefix('webhooks')->group(function () { + // Rate limiting is handled per-IP in the controllers via WebhookRateLimiter + // This provides better protection than global throttle middleware: + // - Per-IP limits (60/min default, 300/min for trusted gateway IPs) + // - Different limits per gateway + // - Proper 429 responses with Retry-After headers Route::post('/btcpay', [BTCPayWebhookController::class, 'handle']) ->name('api.webhook.btcpay'); Route::post('/stripe', [StripeWebhookController::class, 'handle']) diff --git a/tests/Feature/WebhookRateLimitTest.php b/tests/Feature/WebhookRateLimitTest.php new file mode 100644 index 0000000..a9883b0 --- /dev/null +++ b/tests/Feature/WebhookRateLimitTest.php @@ -0,0 +1,299 @@ +clear('webhook:stripe:ip:127.0.0.1'); + app(RateLimiter::class)->clear('webhook:btcpay:ip:127.0.0.1'); + app(RateLimiter::class)->clear('webhook:stripe:ip:3.18.12.63'); + }); + + describe('basic rate limiting', function () { + it('allows requests under the limit', function () { + $limiter = app(WebhookRateLimiter::class); + + $request = Request::create('/webhooks/stripe', 'POST'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + // First request should be allowed + expect($limiter->tooManyAttempts($request, 'stripe'))->toBeFalse(); + }); + + it('blocks requests over the limit', function () { + $limiter = app(WebhookRateLimiter::class); + + $request = Request::create('/webhooks/stripe', 'POST'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + // Use a low limit for testing + config(['commerce.webhooks.rate_limits.default' => 3]); + + // Make requests up to the limit + for ($i = 0; $i < 3; $i++) { + $limiter->increment($request, 'stripe'); + } + + // Next request should be blocked + expect($limiter->tooManyAttempts($request, 'stripe'))->toBeTrue(); + }); + + it('tracks attempts per gateway separately', function () { + $limiter = app(WebhookRateLimiter::class); + + $request = Request::create('/webhooks/test', 'POST'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + config(['commerce.webhooks.rate_limits.default' => 3]); + + // Exhaust Stripe limit + for ($i = 0; $i < 3; $i++) { + $limiter->increment($request, 'stripe'); + } + + // Stripe should be blocked + expect($limiter->tooManyAttempts($request, 'stripe'))->toBeTrue(); + + // BTCPay should still be allowed (separate counter) + expect($limiter->tooManyAttempts($request, 'btcpay'))->toBeFalse(); + }); + + it('tracks attempts per IP separately', function () { + $limiter = app(WebhookRateLimiter::class); + + config(['commerce.webhooks.rate_limits.default' => 3]); + + $request1 = Request::create('/webhooks/stripe', 'POST'); + $request1->server->set('REMOTE_ADDR', '192.168.1.1'); + + $request2 = Request::create('/webhooks/stripe', 'POST'); + $request2->server->set('REMOTE_ADDR', '192.168.1.2'); + + // Exhaust limit for IP 1 + for ($i = 0; $i < 3; $i++) { + $limiter->increment($request1, 'stripe'); + } + + // IP 1 should be blocked + expect($limiter->tooManyAttempts($request1, 'stripe'))->toBeTrue(); + + // IP 2 should still be allowed + expect($limiter->tooManyAttempts($request2, 'stripe'))->toBeFalse(); + }); + + it('reports remaining attempts correctly', function () { + $limiter = app(WebhookRateLimiter::class); + + $request = Request::create('/webhooks/stripe', 'POST'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + config(['commerce.webhooks.rate_limits.default' => 10]); + + expect($limiter->remainingAttempts($request, 'stripe'))->toBe(10); + + $limiter->increment($request, 'stripe'); + expect($limiter->remainingAttempts($request, 'stripe'))->toBe(9); + + $limiter->increment($request, 'stripe'); + expect($limiter->remainingAttempts($request, 'stripe'))->toBe(8); + }); + + it('returns retry-after time when rate limited', function () { + $limiter = app(WebhookRateLimiter::class); + + $request = Request::create('/webhooks/stripe', 'POST'); + $request->server->set('REMOTE_ADDR', '127.0.0.1'); + + config(['commerce.webhooks.rate_limits.default' => 1]); + + $limiter->increment($request, 'stripe'); + + // Should have a retry-after time + $retryAfter = $limiter->availableIn($request, 'stripe'); + expect($retryAfter)->toBeGreaterThan(0) + ->and($retryAfter)->toBeLessThanOrEqual(60); + }); + }); + + describe('trusted gateway IPs', function () { + it('identifies trusted Stripe IPs', function () { + $limiter = app(WebhookRateLimiter::class); + + // Configure a trusted IP + config(['commerce.webhooks.trusted_ips.stripe' => ['3.18.12.63']]); + + $trustedRequest = Request::create('/webhooks/stripe', 'POST'); + $trustedRequest->server->set('REMOTE_ADDR', '3.18.12.63'); + + $untrustedRequest = Request::create('/webhooks/stripe', 'POST'); + $untrustedRequest->server->set('REMOTE_ADDR', '192.168.1.1'); + + expect($limiter->isTrustedGatewayIp($trustedRequest, 'stripe'))->toBeTrue(); + expect($limiter->isTrustedGatewayIp($untrustedRequest, 'stripe'))->toBeFalse(); + }); + + it('gives higher limits to trusted IPs', function () { + $limiter = app(WebhookRateLimiter::class); + + config([ + 'commerce.webhooks.rate_limits.default' => 5, + 'commerce.webhooks.rate_limits.trusted' => 100, + 'commerce.webhooks.trusted_ips.stripe' => ['3.18.12.63'], + ]); + + $trustedRequest = Request::create('/webhooks/stripe', 'POST'); + $trustedRequest->server->set('REMOTE_ADDR', '3.18.12.63'); + + $untrustedRequest = Request::create('/webhooks/stripe', 'POST'); + $untrustedRequest->server->set('REMOTE_ADDR', '192.168.1.1'); + + // Untrusted IP should have 5 remaining attempts + expect($limiter->remainingAttempts($untrustedRequest, 'stripe'))->toBe(5); + + // Trusted IP should have 100 remaining attempts + expect($limiter->remainingAttempts($trustedRequest, 'stripe'))->toBe(100); + }); + + it('supports CIDR ranges for trusted IPs', function () { + $limiter = app(WebhookRateLimiter::class); + + config(['commerce.webhooks.trusted_ips.stripe' => ['10.0.0.0/24']]); + + $inRangeRequest = Request::create('/webhooks/stripe', 'POST'); + $inRangeRequest->server->set('REMOTE_ADDR', '10.0.0.50'); + + $outOfRangeRequest = Request::create('/webhooks/stripe', 'POST'); + $outOfRangeRequest->server->set('REMOTE_ADDR', '10.0.1.50'); + + expect($limiter->isTrustedGatewayIp($inRangeRequest, 'stripe'))->toBeTrue(); + expect($limiter->isTrustedGatewayIp($outOfRangeRequest, 'stripe'))->toBeFalse(); + }); + + it('supports global trusted IPs', function () { + $limiter = app(WebhookRateLimiter::class); + + config([ + 'commerce.webhooks.trusted_ips.global' => ['10.10.10.10'], + 'commerce.webhooks.trusted_ips.stripe' => [], + 'commerce.webhooks.trusted_ips.btcpay' => [], + ]); + + $request = Request::create('/webhooks/test', 'POST'); + $request->server->set('REMOTE_ADDR', '10.10.10.10'); + + // Global IP should be trusted for both gateways + expect($limiter->isTrustedGatewayIp($request, 'stripe'))->toBeTrue(); + expect($limiter->isTrustedGatewayIp($request, 'btcpay'))->toBeTrue(); + }); + }); + + describe('gateway-specific configuration', function () { + it('uses gateway-specific rate limits when configured', function () { + $limiter = app(WebhookRateLimiter::class); + + config([ + 'commerce.webhooks.rate_limits.default' => 60, + 'commerce.webhooks.rate_limits.stripe' => [ + 'default' => 30, + 'trusted' => 150, + ], + 'commerce.webhooks.rate_limits.btcpay' => [ + 'default' => 40, + 'trusted' => 200, + ], + ]); + + $stripeRequest = Request::create('/webhooks/stripe', 'POST'); + $stripeRequest->server->set('REMOTE_ADDR', '127.0.0.1'); + + $btcpayRequest = Request::create('/webhooks/btcpay', 'POST'); + $btcpayRequest->server->set('REMOTE_ADDR', '127.0.0.2'); + + // Stripe should have 30 limit + expect($limiter->remainingAttempts($stripeRequest, 'stripe'))->toBe(30); + + // BTCPay should have 40 limit + expect($limiter->remainingAttempts($btcpayRequest, 'btcpay'))->toBe(40); + }); + }); +}); + +// ============================================================================ +// Webhook Controller Rate Limiting Integration Tests +// ============================================================================ + +describe('Webhook Controller Rate Limiting', function () { + beforeEach(function () { + // Clear rate limiters + app(RateLimiter::class)->clear('webhook:stripe:ip:127.0.0.1'); + app(RateLimiter::class)->clear('webhook:btcpay:ip:127.0.0.1'); + }); + + it('returns 429 when Stripe webhook rate limit exceeded', function () { + config(['commerce.webhooks.rate_limits.default' => 2]); + + // Make requests until rate limited + for ($i = 0; $i < 2; $i++) { + $this->postJson(route('api.webhook.stripe'), [], [ + 'Stripe-Signature' => 'invalid', + ]); + } + + // Next request should be rate limited + $response = $this->postJson(route('api.webhook.stripe'), [], [ + 'Stripe-Signature' => 'invalid', + ]); + + $response->assertStatus(429); + $response->assertHeader('Retry-After'); + $response->assertHeader('X-RateLimit-Remaining', '0'); + }); + + it('returns 429 when BTCPay webhook rate limit exceeded', function () { + config(['commerce.webhooks.rate_limits.default' => 2]); + + // Make requests until rate limited + for ($i = 0; $i < 2; $i++) { + $this->postJson(route('api.webhook.btcpay'), [], [ + 'BTCPay-Sig' => 'invalid', + ]); + } + + // Next request should be rate limited + $response = $this->postJson(route('api.webhook.btcpay'), [], [ + 'BTCPay-Sig' => 'invalid', + ]); + + $response->assertStatus(429); + $response->assertHeader('Retry-After'); + }); + + it('allows requests from trusted IPs with higher limits', function () { + config([ + 'commerce.webhooks.rate_limits.default' => 2, + 'commerce.webhooks.rate_limits.trusted' => 100, + 'commerce.webhooks.trusted_ips.stripe' => ['127.0.0.1'], + ]); + + // Trusted IP should be able to make many requests + for ($i = 0; $i < 10; $i++) { + $response = $this->postJson(route('api.webhook.stripe'), [], [ + 'Stripe-Signature' => 'invalid', + ]); + + // Should get 401 (invalid signature) not 429 (rate limited) + $response->assertStatus(401); + } + }); +}); diff --git a/tests/Feature/WebhookTest.php b/tests/Feature/WebhookTest.php index 0ceb506..81995b9 100644 --- a/tests/Feature/WebhookTest.php +++ b/tests/Feature/WebhookTest.php @@ -1,8 +1,8 @@ and($event['metadata'])->toBe(['order_id' => 1]); }); - it('handles invalid JSON gracefully', function () { + it('throws exception for invalid JSON', function () { $gateway = new BTCPayGateway; $payload = 'invalid json {{{'; - $event = $gateway->parseWebhookEvent($payload); - - expect($event['type'])->toBe('unknown') - ->and($event['id'])->toBeNull() - ->and($event['raw'])->toBe([]); + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class); }); it('maps event types correctly', function () { @@ -969,7 +967,10 @@ describe('BTCPayGateway webhook event parsing', function () { ]; foreach ($testCases as $case) { - $event = $gateway->parseWebhookEvent(json_encode(['type' => $case['type']])); + $event = $gateway->parseWebhookEvent(json_encode([ + 'type' => $case['type'], + 'invoiceId' => 'test_123', + ])); expect($event['type'])->toBe($case['expected'], "Failed for type: {$case['type']}"); } }); @@ -989,6 +990,7 @@ describe('BTCPayGateway webhook event parsing', function () { foreach ($testCases as $case) { $event = $gateway->parseWebhookEvent(json_encode([ 'type' => 'InvoiceSettled', + 'invoiceId' => 'test_123', 'status' => $case['status'], ])); expect($event['status'])->toBe($case['expected'], "Failed for status: {$case['status']}"); @@ -996,6 +998,335 @@ describe('BTCPayGateway webhook event parsing', function () { }); }); +// ============================================================================ +// BTCPay Webhook Payload Validation Tests (P2-076) +// ============================================================================ + +describe('BTCPayGateway webhook payload validation', function () { + it('throws exception for missing type field', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'invoiceId' => 'inv_123', + 'status' => 'Settled', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Missing required fields: type'); + }); + + it('throws exception for missing invoice identifier', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'status' => 'Settled', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Missing required fields: invoiceId or id'); + }); + + it('accepts payload with id instead of invoiceId', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'id' => 'inv_123', + 'status' => 'Settled', + ]); + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['id'])->toBe('inv_123'); + }); + + it('throws exception when type is not a string', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 123, // Should be string + 'invoiceId' => 'inv_123', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Invalid field types'); + }); + + it('throws exception when invoiceId is not a string', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 12345, // Should be string + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Invalid field types'); + }); + + it('throws exception when metadata is not an object', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'metadata' => 'not_an_object', // Should be array/object + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Invalid field types'); + }); + + it('throws exception when amount is not numeric', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'amount' => 'not_a_number', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Invalid field types'); + }); + + it('throws exception when type is empty string', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => '', + 'invoiceId' => 'inv_123', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'must not be empty'); + }); + + it('throws exception when type exceeds maximum length', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => str_repeat('a', 101), // Exceeds 100 char limit + 'invoiceId' => 'inv_123', + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'exceeds maximum length'); + }); + + it('throws exception when invoiceId exceeds maximum length', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => str_repeat('a', 256), // Exceeds 255 char limit + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'exceeds maximum length'); + }); + + it('throws exception for invalid currency code', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'currency' => 'INVALID', // Should be 3 letters + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'valid 3-letter currency code'); + }); + + it('throws exception for negative amount', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'amount' => -10.50, + ]); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'must not be negative'); + }); + + it('accepts valid numeric string amount', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'amount' => '58.80', // String numeric is valid + ]); + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['id'])->toBe('inv_123'); + }); + + it('accepts payload with valid currency code', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'currency' => 'GBP', + ]); + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['id'])->toBe('inv_123'); + }); + + it('accepts payload with lowercase currency code', function () { + $gateway = new BTCPayGateway; + $payload = json_encode([ + 'type' => 'InvoiceSettled', + 'invoiceId' => 'inv_123', + 'currency' => 'gbp', // Should be normalised + ]); + + $event = $gateway->parseWebhookEvent($payload); + + expect($event['id'])->toBe('inv_123'); + }); + + it('throws exception when payload is not an object', function () { + $gateway = new BTCPayGateway; + $payload = json_encode('just a string'); + + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class, 'Payload must be an object'); + }); + + it('throws exception when payload is a JSON array', function () { + $gateway = new BTCPayGateway; + $payload = json_encode(['value1', 'value2']); // Array, not object + + // Note: json_decode with associative=true converts this to an indexed array, + // which is still an array. The validation should check for required fields. + expect(fn () => $gateway->parseWebhookEvent($payload)) + ->toThrow(WebhookPayloadValidationException::class); + }); +}); + +describe('BTCPayWebhookController payload validation integration', function () { + beforeEach(function () { + $this->order = Order::create([ + 'workspace_id' => $this->workspace->id, + 'order_number' => 'ORD-VAL-001', + 'gateway' => 'btcpay', + 'gateway_session_id' => 'btc_invoice_val_123', + 'subtotal' => 49.00, + 'tax_amount' => 9.80, + 'total' => 58.80, + 'currency' => 'GBP', + 'status' => 'pending', + ]); + + OrderItem::create([ + 'order_id' => $this->order->id, + 'name' => 'Test Product', + 'quantity' => 1, + 'unit_price' => 49.00, + 'total' => 49.00, + 'type' => 'product', + ]); + }); + + it('returns 400 for invalid JSON payload', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent') + ->andThrow(WebhookPayloadValidationException::invalidJson('btcpay', 'Syntax error')); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldNotReceive('fulfillOrder'); + + $webhookLogger = new WebhookLogger; + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $request->headers->set('BTCPay-Sig', 'valid_signature'); + + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getContent())->toContain('Invalid payload'); + + // Verify failed validation was logged + $webhookEvent = WebhookEvent::forGateway('btcpay') + ->where('event_type', 'validation_failed') + ->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_FAILED); + }); + + it('returns 400 for payload missing required fields', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent') + ->andThrow(WebhookPayloadValidationException::missingFields('btcpay', ['type'])); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getContent())->toContain('Missing required fields'); + }); + + it('returns 400 for payload with invalid field types', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent') + ->andThrow(WebhookPayloadValidationException::invalidFieldTypes('btcpay', [ + 'type' => ['expected' => 'string', 'actual' => 'integer'], + ])); + + $mockCommerce = Mockery::mock(CommerceService::class); + $webhookLogger = new WebhookLogger; + + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(400) + ->and($response->getContent())->toContain('Invalid field types'); + }); + + it('processes valid payload after validation passes', function () { + $mockGateway = Mockery::mock(BTCPayGateway::class); + $mockGateway->shouldReceive('verifyWebhookSignature')->andReturn(true); + $mockGateway->shouldReceive('parseWebhookEvent')->andReturn([ + 'type' => 'invoice.paid', + 'id' => 'btc_invoice_val_123', + 'status' => 'succeeded', + 'metadata' => [], + 'raw' => ['invoiceId' => 'btc_invoice_val_123'], + ]); + $mockGateway->shouldReceive('getCheckoutSession')->andReturn([ + 'id' => 'btc_invoice_val_123', + 'status' => 'succeeded', + 'amount' => 58.80, + 'currency' => 'GBP', + 'raw' => ['amount' => 58.80, 'currency' => 'GBP'], + ]); + + $mockCommerce = Mockery::mock(CommerceService::class); + $mockCommerce->shouldReceive('fulfillOrder')->once(); + + $webhookLogger = new WebhookLogger; + $controller = new BTCPayWebhookController($mockGateway, $mockCommerce, $webhookLogger); + + $request = new \Illuminate\Http\Request; + $response = $controller->handle($request); + + expect($response->getStatusCode())->toBe(200); + + // Verify webhook was processed successfully + $webhookEvent = WebhookEvent::forGateway('btcpay') + ->where('event_type', 'invoice.paid') + ->first(); + expect($webhookEvent)->not->toBeNull() + ->and($webhookEvent->status)->toBe(WebhookEvent::STATUS_PROCESSED); + }); +}); + // ============================================================================ // Security Tests - Idempotency and Amount Verification // ============================================================================