diff --git a/app/Http/Controllers/SNSController.php b/app/Http/Controllers/SNSController.php index e154680529..22282710f4 100644 --- a/app/Http/Controllers/SNSController.php +++ b/app/Http/Controllers/SNSController.php @@ -401,13 +401,19 @@ class SNSController extends BaseController */ private function handleSubscriptionConfirmation(array $snsData) { - $subscribeUrl = $snsData['SubscribeURL'] ?? null; + // Verify the subscription confirmation payload + $verificationResult = $this->verifySubscriptionConfirmationPayload($snsData); - if (!$subscribeUrl) { - nlog('SNS Subscription confirmation: Missing SubscribeURL'); - return response()->json(['error' => 'Missing SubscribeURL'], 400); + if (!$verificationResult['valid']) { + nlog('SNS Subscription confirmation: Payload verification failed', [ + 'errors' => $verificationResult['errors'], + 'payload' => $snsData + ]); + return response()->json(['error' => 'Invalid subscription confirmation payload', 'details' => $verificationResult['errors']], 400); } + $subscribeUrl = $snsData['SubscribeURL']; + nlog('SNS Subscription confirmation received', [ 'topic_arn' => $snsData['TopicArn'] ?? 'unknown', 'subscribe_url' => $subscribeUrl @@ -416,7 +422,7 @@ class SNSController extends BaseController // You can optionally make an HTTP request to confirm the subscription // This is required by AWS to complete the SNS subscription setup try { - $response = file_get_contents($subscribeUrl); + $response = \Illuminate\Support\Facades\Http::timeout(10)->get($subscribeUrl); nlog('SNS Subscription confirmed', ['response' => $response]); } catch (\Exception $e) { nlog('SNS Subscription confirmation failed', ['error' => $e->getMessage()]); @@ -425,6 +431,133 @@ class SNSController extends BaseController return response()->json(['status' => 'subscription_confirmed']); } + + /** + * Verify SNS subscription confirmation payload structure and content + * + * @param array $snsData + * @return array ['valid' => bool, 'errors' => array] + */ + private function verifySubscriptionConfirmationPayload(array $snsData): array + { + $errors = []; + + // Required fields for subscription confirmation + $requiredFields = [ + 'Type' => 'SubscriptionConfirmation', + 'MessageId' => 'string', + 'TopicArn' => 'string', + 'SubscribeURL' => 'string', + 'Timestamp' => 'string', + 'Token' => 'string' + ]; + + // Validate required fields exist and have correct types + foreach ($requiredFields as $field => $expectedType) { + if (!isset($snsData[$field])) { + $errors[] = "Missing required field: {$field}"; + continue; + } + + $value = $snsData[$field]; + + // Type-specific validation + if ($expectedType === 'string' && !is_string($value)) { + $errors[] = "Field '{$field}' must be a string"; + } elseif ($expectedType === 'SubscriptionConfirmation' && $value !== 'SubscriptionConfirmation') { + $errors[] = "Field '{$field}' must be 'SubscriptionConfirmation'"; + } + } + + // Validate specific field formats + if (isset($snsData['MessageId']) && !$this->isValidMessageId($snsData['MessageId'])) { + $errors[] = 'Invalid MessageId format'; + } + + if (isset($snsData['TopicArn']) && !$this->isValidTopicArn($snsData['TopicArn'])) { + $errors[] = 'Invalid TopicArn format'; + } + + if (isset($snsData['SubscribeURL']) && !$this->isValidSubscribeUrl($snsData['SubscribeURL'])) { + $errors[] = 'Invalid SubscribeURL format or domain'; + } + + if (isset($snsData['Timestamp']) && !$this->isValidISOTimestamp($snsData['Timestamp'])) { + $errors[] = 'Invalid Timestamp format (must be ISO 8601)'; + } + + if (isset($snsData['Token']) && !$this->isValidSubscriptionToken($snsData['Token'])) { + $errors[] = 'Invalid Token format'; + } + + // Validate TopicArn matches expected if configured + if (isset($snsData['TopicArn']) && !empty($this->expectedTopicArn)) { + if ($snsData['TopicArn'] !== $this->expectedTopicArn) { + $errors[] = 'TopicArn does not match expected value'; + } + } + + // Check for replay attacks (messages older than 15 minutes) + if (isset($snsData['Timestamp'])) { + $messageTimestamp = strtotime($snsData['Timestamp']); + $currentTimestamp = time(); + if (($currentTimestamp - $messageTimestamp) > 900) { // 15 minutes + $errors[] = 'Message timestamp is too old (potential replay attack)'; + } + } + + // Check for suspicious content patterns + if ($this->containsSuspiciousContent($snsData)) { + $errors[] = 'Payload contains suspicious content patterns'; + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors + ]; + } + + /** + * Validate MessageId format (should be a UUID-like string) + * + * @param string $messageId + * @return bool + */ + private function isValidMessageId(string $messageId): bool + { + // AWS SNS MessageId is typically a UUID format + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $messageId) === 1; + } + + /** + * Validate TopicArn format + * + * @param string $topicArn + * @return bool + */ + private function isValidTopicArn(string $topicArn): bool + { + // AWS SNS Topic ARN format: arn:aws:sns:region:account-id:topic-name + return preg_match('/^arn:aws:sns:[a-z0-9-]+:[0-9]{12}:[a-zA-Z0-9_-]+$/', $topicArn) === 1; + } + + /** + * Validate subscription token format + * + * @param string $token + * @return bool + */ + private function isValidSubscriptionToken(string $token): bool + { + // AWS SNS subscription tokens are typically long alphanumeric strings + if (strlen($token) < 20 || strlen($token) > 200) { + return false; + } + + // Should contain only alphanumeric characters and common symbols + return preg_match('/^[a-zA-Z0-9+\/=\-_]+$/', $token) === 1; + } + /** * Handle SES notification from SNS * diff --git a/routes/api.php b/routes/api.php index 980e4336d0..991400ae75 100644 --- a/routes/api.php +++ b/routes/api.php @@ -467,8 +467,8 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local }); -Route::post('api/v1/sms_reset', [TwilioController::class, 'generate2faResetCode'])->name('sms_reset.generate')->middleware('throttle:1,3'); -Route::post('api/v1/sms_reset/confirm', [TwilioController::class, 'confirm2faResetCode'])->name('sms_reset.confirm')->middleware('throttle:2,1'); +Route::post('api/v1/sms_reset', [TwilioController::class, 'generate2faResetCode'])->name('sms_reset.generate')->middleware('throttle:1,2'); +Route::post('api/v1/sms_reset/confirm', [TwilioController::class, 'confirm2faResetCode'])->name('sms_reset.confirm')->middleware('throttle:5,1'); Route::match(['get', 'post'], 'payment_webhook/{company_key}/{company_gateway_id}', PaymentWebhookController::class) ->middleware('throttle:1000,1')