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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-29 18:11:02 +00:00
parent 2e5cd499b9
commit c169f4161f
11 changed files with 1340 additions and 28 deletions

View file

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

View file

@ -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);
}
// 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);

View file

@ -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');

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Exceptions;
use Exception;
/**
* Exception thrown when a webhook payload fails validation.
*
* This security measure prevents malformed or malicious payloads from
* causing unexpected behaviour in webhook handlers.
*/
class WebhookPayloadValidationException extends Exception
{
/**
* Create a new webhook payload validation exception.
*
* @param string $message The validation error message
* @param string $gateway The payment gateway identifier
* @param array<string, mixed> $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<string, mixed>
*/
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<string> $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<string, array{expected: string, actual: string}> $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<string, string> $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]
);
}
}

View file

@ -1,15 +1,18 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services\PaymentGateway;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Core\Mod\Commerce\Exceptions\WebhookPayloadValidationException;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Mod\Commerce\Models\PaymentMethod;
use Core\Mod\Commerce\Models\Subscription;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* BTCPay Server payment gateway implementation.
@ -344,24 +347,46 @@ class BTCPayGateway implements PaymentGatewayContract
return true;
}
/**
* Parse and validate a webhook event payload.
*
* @throws WebhookPayloadValidationException When the payload is malformed or invalid
*/
public function parseWebhookEvent(string $payload): array
{
// Step 1: Validate JSON structure
$data = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$error = json_last_error_msg();
Log::warning('BTCPay webhook: Invalid JSON payload', [
'error' => 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<string, mixed> $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<string, mixed> $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<string, mixed> $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

View file

@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
/**
* Rate limiter for webhook endpoints.
*
* Provides IP-based rate limiting to prevent abuse of webhook endpoints.
* Supports different limits for known gateway IPs vs unknown sources.
*
* Security considerations:
* - Webhook endpoints are public (no auth) but use signature verification
* - A malicious actor could exhaust rate limits, blocking legitimate webhooks
* - Trusted gateway IPs get higher limits to prevent this attack vector
* - Per-IP limiting ensures one abuser doesn't affect others
*/
class WebhookRateLimiter
{
/**
* Default maximum webhook requests per IP per minute.
*/
private const DEFAULT_MAX_ATTEMPTS = 60;
/**
* Maximum requests for trusted gateway IPs per minute.
* Higher limit since these are legitimate payment processor requests.
*/
private const TRUSTED_MAX_ATTEMPTS = 300;
/**
* Window duration in seconds (1 minute).
*/
private const DECAY_SECONDS = 60;
public function __construct(
protected readonly RateLimiter $limiter
) {}
/**
* Check if the IP has exceeded webhook rate limits.
*/
public function tooManyAttempts(Request $request, string $gateway): bool
{
$key = $this->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<string>
*/
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}";
}
}

12
TODO.md
View file

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

View file

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

View file

@ -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'])

View file

@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
use Illuminate\Cache\RateLimiter;
use Illuminate\Http\Request;
use Core\Mod\Commerce\Services\WebhookRateLimiter;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
// ============================================================================
// WebhookRateLimiter Unit Tests
// ============================================================================
describe('WebhookRateLimiter', function () {
beforeEach(function () {
// Clear rate limiter cache before each test
app(RateLimiter::class)->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);
}
});
});

View file

@ -1,8 +1,8 @@
<?php
use Illuminate\Support\Facades\Notification;
use Core\Mod\Commerce\Controllers\Webhooks\BTCPayWebhookController;
use Core\Mod\Commerce\Controllers\Webhooks\StripeWebhookController;
use WebhookPayloadValidationException;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\OrderItem;
use Core\Mod\Commerce\Models\Payment;
@ -20,6 +20,7 @@ use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspacePackage;
use Core\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Notification;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
@ -944,15 +945,12 @@ describe('BTCPayGateway webhook event parsing', function () {
->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
// ============================================================================