Verify SNS messages delivered via HTTP(S) in Node.js

Michael Wittig – 14 Aug 2019

Are you implementing an HTTP/HTTPS endpoint for SNS? If so, you should definetly verify the incoming messages. Otherwise, anyone on the Internet can deliver messages to your HTTP/HTTPS endpoint. Which is a security risk.

Code

How do you verify incoming messages? The SNS documentation answers this question:

You should verify the authenticity of a notification, subscription confirmation, or unsubscribe confirmation message sent by Amazon SNS.

In a nutshell, each SNS message contains a signature that we have to verify.

Verify SNS message: check the signature

The npm module sns-validator does the job. Unfortunately, the module is old and lacks support for save caching and certificate download retries. Therefore, I decided to implement this on my own, which wasn’t as hard as expected. Let’s get started.

First, you need to install a few dependencies:

  • request and requestretry to perform HTTP(S) requests with retries
  • lru-cache to safely cache certificates without running out of memory

Install the modules with:


Looking for a new challenge?

  • tecRacer

    Cloud Consultant

    tecRacer • Premier AWS Consulting Partner • Germany, Austria, Spain, and Switzerland
    AWS only Infrastructure as Code EC2 Containers Serverless
  • tecRacer

    Cloud Migration Specialist

    tecRacer • Premier AWS Consulting Partner • Germany, Austria, Spain, and Switzerland
    Lift&Shift Transformation EC2 RDS VPC

npm i request requestretry lru-cache

Create a new JavaScript file (e.g., index.js) and import the dependencies we need:

const crypto = require('crypto');
const requestretry = require('requestretry');
const LRU = require('lru-cache');

According to the SNS documentation, we have to use different fields of the message based on the Type of the message delivered by SNS.

function fieldsForSignature(type) {
if (type === 'SubscriptionConfirmation' || type === 'UnsubscribeConfirmation') {
return ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'];
} else if (type === 'Notification') {
return ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'];
} else {
return [];
}
}

We also have to come up with a way to download the certificate that we need to verify the signature. The certificate is attached to the message in the form of a URL. We have to download the certificate before we can verify the signature. Downloading things can fail for many reasons. Therefore, we retry failed download requests. To optimize for performance, we also want to cache downloaded certificates. Let’s look at the code.

const CERT_CACHE = new LRU({max: 5000, maxAge: 1000 * 60});

function fetchCert(certUrl, cb) {
const cachedCertificate = CERT_CACHE.get(certUrl);
if (cachedCertificate !== undefined) {
cb(null, cachedCertificate);
} else {
requestretry({
method: 'GET',
url: certUrl,
maxAttempts: 3,
retryDelay: 100,
timeout: 3000
}, (err, res, certificate) => {
if (err) {
cb(err);
} else {
if (res.statusCode === 200) {
CERT_CACHE.set(certUrl, certificate);
cb(null, certificate);
} else {
cb(new Error(`expected 200 status code, received: ${res.statusCode}`));
}
}
});
}
}

The cache stores a maximum of 5000 certificates and the certificates expire after 1 minute from the cache.

Last but not least, we do some input validation:

  • the fields SignatureVersion, SigningCertURL, Type, and Signature must be available
  • SignatureVersion must be 1
  • the SigningCertURL must start with https:// and we only want to download certificates from AWS
const CERT_URL_PATTERN = /^https:\/\/sns\.[a-zA-Z0-9-]{3,}\.amazonaws\.com(\.cn)?\/SimpleNotificationService-[a-zA-Z0-9]{32}\.pem$/;

function validate(message, cb) {
if (!('SignatureVersion' in message && 'SigningCertURL' in message && 'Type' in message && 'Signature' in message)) {
console.log('missing field');
cb(null, false);
} else if (message.SignatureVersion !== '1') {
console.log('invalid SignatureVersion');
cb(null, false);
} else if (!CERT_URL_PATTERN.test(message.SigningCertURL)) {
console.log('invalid certificate URL');
cb(null, false);
} else {
fetchCert(message.SigningCertURL, (err, certificate) => {
if (err) {
cb(err);
} else {
// TODO verifiy signature (insert next code block here)
}
});
}
}

Finally, the signature is verified.

const verify = crypto.createVerify('sha1WithRSAEncryption');
fieldsForSignature(message.Type).forEach(key => {
if (key in message) {
verify.write(`${key}\n${message[key]}\n`);
}
});
verify.end();
const result = verify.verify(certificate, message.Signature, 'base64');
cb(null, result);

You can test the code with a message like this:

validate({
Type: 'Notification',
MessageId: '4c807a89-9ef9-543b-bfab-2f4ed41e91b4',
TopicArn: 'arn:aws:sns:us-east-1:853553028582:marbot-dev-alert-Topic-8CT7ZJRNSA5Y',
Subject: 'INSUFFICIENT_DATA: "insufficient test" in US East (N. Virginia)',
Message: '{"AlarmName":"insufficient test","AlarmDescription":null,"AWSAccountId":"853553028582","NewStateValue":"INSUFFICIENT_DATA","NewStateReason":"tets","StateChangeTime":"2019-08-09T10:19:19.614+0000","Region":"US East (N. Virginia)","OldStateValue":"OK","Trigger":{"MetricName":"CallCount2","Namespace":"AWS/Usage","StatisticType":"Statistic","Statistic":"AVERAGE","Unit":null,"Dimensions":[{"value":"API","name":"Type"},{"value":"PutMetricData","name":"Resource"},{"value":"CloudWatch","name":"Service"},{"value":"None","name":"Class"}],"Period":300,"EvaluationPeriods":1,"ComparisonOperator":"GreaterThanThreshold","Threshold":1.0,"TreatMissingData":"- TreatMissingData: missing","EvaluateLowSampleCountPercentile":""}}',
Timestamp: '2019-08-09T10:19:19.644Z',
SignatureVersion: '1',
Signature: 'gnCKAUYX6YlBW3dkOmrSFvdB6r82Q2He+7uZV9072sdCP0DSaR46ka/4ymSdDfqilqxjJ9hajd9l7j8ZsL98vYdUbut/1IJ2hsuALF9nd/HwNLPPWvKXaK/Y3Hp57izOpeBAkuR6koitSbXX50lEj7FraaMVQfpexm01z7IUcx4vCCvZBTdQLbkWw+TYWkWNsMrqarW39zy474SmTBCSZlz1eoV6tCwYk2Z2G2awiXpnfsQRRZvHn4ot176oY+ADAFJ0sIa44effQXq+tAWE6/Z3M5rjtfg6OULDM+NGEmnVZL3xyWK8bIzB48ZclQo3ZsvLPGmCNQLlFpaP/3fGGg==',
SigningCertURL: 'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-6aad65c2f9911b05cd53efda11f913f9.pem',
UnsubscribeURL: 'https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:853553028582:marbot-dev-alert-Topic-8CT7ZJRNSA5Y:86a160f0-c3c5-4ae1-ae50-2903eede0af1'
}, (err, result) => {
if (err) {
console.log(err);
} else {
console.log('result', result);
}
});

Summary

You should verify the authenticity of a message sent by Amazon SNS. The SNS documentation provides an in-depth description of the needed steps which can be implemented in Node.js as shown in this blog post.

Become a cloudonaut supporter

Michael Wittig

Michael Wittig ( Email, Twitter, or LinkedIn )

We launched the cloudonaut blog in 2015. Since then, we have published 345 articles, 45 podcast episodes, and 37 videos. It's all free and means a lot of work in our spare time. We enjoy sharing our AWS knowledge with you.

Please support us

Have you learned something new by reading, listening, or watching our content? With your help, we can spend enough time to keep publishing great content in the future. Learn more

$
Amount must be a multriply of 5. E.g, 5, 10, 15.

Thanks to Alan Leech, Alex DeBrie, ANTHONY RAITI, Jaap-Jan Frans, Jason Yorty, Jeff Finley, Jens Gehring, jhoadley, Johannes Grumböck, John Culkin, Jonas Mellquist, Juraj Martinka, Kamil Oboril, Ken Snyder, Ross Mohan, Ross Mohan, sam onaga, Shawn Tolidano, Thorsten Hoeger, Todd Valentine, and all anonymous supporters for your help! We also want to thank all supporters who purchased a cloudonaut t-shirt.