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:
parent
2e5cd499b9
commit
c169f4161f
11 changed files with 1340 additions and 28 deletions
1
Boot.php
1
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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
115
Exceptions/WebhookPayloadValidationException.php
Normal file
115
Exceptions/WebhookPayloadValidationException.php
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
231
Services/WebhookRateLimiter.php
Normal file
231
Services/WebhookRateLimiter.php
Normal 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
12
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.
|
||||
|
|
|
|||
60
config.php
60
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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
299
tests/Feature/WebhookRateLimitTest.php
Normal file
299
tests/Feature/WebhookRateLimitTest.php
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue