invoiceninja/app/Http/Controllers/SNSController.php

793 lines
27 KiB
PHP

<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Jobs\SES\SESWebhook;
use App\Jobs\PostMark\ProcessPostmarkWebhook;
use App\Libraries\MultiDB;
use App\Services\InboundMail\InboundMail;
use App\Services\InboundMail\InboundMailEngine;
use App\Utils\TempFile;
use Illuminate\Support\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Class SNSController.
*
* Handles Amazon SNS webhook notifications that contain SES email event data.
* SNS acts as an intermediary between SES and your application.
*/
class SNSController extends BaseController
{
/**
* Expected SNS Topic ARN for validation
*/
private string $expectedTopicArn;
public function __construct()
{
$this->expectedTopicArn = config('services.ses.topic_arn');
}
/**
* Handle SNS webhook notifications
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function webhook(Request $request)
{
try {
// Get the raw request body for SNS signature verification
$payload = $request->getContent();
$headers = $request->headers->all();
// Parse the SNS payload
$snsData = json_decode($payload, true);
if (!$snsData) {
nlog('SNS Webhook: Invalid JSON payload');
return response()->json(['error' => 'Invalid JSON payload'], 400);
}
// Verify SNS signature for security (skip for subscription confirmation)
$snsMessageType = $headers['x-amz-sns-message-type'][0] ?? null;
if ($snsMessageType === 'Notification') {
$signatureValid = $this->verifySNSSignature($request, $payload);
if (!$signatureValid) {
nlog('SNS Webhook: Invalid signature - potential security threat');
return response()->json(['error' => 'Invalid signature'], 401);
}
}
if (!$snsMessageType) {
nlog('SNS Webhook: Missing x-amz-sns-message-type header');
return response()->json(['error' => 'Missing SNS message type'], 400);
}
// Handle SNS subscription confirmation
if ($snsMessageType === 'SubscriptionConfirmation') {
return $this->handleSubscriptionConfirmation($snsData);
}
// Handle SNS notification (contains SES data)
if ($snsMessageType === 'Notification') {
return $this->handleSESNotification($snsData);
}
// Handle unsubscribe confirmation
if ($snsMessageType === 'UnsubscribeConfirmation') {
nlog('SNS Unsubscribe confirmation received', ['topic_arn' => $snsData['TopicArn'] ?? 'unknown']);
return response()->json(['status' => 'unsubscribe_confirmed']);
}
return response()->json(['error' => 'Unknown message type'], 400);
} catch (\Exception $e) {
nlog('SNS Webhook: Error processing request', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return response()->json(['error' => 'Internal server error'], 500);
}
}
/**
* Verify SNS message signature using full AWS SNS validation
*
* @param Request $request
* @param string $payload
* @return bool
*/
private function verifySNSSignature(Request $request, string $payload): bool
{
try {
// Parse the SNS message
$snsData = json_decode($payload, true);
if (!$snsData) {
nlog('SNS: Invalid JSON payload for signature verification');
return false;
}
// Check required SNS fields
$requiredFields = ['Type', 'MessageId', 'TopicArn', 'Timestamp', 'SigningCertURL'];
foreach ($requiredFields as $field) {
if (!isset($snsData[$field])) {
nlog('SNS: Missing required field for signature verification', ['field' => $field]);
return false;
}
}
// Validate Topic ARN if configured
if (!empty($this->expectedTopicArn) && $snsData['TopicArn'] !== $this->expectedTopicArn) {
return false;
} elseif (!empty($this->expectedTopicArn)) {
}
// Check for replay attacks (messages older than 15 minutes)
$messageTimestamp = strtotime($snsData['Timestamp']);
$currentTimestamp = time();
if (($currentTimestamp - $messageTimestamp) > 900) { // 15 minutes
return false;
}
// Get the signing certificate
$certificate = $this->fetchSigningCertificate($snsData['SigningCertURL']);
if (!$certificate) {
return false;
}
// Verify the signature
$signatureValid = $this->verifyMessageSignature($snsData, $certificate, $payload);
return $signatureValid;
} catch (\Exception $e) {
nlog('SNS: Error during signature verification', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Fallback to basic validation if full verification fails
return $this->fallbackBasicValidation($request, $payload);
}
}
/**
* Fallback basic validation when full signature verification fails
*
* @param Request $request
* @param string $payload
* @return bool
*/
private function fallbackBasicValidation(Request $request, string $payload): bool
{
try {
// Basic checks
$requiredHeaders = [
'x-amz-sns-message-type',
'x-amz-sns-message-id',
'x-amz-sns-topic-arn'
];
foreach ($requiredHeaders as $header) {
if (!$request->header($header)) {
nlog('SNS: Missing required header for basic validation', ['header' => $header]);
return false;
}
}
// Check if the payload contains valid AWS SNS structure
$snsData = json_decode($payload, true);
if (!isset($snsData['Type']) || !isset($snsData['MessageId']) || !isset($snsData['TopicArn'])) {
return false;
}
return true;
} catch (\Exception $e) {
return false;
}
}
/**
* Fetch and cache the SNS signing certificate
*
* @param string $certUrl
* @return string|false
*/
private function fetchSigningCertificate(string $certUrl): string|false
{
// Validate the certificate URL is from AWS
if (!$this->isValidAWSCertificateUrl($certUrl)) {
nlog('SNS: Invalid certificate URL', ['url' => $certUrl]);
return false;
}
// Check cache first
$cacheKey = 'sns_cert_' . md5($certUrl);
$cachedCert = Cache::get($cacheKey);
if ($cachedCert) {
nlog('SNS: Using cached certificate');
return $cachedCert;
}
try {
// Fetch the certificate
$response = Http::timeout(10)->get($certUrl);
if ($response->successful()) {
$certificate = $response->body();
// Validate certificate format
if ($this->isValidCertificate($certificate)) {
// Cache for 24 hours (AWS certificates are long-lived)
Cache::put($cacheKey, $certificate, 86400);
nlog('SNS: Certificate fetched and cached successfully');
return $certificate;
} else {
nlog('SNS: Invalid certificate format received');
return false;
}
} else {
nlog('SNS: Failed to fetch certificate', [
'status' => $response->status(),
'body' => $response->body()
]);
return false;
}
} catch (\Exception $e) {
nlog('SNS: Error fetching certificate', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Verify the message signature using the certificate
*
* @param array $snsData
* @param string $certificate
* @param string $payload
* @return bool
*/
private function verifyMessageSignature(array $snsData, string $certificate, string $payload): bool
{
try {
// Extract the signature
$signature = $snsData['Signature'] ?? '';
if (empty($signature)) {
nlog('SNS: Missing signature in message');
return false;
}
// Create the string to sign
$stringToSign = $this->createStringToSign($snsData);
// Decode the signature
$decodedSignature = base64_decode($signature, true);
if ($decodedSignature === false || $decodedSignature === '') {
nlog('SNS: Invalid signature encoding');
return false;
}
// Verify using OpenSSL
$publicKey = openssl_pkey_get_public($certificate);
if ($publicKey === false) {
nlog('SNS: Failed to load public key from certificate');
return false;
}
$verificationResult = openssl_verify(
$stringToSign,
$decodedSignature,
$publicKey,
OPENSSL_ALGO_SHA1
);
openssl_free_key($publicKey);
if ($verificationResult === 1) {
nlog('SNS: Signature verification successful');
return true;
} elseif ($verificationResult === 0) {
nlog('SNS: Signature verification failed');
return false;
} else {
nlog('SNS: Error during signature verification');
return false;
}
} catch (\Exception $e) {
nlog('SNS: Error during signature verification', ['error' => $e->getMessage()]);
return false;
}
}
/**
* Create the string to sign according to AWS SNS specification
*
* @param array $snsData
* @return string
*/
private function createStringToSign(array $snsData): string
{
$fields = [
'Message',
'MessageId',
'Subject',
'Timestamp',
'TopicArn',
'Type'
];
$stringToSign = '';
foreach ($fields as $field) {
if (isset($snsData[$field])) {
$stringToSign .= $field . "\n" . $snsData[$field] . "\n";
}
}
return $stringToSign;
}
/**
* Validate that the certificate URL is from AWS
*
* @param string $url
* @return bool
*/
private function isValidAWSCertificateUrl(string $url): bool
{
$parsedUrl = parse_url($url);
if (!$parsedUrl || !isset($parsedUrl['host'])) {
return false;
}
// Check if it's an AWS domain
$validDomains = [
'sns.us-east-1.amazonaws.com',
'sns.us-east-2.amazonaws.com',
'sns.us-west-1.amazonaws.com',
'sns.us-west-2.amazonaws.com',
'sns.eu-west-1.amazonaws.com',
'sns.eu-central-1.amazonaws.com',
'sns.ap-southeast-1.amazonaws.com',
'sns.ap-southeast-2.amazonaws.com',
'sns.ap-northeast-1.amazonaws.com',
'sns.sa-east-1.amazonaws.com'
];
return in_array($parsedUrl['host'], $validDomains);
}
/**
* Validate certificate format
*
* @param string $certificate
* @return bool
*/
private function isValidCertificate(string $certificate): bool
{
// Check if it looks like a valid X.509 certificate
return strpos($certificate, '-----BEGIN CERTIFICATE-----') !== false &&
strpos($certificate, '-----END CERTIFICATE-----') !== false &&
strlen($certificate) > 1000; // Reasonable minimum size
}
/**
* Handle SNS subscription confirmation
*
* @param array $snsData
* @return \Illuminate\Http\JsonResponse
*/
private function handleSubscriptionConfirmation(array $snsData)
{
$subscribeUrl = $snsData['SubscribeURL'] ?? null;
if (!$subscribeUrl) {
nlog('SNS Subscription confirmation: Missing SubscribeURL');
return response()->json(['error' => 'Missing SubscribeURL'], 400);
}
nlog('SNS Subscription confirmation received', [
'topic_arn' => $snsData['TopicArn'] ?? 'unknown',
'subscribe_url' => $subscribeUrl
]);
// 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);
nlog('SNS Subscription confirmed', ['response' => $response]);
} catch (\Exception $e) {
nlog('SNS Subscription confirmation failed', ['error' => $e->getMessage()]);
}
return response()->json(['status' => 'subscription_confirmed']);
}
/**
* Handle SES notification from SNS
*
* @param array $snsData
* @return \Illuminate\Http\JsonResponse
*/
private function handleSESNotification(array $snsData)
{
$message = $snsData['Message'] ?? null;
if (!$message) {
nlog('SNS Notification: Missing Message content');
return response()->json(['error' => 'Missing Message content'], 400);
}
// Parse the SES message (it's JSON encoded as a string)
$sesData = json_decode($message, true);
if (!$sesData) {
nlog('SNS Notification: Invalid SES message format');
return response()->json(['error' => 'Invalid SES message format'], 400);
}
// Extract company key from SES data early
$companyKey = $this->extractCompanyKeyFromSES($sesData);
if (!$companyKey) {
nlog('SNS Notification: No company key found in SES data', [
'ses_data' => $sesData
]);
return response()->json(['error' => 'No company key found'], 400);
}
// Resolve the company and get their specific SES topic ARN if configured
$this->resolveCompany($companyKey);
// Validate the SES payload structure
$validationResult = $this->validateSESPayload($sesData);
if (!$validationResult['valid']) {
nlog('SNS Notification: SES payload validation failed', [
'errors' => $validationResult['errors'],
'payload' => $sesData,
'company_key' => $companyKey
]);
return response()->json(['error' => 'Invalid SES payload', 'details' => $validationResult['errors']], 400);
}
// Dispatch the SES webhook job for processing
SESWebhook::dispatch($sesData);
return response()->json([],200);
}
/**
* Validate SES payload structure and required fields
*
* @param array $sesData
* @return array ['valid' => bool, 'errors' => array]
*/
private function validateSESPayload(array $sesData): array
{
$errors = [];
// Check if required top-level fields exist
if (!isset($sesData['mail'])) {
$errors[] = 'Missing required field: mail';
}
if (!isset($sesData['eventType']) && !isset($sesData['notificationType'])) {
$errors[] = 'Missing required field: eventType or notificationType';
}
// Validate mail object structure
if (isset($sesData['mail'])) {
$mail = $sesData['mail'];
if (!isset($mail['messageId'])) {
$errors[] = 'Missing required field: mail.messageId';
}
if (!isset($mail['timestamp'])) {
$errors[] = 'Missing required field: mail.timestamp';
}
if (!isset($mail['source'])) {
$errors[] = 'Missing required field: mail.source';
}
if (!isset($mail['destination']) || !is_array($mail['destination']) || empty($mail['destination'])) {
$errors[] = 'Missing or invalid field: mail.destination';
}
// Validate headers structure if present
if (isset($mail['headers']) && !is_array($mail['headers'])) {
$errors[] = 'Invalid field: mail.headers must be an array';
}
// Validate commonHeaders structure if present
if (isset($mail['commonHeaders']) && !is_array($mail['commonHeaders'])) {
$errors[] = 'Invalid field: mail.commonHeaders must be an array';
}
}
// Validate event-specific data based on event type
$eventType = $sesData['eventType'] ?? $sesData['notificationType'] ?? '';
switch (strtolower($eventType)) {
case 'delivery':
if (!isset($sesData['delivery'])) {
$errors[] = 'Missing required field: delivery for delivery event';
} else {
$delivery = $sesData['delivery'];
if (!isset($delivery['timestamp'])) {
$errors[] = 'Missing required field: delivery.timestamp';
}
if (!isset($delivery['recipients']) || !is_array($delivery['recipients'])) {
$errors[] = 'Missing or invalid field: delivery.recipients';
}
}
break;
case 'bounce':
if (!isset($sesData['bounce'])) {
$errors[] = 'Missing required field: bounce for bounce event';
} else {
$bounce = $sesData['bounce'];
if (!isset($bounce['timestamp'])) {
$errors[] = 'Missing required field: bounce.timestamp';
}
if (!isset($bounce['bounceType'])) {
$errors[] = 'Missing required field: bounce.bounceType';
}
if (!isset($bounce['bouncedRecipients']) || !is_array($bounce['bouncedRecipients'])) {
$errors[] = 'Missing or invalid field: bounce.bouncedRecipients';
}
}
break;
case 'complaint':
if (!isset($sesData['complaint'])) {
$errors[] = 'Missing required field: complaint for complaint event';
} else {
$complaint = $sesData['complaint'];
if (!isset($complaint['timestamp'])) {
$errors[] = 'Missing required field: complaint.timestamp';
}
if (!isset($complaint['complainedRecipients']) || !is_array($complaint['complainedRecipients'])) {
$errors[] = 'Missing or invalid field: complaint.complainedRecipients';
}
}
break;
case 'open':
// Open events might not have additional data beyond the mail object
break;
default:
if (!empty($eventType)) {
$errors[] = "Unknown event type: {$eventType}";
}
break;
}
// Validate timestamp format if present
if (isset($sesData['mail']['timestamp'])) {
$timestamp = $sesData['mail']['timestamp'];
if (!$this->isValidISOTimestamp($timestamp)) {
$errors[] = 'Invalid timestamp format: mail.timestamp must be ISO 8601 format';
}
}
// Validate messageId format (should be a valid string)
if (isset($sesData['mail']['messageId'])) {
$messageId = $sesData['mail']['messageId'];
if (!is_string($messageId) || strlen(trim($messageId)) === 0) {
$errors[] = 'Invalid messageId: must be a non-empty string';
}
}
// Check for suspicious patterns
if ($this->containsSuspiciousContent($sesData)) {
$errors[] = 'Payload contains suspicious content patterns';
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
/**
* Check if timestamp is in valid ISO 8601 format
*
* @param string $timestamp
* @return bool
*/
private function isValidISOTimestamp(string $timestamp): bool
{
try {
$date = new \DateTime($timestamp);
return $date !== false;
} catch (\Exception $e) {
return false;
}
}
/**
* Check for suspicious content patterns in the payload
*
* @param array $sesData
* @return bool
*/
private function containsSuspiciousContent(array $sesData): bool
{
$suspiciousPatterns = [
'javascript:',
'data:text/html',
'vbscript:',
'onload=',
'onerror=',
'onclick=',
'<script',
'<?php',
'eval(',
'document.cookie'
];
$payloadString = json_encode($sesData);
foreach ($suspiciousPatterns as $pattern) {
if (stripos($payloadString, $pattern) !== false) {
nlog('SNS: Suspicious content pattern detected', ['pattern' => $pattern]);
return true;
}
}
return false;
}
/**
* Extract company key from SES data
*
* @param array $sesData
* @return string|null
*/
private function extractCompanyKeyFromSES(array $sesData): ?string
{
// Check various possible locations for company key in SES data
// Check mail tags
if (isset($sesData['mail']['tags']['company_key'])) {
$companyKey = $sesData['mail']['tags']['company_key'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found company key in mail tags', ['value' => $companyKey]);
return $companyKey;
}
}
// Check custom headers - specifically X-Tag which contains the company key
if (isset($sesData['mail']['headers'])) {
nlog('SNS: Checking mail headers for X-Tag', [
'headers_count' => count($sesData['mail']['headers']),
'headers' => $sesData['mail']['headers']
]);
foreach ($sesData['mail']['headers'] as $header) {
if (isset($header['name']) && $header['name'] === 'X-Tag' && isset($header['value'])) {
$companyKey = $header['value'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found X-Tag header', ['value' => $companyKey]);
return $companyKey;
}
}
}
nlog('SNS: X-Tag header not found in mail headers');
}
// Check if company key is in the main SES data
if (isset($sesData['company_key'])) {
$companyKey = $sesData['company_key'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found company key in main SES data', ['value' => $companyKey]);
return $companyKey;
}
}
// Check bounce data
if (isset($sesData['bounce']) && isset($sesData['bounce']['tags']['company_key'])) {
$companyKey = $sesData['bounce']['tags']['company_key'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found company key in bounce tags', ['value' => $companyKey]);
return $companyKey;
}
}
// Check complaint data
if (isset($sesData['complaint']) && isset($sesData['complaint']['tags']['company_key'])) {
$companyKey = $sesData['complaint']['tags']['company_key'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found company key in complaint tags', ['value' => $companyKey]);
return $companyKey;
}
}
// Check delivery data
if (isset($sesData['delivery']) && isset($sesData['delivery']['tags']['company_key'])) {
$companyKey = $sesData['delivery']['tags']['company_key'];
if ($this->isValidCompanyKey($companyKey)) {
nlog('SNS: Found company key in delivery tags', ['value' => $companyKey]);
return $companyKey;
}
}
return null;
}
/**
* Validate company key format
*
* @param string $companyKey
* @return bool
*/
private function isValidCompanyKey(string $companyKey): bool
{
// Company key should be a non-empty string
if (empty(trim($companyKey))) {
return false;
}
// Company key should be a reasonable length (Invoice Ninja uses 32 character keys)
if (strlen($companyKey) < 10 || strlen($companyKey) > 100) {
return false;
}
// Company key should only contain alphanumeric characters and common symbols
if (!preg_match('/^[a-zA-Z0-9\-_\.]+$/', $companyKey)) {
return false;
}
return true;
}
private function resolveCompany(string $companyKey): void
{
try {
MultiDB::findAndSetDbByCompanyKey($companyKey);
// Use MultiDB to find the company
$company = \App\Models\Company::where('company_key', $companyKey)->first();
if($company && $company->settings->email_sending_method === 'client_ses' && strlen($company->settings->ses_topic_arn ?? '') > 2) {
$this->expectedTopicArn = $company->settings->ses_topic_arn;
}
} catch (\Exception $e) {
}
}
}