Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ if ($message['Type'] === 'SubscriptionConfirmation') {
}
```

After `validate()` succeeds, Lambda-style URL keys such as `SubscribeUrl` are
normalized to their canonical SNS spelling, such as `SubscribeURL`, on the
`Message` object.

* Before requesting `SubscribeURL`, verify that it uses HTTPS and that its host
matches the expected regional SNS endpoint, such as
`sns.<region>.amazonaws.com`.

### Receiving a Notification

To receive a notification, use the same code as the preceding example, but
Expand Down
27 changes: 27 additions & 0 deletions src/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ class Message implements \ArrayAccess, \IteratorAggregate
'Token'
];

private static $keyAliases = [
['SigningCertURL', 'SigningCertUrl'],
['SubscribeURL', 'SubscribeUrl'],
['UnsubscribeURL', 'UnsubscribeUrl'],
];

/** @var array The message data */
private $data;

Expand Down Expand Up @@ -81,6 +87,8 @@ public static function fromJsonString($requestBody)
*/
public function __construct(array $data)
{
$this->validateKeyAliases($data);

// Ensure that all the required keys for the message's type are present.
$this->validateRequiredKeys($data, self::$requiredKeys);
if ($data['Type'] === 'SubscriptionConfirmation'
Expand Down Expand Up @@ -132,6 +140,25 @@ public function toArray()
return $this->data;
}

private function validateKeyAliases(array $data)
{
foreach (self::$keyAliases as $keyAliases) {
$present = [];
foreach ($keyAliases as $keyAlias) {
if (array_key_exists($keyAlias, $data)) {
$present[] = $keyAlias;
}
}

if (count($present) > 1) {
throw new \InvalidArgumentException(sprintf(
'The SNS message contains multiple spellings of the same field: %s.',
implode(', ', $present)
));
}
}
}

private function validateRequiredKeys(array $data, array $keys)
{
foreach ($keys as $key) {
Expand Down
48 changes: 34 additions & 14 deletions src/MessageValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,28 @@ class MessageValidator

private static function isLambdaStyle(Message $message)
{
return isset($message['SigningCertUrl']);
$messageData = $message->toArray();

return array_key_exists('SigningCertUrl', $messageData)
|| array_key_exists('SubscribeUrl', $messageData)
|| array_key_exists('UnsubscribeUrl', $messageData);
}

private static function convertLambdaMessage(Message $lambdaMessage)
private static function convertLambdaMessage(Message $message)
{
$messageData = $message->toArray();
$keyReplacements = [
'SigningCertUrl' => 'SigningCertURL',
'SubscribeUrl' => 'SubscribeURL',
'UnsubscribeUrl' => 'UnsubscribeURL',
];

$message = clone $lambdaMessage;
foreach ($keyReplacements as $lambdaKey => $canonicalKey) {
if (isset($message[$lambdaKey])) {
if (array_key_exists($lambdaKey, $messageData)) {
$message[$canonicalKey] = $message[$lambdaKey];
unset($message[$lambdaKey]);
}
}

return $message;
}

/**
Expand All @@ -74,6 +76,9 @@ public function __construct(
/**
* Validates a message from SNS to ensure that it was delivered by AWS.
*
* Lambda-style URL keys are canonicalized on the provided message after
* successful validation.
*
* @param Message $message Message to validate.
*
* @throws InvalidSnsMessageException If the cert cannot be retrieved or its
Expand All @@ -82,16 +87,22 @@ public function __construct(
*/
public function validate(Message $message)
{
$messageToValidate = $message;
if (self::isLambdaStyle($message)) {
$message = self::convertLambdaMessage($message);
$messageToValidate = clone $message;
self::convertLambdaMessage($messageToValidate);
}

// Get the certificate.
$this->validateUrl($message['SigningCertURL']);
$certificate = call_user_func($this->certClient, $message['SigningCertURL']);
$certUrl = $messageToValidate['SigningCertURL'];
$this->validateUrl($certUrl);
$certificate = call_user_func(
$this->certClient,
$certUrl
);
if ($certificate === false) {
throw new InvalidSnsMessageException(
"Cannot get the certificate from \"{$message['SigningCertURL']}\"."
"Cannot get the certificate from \"{$certUrl}\"."
);
}

Expand All @@ -104,28 +115,37 @@ public function validate(Message $message)
}

// 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);
$content = $this->getStringToSign($messageToValidate);
$signature = base64_decode($messageToValidate['Signature']);
$algo = $messageToValidate['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.'
);
}

if ($messageToValidate !== $message) {
self::convertLambdaMessage($message);
}
}

/**
* 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.
*
* The provided message is not modified.
*
* @param Message $message The message to validate
*
* @return bool
*/
public function isValid(Message $message)
{
try {
$this->validate($message);
$this->validate(clone $message);
return true;
} catch (InvalidSnsMessageException $e) {
return false;
Expand Down
47 changes: 47 additions & 0 deletions tests/MessageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,39 @@ public function testRequiresTokenAndSubscribeUrlForUnsubscribeMessage()
);
}

public function testRejectsMessageWithBothSigningCertKeySpellings()
{
$this->assertDuplicateAliasesRejected(
['SigningCertUrl' => 'alternate'] + $this->messageData,
'SigningCertURL',
'SigningCertUrl'
);
}

public function testRejectsMessageWithBothSubscribeKeySpellings()
{
$this->assertDuplicateAliasesRejected(
[
'Type' => 'SubscriptionConfirmation',
'SubscribeUrl' => 'alternate',
] + $this->messageData,
'SubscribeURL',
'SubscribeUrl'
);
}

public function testRejectsMessageWithBothUnsubscribeKeySpellings()
{
$this->assertDuplicateAliasesRejected(
[
'UnsubscribeURL' => 'canonical',
'UnsubscribeUrl' => 'alternate',
] + $this->messageData,
'UnsubscribeURL',
'UnsubscribeUrl'
);
}

public function testCanCreateFromRawPost()
{
$_SERVER['HTTP_X_AMZ_SNS_MESSAGE_TYPE'] = 'Notification';
Expand Down Expand Up @@ -167,4 +200,18 @@ public function testArrayAccess()
unset($message['foo']);
$this->assertFalse(isset($message['foo']));
}

private function assertDuplicateAliasesRejected(
array $messageData,
$canonicalKey,
$alternateKey
) {
try {
new Message($messageData);
$this->fail('Expected duplicate field spellings to be rejected.');
} catch (\InvalidArgumentException $e) {
$this->assertNotFalse(strpos($e->getMessage(), $canonicalKey));
$this->assertNotFalse(strpos($e->getMessage(), $alternateKey));
}
}
}
Loading
Loading