MessageValidator.php 6.2 KB

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