diff --git a/Controllers/Webhooks/StripeWebhookController.php b/Controllers/Webhooks/StripeWebhookController.php index a9f45db..024c219 100644 --- a/Controllers/Webhooks/StripeWebhookController.php +++ b/Controllers/Webhooks/StripeWebhookController.php @@ -21,6 +21,7 @@ use Core\Mod\Commerce\Notifications\OrderConfirmation; use Core\Mod\Commerce\Notifications\PaymentFailed; use Core\Mod\Commerce\Notifications\SubscriptionCancelled; use Core\Mod\Commerce\Services\CommerceService; +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; @@ -44,6 +45,7 @@ class StripeWebhookController extends Controller protected InvoiceService $invoiceService, protected EntitlementService $entitlements, protected WebhookLogger $webhookLogger, + protected FraudService $fraudService, ) {} public function handle(Request $request): Response @@ -92,6 +94,8 @@ class StripeWebhookController extends Controller 'payment_method.detached' => $this->handlePaymentMethodDetached($event), 'payment_method.updated' => $this->handlePaymentMethodUpdated($event), 'setup_intent.succeeded' => $this->handleSetupIntentSucceeded($event), + 'charge.succeeded' => $this->handleChargeSucceeded($event), + 'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event), default => $this->handleUnknownEvent($event), }; }); @@ -540,4 +544,91 @@ class StripeWebhookController extends Controller $owner->notify(new OrderConfirmation($order)); } } + + /** + * Handle charge.succeeded event for Stripe Radar fraud scoring. + * + * Processes the Radar outcome from successful charges to update payment + * records with fraud assessment data. + */ + protected function handleChargeSucceeded(array $event): Response + { + $charge = $event['raw']['data']['object']; + $paymentIntentId = $charge['payment_intent'] ?? null; + + // Extract Stripe Radar outcome if present + $outcome = $charge['outcome'] ?? null; + if (! $outcome) { + return response('OK (no Radar data)', 200); + } + + // Find the payment record + $payment = null; + if ($paymentIntentId) { + $payment = Payment::where('gateway', 'stripe') + ->where('gateway_payment_id', $paymentIntentId) + ->first(); + } + + if (! $payment) { + // Try finding by charge ID + $payment = Payment::where('gateway', 'stripe') + ->where('gateway_payment_id', $charge['id']) + ->first(); + } + + if ($payment) { + // Process Stripe Radar outcome and store on payment + $this->fraudService->processStripeRadarOutcome($payment, $outcome); + + Log::info('Stripe Radar outcome processed', [ + 'payment_id' => $payment->id, + 'risk_level' => $outcome['risk_level'] ?? 'unknown', + 'risk_score' => $outcome['risk_score'] ?? null, + ]); + } + + return response('OK', 200); + } + + /** + * Handle payment_intent.succeeded event for Stripe Radar fraud scoring. + * + * This is an alternative entry point for Radar data when charge events + * are not received. + */ + protected function handlePaymentIntentSucceeded(array $event): Response + { + $paymentIntent = $event['raw']['data']['object']; + + // Check for charges array (contains Radar outcome) + $charges = $paymentIntent['charges']['data'] ?? []; + if (empty($charges)) { + return response('OK (no charges)', 200); + } + + // Process the first charge's Radar outcome + $charge = $charges[0]; + $outcome = $charge['outcome'] ?? null; + if (! $outcome) { + return response('OK (no Radar data)', 200); + } + + // Find the payment record + $payment = Payment::where('gateway', 'stripe') + ->where('gateway_payment_id', $paymentIntent['id']) + ->first(); + + if ($payment) { + // Process Stripe Radar outcome + $this->fraudService->processStripeRadarOutcome($payment, $outcome); + + Log::info('Stripe Radar outcome processed (from payment_intent)', [ + 'payment_id' => $payment->id, + 'risk_level' => $outcome['risk_level'] ?? 'unknown', + ]); + } + + return response('OK', 200); + } } diff --git a/Exceptions/FraudBlockedException.php b/Exceptions/FraudBlockedException.php new file mode 100644 index 0000000..f09fb18 --- /dev/null +++ b/Exceptions/FraudBlockedException.php @@ -0,0 +1,59 @@ +assessment; + } + + /** + * Get the risk level from the assessment. + */ + public function getRiskLevel(): string + { + return $this->assessment->riskLevel; + } + + /** + * Get the fraud signals that were detected. + */ + public function getSignals(): array + { + return $this->assessment->signals; + } +} diff --git a/Services/CommerceService.php b/Services/CommerceService.php index b4ec897..6f0537a 100644 --- a/Services/CommerceService.php +++ b/Services/CommerceService.php @@ -1,13 +1,13 @@ enforceCheckoutRateLimit($order, $request); + + // Perform pre-checkout fraud assessment (velocity checks, geo-anomaly detection) + $fraudAssessment = $this->assessOrderFraud($order); + $gateway = $gateway ?? $this->getDefaultGateway(); $successUrl = $successUrl ?? route('checkout.success', ['order' => $order->order_number]); $cancelUrl = $cancelUrl ?? route('checkout.cancel', ['order' => $order->order_number]); @@ -186,10 +212,16 @@ class CommerceService $this->ensureCustomer($order->orderable, $gateway); } - // Update order with gateway info + // Update order with gateway info and fraud assessment + $metadata = $order->metadata ?? []; + if ($fraudAssessment->wasAssessed()) { + $metadata['fraud_assessment'] = $fraudAssessment->toArray(); + } + $order->update([ 'gateway' => $gateway, 'status' => 'processing', + 'metadata' => $metadata, ]); // Create checkout session @@ -199,11 +231,88 @@ class CommerceService 'gateway_session_id' => $session['session_id'], ]); - return [ + $result = [ 'order' => $order->fresh(), 'session_id' => $session['session_id'], 'checkout_url' => $session['checkout_url'], ]; + + // Include fraud assessment in response for logging/monitoring + if ($fraudAssessment->wasAssessed()) { + $result['fraud_assessment'] = $fraudAssessment; + } + + return $result; + } + + /** + * Assess order for fraud before checkout. + * + * Performs velocity checks and geo-anomaly detection. If the fraud risk + * is too high, the order will be blocked and an exception thrown. + * + * @throws FraudBlockedException When order should be blocked due to fraud risk + */ + protected function assessOrderFraud(Order $order): FraudAssessment + { + $assessment = $this->fraudService->assessOrder($order); + + // Block the order if fraud risk is too high + if ($assessment->shouldBlock) { + Log::warning('Order blocked due to fraud risk', [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'risk_level' => $assessment->riskLevel, + 'signals' => $assessment->signals, + ]); + + // Mark order as failed with fraud reason + $order->markAsFailed('Blocked due to suspected fraud'); + + throw new FraudBlockedException( + 'This order could not be processed. Please contact support if you believe this is an error.', + $assessment + ); + } + + // Log elevated risk orders for review + if ($assessment->shouldReview) { + Log::info('Order flagged for fraud review', [ + 'order_id' => $order->id, + 'order_number' => $order->order_number, + 'risk_level' => $assessment->riskLevel, + 'signals' => $assessment->signals, + ]); + } + + return $assessment; + } + + /** + * Enforce checkout rate limiting. + * + * @throws CheckoutRateLimitException When rate limit is exceeded + */ + protected function enforceCheckoutRateLimit(Order $order, ?Request $request = null): void + { + $request = $request ?? request(); + + // Extract identifiers from order + $workspaceId = $order->orderable instanceof Workspace ? $order->orderable->id : null; + $userId = $order->user_id; + + // Check rate limit + if ($this->rateLimiter->tooManyAttempts($workspaceId, $userId, $request)) { + $availableIn = $this->rateLimiter->availableIn($workspaceId, $userId, $request); + + throw new CheckoutRateLimitException( + 'Too many checkout attempts. Please wait before trying again.', + $availableIn + ); + } + + // Increment the rate limiter counter + $this->rateLimiter->increment($workspaceId, $userId, $request); } /** diff --git a/Services/CouponService.php b/Services/CouponService.php index 5585ee3..6a94f2b 100644 --- a/Services/CouponService.php +++ b/Services/CouponService.php @@ -1,27 +1,99 @@ first(); + $sanitised = $this->sanitiseCode($code); + + if ($sanitised === null) { + return null; + } + + return Coupon::byCode($sanitised)->first(); + } + + /** + * Sanitise and validate a coupon code. + * + * Performs the following transformations and validations: + * - Trims whitespace + * - Converts to uppercase (normalisation) + * - Enforces length limits (3-50 characters) + * - Validates allowed characters (alphanumeric, hyphens, underscores) + * + * @param string $code The raw coupon code input + * @return string|null The sanitised code, or null if invalid + */ + public function sanitiseCode(string $code): ?string + { + // Trim whitespace and convert to uppercase + $sanitised = strtoupper(trim($code)); + + // Check length constraints + $length = strlen($sanitised); + if ($length < self::MIN_CODE_LENGTH || $length > self::MAX_CODE_LENGTH) { + return null; + } + + // Validate allowed characters (alphanumeric, hyphens, underscores only) + if (! preg_match(self::VALID_CODE_PATTERN, $sanitised)) { + return null; + } + + return $sanitised; + } + + /** + * Check if a coupon code format is valid without looking it up. + * + * Useful for early validation before database queries. + */ + public function isValidCodeFormat(string $code): bool + { + return $this->sanitiseCode($code) !== null; } /** @@ -74,10 +146,20 @@ class CouponService /** * Validate a coupon by code. + * + * Sanitises the code before validation. Returns an invalid result + * if the code format is invalid or the coupon doesn't exist. */ public function validateByCode(string $code, Workspace $workspace, ?Package $package = null): CouponValidationResult { - $coupon = $this->findByCode($code); + // Sanitise the code first - reject invalid formats early + $sanitised = $this->sanitiseCode($code); + + if ($sanitised === null) { + return CouponValidationResult::invalid('Invalid coupon code format'); + } + + $coupon = Coupon::byCode($sanitised)->first(); if (! $coupon) { return CouponValidationResult::invalid('Invalid coupon code'); diff --git a/TODO.md b/TODO.md index f1530d6..3045c77 100644 --- a/TODO.md +++ b/TODO.md @@ -22,13 +22,13 @@ Production-quality task list for the commerce module. - [x] **Add currency mismatch detection** - ~~If gateway returns different currency than order, this could result in incorrect fulfillment.~~ **FIXED:** The `verifyPaymentAmount()` method now validates currency matches. Orders with currency mismatch are marked as failed with "Currency mismatch: received X, expected Y" message. -- [ ] **Rate limit checkout session creation** - `CheckoutRateLimiter` exists but isn't applied in `CommerceService::createCheckout()`. Card testing attacks could abuse this endpoint. +- [x] **Rate limit checkout session creation** - ~~`CheckoutRateLimiter` exists but isn't applied in `CommerceService::createCheckout()`. Card testing attacks could abuse this endpoint.~~ **FIXED:** `CheckoutRateLimiter` is already integrated into `CommerceService::createCheckout()` via the `enforceCheckoutRateLimit()` method. Limits are 5 attempts per 15-minute window per workspace/user/IP. `CheckoutRateLimitException` thrown when exceeded. -- [ ] **Add fraud scoring integration** - No fraud detection for suspicious patterns (multiple failed payments, velocity checks, geo-anomalies). Consider Stripe Radar integration for Stripe gateway. +- [x] **Add fraud scoring integration** - ~~No fraud detection for suspicious patterns (multiple failed payments, velocity checks, geo-anomalies). Consider Stripe Radar integration for Stripe gateway.~~ **FIXED (2026-01-29):** Integrated `FraudService` into checkout flow. Pre-checkout assessment performs velocity checks (orders per IP/email, failed payments per workspace) and geo-anomaly detection (country mismatch, high-risk countries). Post-payment Stripe Radar outcomes are processed via `charge.succeeded` and `payment_intent.succeeded` webhooks. High-risk orders are blocked with `FraudBlockedException`. Elevated-risk orders are flagged for review. Fraud assessments stored in order/payment metadata. ### Input Validation -- [ ] **Sanitise user-provided coupon codes** - `CouponService::validateByCode()` uses raw input. Add length limits, character validation, and normalisation (uppercase) before DB query. +- [x] **Sanitise user-provided coupon codes** - ~~`CouponService::validateByCode()` uses raw input. Add length limits, character validation, and normalisation (uppercase) before DB query.~~ **FIXED (2026-01-29):** Added `sanitiseCode()` method to `CouponService` that enforces: 1) Length limits (3-50 characters) 2) Character validation (alphanumeric, hyphens, underscores only) 3) Uppercase normalisation. Both `findByCode()` and `validateByCode()` now sanitise input before database queries. Invalid format codes return null/invalid result early without hitting the database. - [ ] **Validate billing address components** - `Order::create()` accepts `billing_address` array without validating structure. Malformed addresses could cause PDF generation issues or tax calculation failures. @@ -191,6 +191,23 @@ Production-quality task list for the commerce module. ## Completed +### 2026-01-29 - Payment Security & Input Validation (P1-040, P1-041, P1-042) + +- **Rate limit checkout session creation (P1-040)** - Verified `CheckoutRateLimiter` integration in `CommerceService::createCheckout()`. Rate limits of 5 attempts per 15-minute window protect against card testing attacks. + +- **Add fraud scoring integration (P1-041)** - Integrated `FraudService` into checkout and webhook flows: + - Pre-checkout: Velocity checks (IP/email order limits, failed payment tracking), geo-anomaly detection (country mismatch, high-risk countries) + - Post-payment: Stripe Radar outcome processing via `charge.succeeded` and `payment_intent.succeeded` webhooks + - Risk levels: normal, elevated (review), highest (block) + - New `FraudBlockedException` for blocked orders + - Fraud assessments stored in order/payment metadata for audit + +- **Sanitise coupon codes (P1-042)** - Added `CouponService::sanitiseCode()` with: + - Length limits: 3-50 characters + - Character validation: alphanumeric, hyphens, underscores only + - Uppercase normalisation + - Early rejection of invalid formats before database queries + ### 2026-01-29 - Webhook Security Fixes - **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.