MessageValidator.php 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. <?php
  2. namespace Aws\Sns;
  3. use Aws\Sns\Exception\InvalidSnsMessageException;
  4. /**
  5. * Uses openssl to verify SNS messages to ensure that they were sent by AWS.
  6. */
  7. class MessageValidator
  8. {
  9. const SIGNATURE_VERSION_1 = '1';
  10. /**
  11. * @var callable Callable used to download the certificate content.
  12. */
  13. private $certClient;
  14. /** @var string */
  15. private $hostPattern;
  16. /**
  17. * @var string A pattern that will match all regional SNS endpoints, e.g.:
  18. * - sns.<region>.amazonaws.com (AWS)
  19. * - sns.us-gov-west-1.amazonaws.com (AWS GovCloud)
  20. * - sns.cn-north-1.amazonaws.com.cn (AWS China)
  21. */
  22. private static $defaultHostPattern
  23. = '/^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/';
  24. private static function isLambdaStyle(Message $message)
  25. {
  26. return isset($message['SigningCertUrl']);
  27. }
  28. private static function convertLambdaMessage(Message $lambdaMessage)
  29. {
  30. $keyReplacements = [
  31. 'SigningCertUrl' => 'SigningCertURL',
  32. 'SubscribeUrl' => 'SubscribeURL',
  33. 'UnsubscribeUrl' => 'UnsubscribeURL',
  34. ];
  35. $message = clone $lambdaMessage;
  36. foreach ($keyReplacements as $lambdaKey => $canonicalKey) {
  37. if (isset($message[$lambdaKey])) {
  38. $message[$canonicalKey] = $message[$lambdaKey];
  39. unset($message[$lambdaKey]);
  40. }
  41. }
  42. return $message;
  43. }
  44. /**
  45. * Constructs the Message Validator object and ensures that openssl is
  46. * installed.
  47. *
  48. * @param callable $certClient Callable used to download the certificate.
  49. * Should have the following function signature:
  50. * `function (string $certUrl) : string|false $certContent`
  51. * @param string $hostNamePattern
  52. */
  53. public function __construct(
  54. callable $certClient = null,
  55. $hostNamePattern = ''
  56. ) {
  57. $this->certClient = $certClient ?: function($certUrl) {
  58. return @ file_get_contents($certUrl);
  59. };
  60. $this->hostPattern = $hostNamePattern ?: self::$defaultHostPattern;
  61. }
  62. /**
  63. * Validates a message from SNS to ensure that it was delivered by AWS.
  64. *
  65. * @param Message $message Message to validate.
  66. *
  67. * @throws InvalidSnsMessageException If the cert cannot be retrieved or its
  68. * source verified, or the message
  69. * signature is invalid.
  70. */
  71. public function validate(Message $message)
  72. {
  73. if (self::isLambdaStyle($message)) {
  74. $message = self::convertLambdaMessage($message);
  75. }
  76. // Get the certificate.
  77. $this->validateUrl($message['SigningCertURL']);
  78. $certificate = call_user_func($this->certClient, $message['SigningCertURL']);
  79. if ($certificate === false) {
  80. throw new InvalidSnsMessageException(
  81. "Cannot get the certificate from \"{$message['SigningCertURL']}\"."
  82. );
  83. }
  84. // Extract the public key.
  85. $key = openssl_get_publickey($certificate);
  86. if (!$key) {
  87. throw new InvalidSnsMessageException(
  88. 'Cannot get the public key from the certificate.'
  89. );
  90. }
  91. // Verify the signature of the message.
  92. $content = $this->getStringToSign($message);
  93. $signature = base64_decode($message['Signature']);
  94. if (openssl_verify($content, $signature, $key, OPENSSL_ALGO_SHA1) != 1) {
  95. throw new InvalidSnsMessageException(
  96. 'The message signature is invalid.'
  97. );
  98. }
  99. }
  100. /**
  101. * Determines if a message is valid and that is was delivered by AWS. This
  102. * method does not throw exceptions and returns a simple boolean value.
  103. *
  104. * @param Message $message The message to validate
  105. *
  106. * @return bool
  107. */
  108. public function isValid(Message $message)
  109. {
  110. try {
  111. $this->validate($message);
  112. return true;
  113. } catch (InvalidSnsMessageException $e) {
  114. return false;
  115. }
  116. }
  117. /**
  118. * Builds string-to-sign according to the SNS message spec.
  119. *
  120. * @param Message $message Message for which to build the string-to-sign.
  121. *
  122. * @return string
  123. * @link http://docs.aws.amazon.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
  124. */
  125. public function getStringToSign(Message $message)
  126. {
  127. static $signableKeys = [
  128. 'Message',
  129. 'MessageId',
  130. 'Subject',
  131. 'SubscribeURL',
  132. 'Timestamp',
  133. 'Token',
  134. 'TopicArn',
  135. 'Type',
  136. ];
  137. if ($message['SignatureVersion'] !== self::SIGNATURE_VERSION_1) {
  138. throw new InvalidSnsMessageException(
  139. "The SignatureVersion \"{$message['SignatureVersion']}\" is not supported."
  140. );
  141. }
  142. $stringToSign = '';
  143. foreach ($signableKeys as $key) {
  144. if (isset($message[$key])) {
  145. $stringToSign .= "{$key}\n{$message[$key]}\n";
  146. }
  147. }
  148. return $stringToSign;
  149. }
  150. /**
  151. * Ensures that the URL of the certificate is one belonging to AWS, and not
  152. * just something from the amazonaws domain, which could include S3 buckets.
  153. *
  154. * @param string $url Certificate URL
  155. *
  156. * @throws InvalidSnsMessageException if the cert url is invalid.
  157. */
  158. private function validateUrl($url)
  159. {
  160. $parsed = parse_url($url);
  161. if (empty($parsed['scheme'])
  162. || empty($parsed['host'])
  163. || $parsed['scheme'] !== 'https'
  164. || substr($url, -4) !== '.pem'
  165. || !preg_match($this->hostPattern, $parsed['host'])
  166. ) {
  167. throw new InvalidSnsMessageException(
  168. 'The certificate is located on an invalid domain.'
  169. );
  170. }
  171. }
  172. }