123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- <?php
- namespace Aws\Sns;
- use Aws\Sns\Exception\InvalidSnsMessageException;
- /**
- * Uses openssl to verify SNS messages to ensure that they were sent by AWS.
- */
- class MessageValidator
- {
- const SIGNATURE_VERSION_1 = '1';
- const SIGNATURE_VERSION_2 = '2';
- /**
- * @var callable Callable used to download the certificate content.
- */
- private $certClient;
- /** @var string */
- private $hostPattern;
- /**
- * @var string A pattern that will match all regional SNS endpoints, e.g.:
- * - sns.<region>.amazonaws.com (AWS)
- * - sns.us-gov-west-1.amazonaws.com (AWS GovCloud)
- * - sns.cn-north-1.amazonaws.com.cn (AWS China)
- */
- private static $defaultHostPattern
- = '/^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/';
- private static function isLambdaStyle(Message $message)
- {
- return isset($message['SigningCertUrl']);
- }
- private static function convertLambdaMessage(Message $lambdaMessage)
- {
- $keyReplacements = [
- 'SigningCertUrl' => 'SigningCertURL',
- 'SubscribeUrl' => 'SubscribeURL',
- 'UnsubscribeUrl' => 'UnsubscribeURL',
- ];
- $message = clone $lambdaMessage;
- foreach ($keyReplacements as $lambdaKey => $canonicalKey) {
- if (isset($message[$lambdaKey])) {
- $message[$canonicalKey] = $message[$lambdaKey];
- unset($message[$lambdaKey]);
- }
- }
- return $message;
- }
- /**
- * Constructs the Message Validator object and ensures that openssl is
- * installed.
- *
- * @param callable $certClient Callable used to download the certificate.
- * Should have the following function signature:
- * `function (string $certUrl) : string|false $certContent`
- * @param string $hostNamePattern
- */
- public function __construct(
- callable $certClient = null,
- $hostNamePattern = ''
- ) {
- $this->certClient = $certClient ?: function($certUrl) {
- return @ file_get_contents($certUrl);
- };
- $this->hostPattern = $hostNamePattern ?: self::$defaultHostPattern;
- }
- /**
- * Validates a message from SNS to ensure that it was delivered by AWS.
- *
- * @param Message $message Message to validate.
- *
- * @throws InvalidSnsMessageException If the cert cannot be retrieved or its
- * source verified, or the message
- * signature is invalid.
- */
- public function validate(Message $message)
- {
- if (self::isLambdaStyle($message)) {
- $message = self::convertLambdaMessage($message);
- }
- // Get the certificate.
- $this->validateUrl($message['SigningCertURL']);
- $certificate = call_user_func($this->certClient, $message['SigningCertURL']);
- if ($certificate === false) {
- throw new InvalidSnsMessageException(
- "Cannot get the certificate from \"{$message['SigningCertURL']}\"."
- );
- }
- // Extract the public key.
- $key = openssl_get_publickey($certificate);
- if (!$key) {
- throw new InvalidSnsMessageException(
- 'Cannot get the public key from the certificate.'
- );
- }
- // Verify the signature of the message.
- $content = $this->getStringToSign($message);
- $signature = base64_decode($message['Signature']);
- $algo = ($message['SignatureVersion'] === self::SIGNATURE_VERSION_1 ? OPENSSL_ALGO_SHA1 : OPENSSL_ALGO_SHA256);
- if (openssl_verify($content, $signature, $key, $algo) !== 1) {
- throw new InvalidSnsMessageException(
- 'The message signature is invalid.'
- );
- }
- }
- /**
- * Determines if a message is valid and that is was delivered by AWS. This
- * method does not throw exceptions and returns a simple boolean value.
- *
- * @param Message $message The message to validate
- *
- * @return bool
- */
- public function isValid(Message $message)
- {
- try {
- $this->validate($message);
- return true;
- } catch (InvalidSnsMessageException $e) {
- return false;
- }
- }
- /**
- * Builds string-to-sign according to the SNS message spec.
- *
- * @param Message $message Message for which to build the string-to-sign.
- *
- * @return string
- * @link http://docs.aws.amazon.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
- */
- public function getStringToSign(Message $message)
- {
- static $signableKeys = [
- 'Message',
- 'MessageId',
- 'Subject',
- 'SubscribeURL',
- 'Timestamp',
- 'Token',
- 'TopicArn',
- 'Type',
- ];
- if ($message['SignatureVersion'] !== self::SIGNATURE_VERSION_1
- && $message['SignatureVersion'] !== self::SIGNATURE_VERSION_2) {
- throw new InvalidSnsMessageException(
- "The SignatureVersion \"{$message['SignatureVersion']}\" is not supported."
- );
- }
- $stringToSign = '';
- foreach ($signableKeys as $key) {
- if (isset($message[$key])) {
- $stringToSign .= "{$key}\n{$message[$key]}\n";
- }
- }
- return $stringToSign;
- }
- /**
- * Ensures that the URL of the certificate is one belonging to AWS, and not
- * just something from the amazonaws domain, which could include S3 buckets.
- *
- * @param string $url Certificate URL
- *
- * @throws InvalidSnsMessageException if the cert url is invalid.
- */
- private function validateUrl($url)
- {
- $parsed = parse_url($url);
- if (empty($parsed['scheme'])
- || empty($parsed['host'])
- || $parsed['scheme'] !== 'https'
- || substr($url, -4) !== '.pem'
- || !preg_match($this->hostPattern, $parsed['host'])
- ) {
- throw new InvalidSnsMessageException(
- 'The certificate is located on an invalid domain.'
- );
- }
- }
- }
|