heroic-sns 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +15 -0
- data/LICENSE +13 -0
- data/README.md +133 -0
- data/lib/heroic/sns.rb +20 -0
- data/lib/heroic/sns/endpoint.rb +136 -0
- data/lib/heroic/sns/message.rb +149 -0
- data/test/helper.rb +34 -0
- data/test/test_endpoint.rb +47 -0
- data/test/test_message.rb +63 -0
- metadata +77 -0
checksums.yaml
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: !binary |-
|
4
|
+
ODJhMzlkZTE4ODA5ZWE2ODdmMGNmOTM0M2IxYTFkNWYwMzBlMjVjYw==
|
5
|
+
data.tar.gz: !binary |-
|
6
|
+
MzEyN2NhN2IyMDMzMjQ0NGZjZGJjNzRlOWZiYWNjNzIxMzI2OGRjNA==
|
7
|
+
!binary "U0hBNTEy":
|
8
|
+
metadata.gz: !binary |-
|
9
|
+
OTNmZTE4MGI0YTVjZWM4MTYxNjdmNGFlM2I1OGI1YzZiMmUyM2FhNzdmNzk5
|
10
|
+
YmE4MThiMjNmYmMxZGFjMDBkZDg0N2VhNDVhYzlhMzBjMDU1MjUyOTg3ZGU5
|
11
|
+
NzI1MGIzNTExNzM2MjZiYjU1ZmI2YTYyODNiZjM2OGU3NzcwZmM=
|
12
|
+
data.tar.gz: !binary |-
|
13
|
+
YjJmNmJhZjg1YzFkZDhjODM2YmQ2ZDQ2MWNjZmM5MDRkYjk5ZjYyYmNiY2E2
|
14
|
+
NmQ5ZTczZmE5OTMwYWQyMzhmZWQwZTAwNTNhZGVmOTZlYjIxMjRkODdlMjlm
|
15
|
+
NTUxMmI5ODc0ZWY2YzE0MzEwMmQ5YTA0ODNmYTIzMThjM2IwMjY=
|
data/LICENSE
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
Copyright 2013 Heroic Software Inc. All Rights Reserved.
|
2
|
+
|
3
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License.
|
5
|
+
You may obtain a copy of the License at
|
6
|
+
|
7
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
8
|
+
|
9
|
+
Unless required by applicable law or agreed to in writing, software
|
10
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
11
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
12
|
+
See the License for the specific language governing permissions and
|
13
|
+
limitations under the License.
|
data/README.md
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
# Heroic SNS Endpoint
|
2
|
+
|
3
|
+
This gem contains a lightweight Rack middleware for AWS Simple Notification
|
4
|
+
Service (SNS) endpoints.
|
5
|
+
|
6
|
+
SNS messages to your web application are intercepted, parsed, verified, and then
|
7
|
+
passed along to your Rack application in the `sns.message` environment key.
|
8
|
+
|
9
|
+
If something goes wrong, the error will be passed along in the `sns.error`
|
10
|
+
environment key. `Endpoint` does not log any messages itself.
|
11
|
+
|
12
|
+
This gem *does not* depend on [aws-sdk][]. In fact, the only dependency it has
|
13
|
+
beyond the standard libraries is [rack][]. This is intentional, since AWS
|
14
|
+
credentials are not required to receive SNS notifications.
|
15
|
+
|
16
|
+
[aws-sdk]: https://github.com/aws/aws-sdk-ruby
|
17
|
+
[rack]: http://rack.github.io/
|
18
|
+
|
19
|
+
## Overview
|
20
|
+
|
21
|
+
1. Install in your middleware stack
|
22
|
+
2. Get SNS messages from `env['sns.message']`
|
23
|
+
3. Get errors from `env['sns.error']`
|
24
|
+
|
25
|
+
## How to use it
|
26
|
+
|
27
|
+
Simply add the following to your `config.ru` file:
|
28
|
+
|
29
|
+
use Heroic::SNS::Endpoint, :topics => /:aws-ses-bounces$/
|
30
|
+
|
31
|
+
For Rails, you could also install it in `/config/initializers/sns_endpoint.rb`:
|
32
|
+
|
33
|
+
Rails.application.config.middleware.use Heroic::SNS::Endpoint, :topic => ...
|
34
|
+
|
35
|
+
The Endpoint class takes an options hash as an argument, and understands these
|
36
|
+
options:
|
37
|
+
|
38
|
+
`:topic` is required, and provides a filter that defines what SNS topics are
|
39
|
+
handled by this endpoint. A message is considered either "on-topic" or
|
40
|
+
"off-topic". You can supply any of the following:
|
41
|
+
|
42
|
+
- a single topic ARN as a `String`
|
43
|
+
- a list of topic ARNs as an `Array` of `String`
|
44
|
+
- a `RegExp` which matches on-topic ARNs
|
45
|
+
- a `Proc` which accepts an ARN as an argument and returns `true` or `false` for
|
46
|
+
on-topic and off-topic ARNs, respectively.
|
47
|
+
|
48
|
+
The key `topics:` also works.
|
49
|
+
|
50
|
+
`:auto_confirm` affects how on-topic subscription confirmations are handled.
|
51
|
+
|
52
|
+
- If `true`, they are confirmed by retrieving the URL in the `SubscribeURL` field
|
53
|
+
of the SNS message, and your app is not notified.
|
54
|
+
- If `false`, they are ignored; your app is also not notified.
|
55
|
+
- If `nil`, there is no special handling and the message is passed along to your
|
56
|
+
app.
|
57
|
+
|
58
|
+
The default is `true`.
|
59
|
+
|
60
|
+
`:auto_resubscribe` affects how on-topic unsubscribe confirmations are handled.
|
61
|
+
|
62
|
+
- If `true`, they topic is automatically re-subscribed by retrieving the URL in
|
63
|
+
the `SubscribeURL` field of the SNS message, and your app is not notified.
|
64
|
+
- If `false`, they are ignored and your app is also not notified.
|
65
|
+
- If `nil`, there is no special handling and the message is passed along to your
|
66
|
+
app.
|
67
|
+
|
68
|
+
The default is `false`.
|
69
|
+
|
70
|
+
If you are a control-freak and want no special handling whatsoever, use these
|
71
|
+
options:
|
72
|
+
|
73
|
+
use Heroic::SNS::Endpoint, :topics => Proc.new { true }, :auto_confirm => nil, :auto_resubscribe => nil
|
74
|
+
|
75
|
+
Then the object will simply parse and verify SNS messages it finds and pass them
|
76
|
+
along to your app, taking no action.
|
77
|
+
|
78
|
+
Once the middleware is set up, any notifications will be made available in your
|
79
|
+
Rack environment under the `sns.message` key. If you are using Rails, your
|
80
|
+
controller would have a method like this:
|
81
|
+
|
82
|
+
skip_before_filter :verify_authenticity_token, :only => [:handle_notification]
|
83
|
+
|
84
|
+
def handle_notification
|
85
|
+
if message = request.env['sns.message']
|
86
|
+
# message is an instance of Heroic::SNS::Message
|
87
|
+
payload = JSON.parse(message.body)
|
88
|
+
do_something_awesome(payload)
|
89
|
+
elsif error = request.env['sns.error']
|
90
|
+
raise error # let the warning be logged
|
91
|
+
end
|
92
|
+
head :ok
|
93
|
+
end
|
94
|
+
|
95
|
+
You must skip the authenticity token verification to allow Amazon to POST to the
|
96
|
+
controller action. Be careful not to disable it for more actions than you need.
|
97
|
+
Be sure to disable any authentication checks for that action, too.
|
98
|
+
|
99
|
+
## How off-topic notifications are handled
|
100
|
+
|
101
|
+
As a security measure, `Endpoint` requires you to set up a topic filter. Any
|
102
|
+
notifications that do not match this filter are not passed along to your
|
103
|
+
application.
|
104
|
+
|
105
|
+
All off-topic messages are ignored with one exception: if the message is a
|
106
|
+
regular notification (meaning your app has an active subscription) *and* the
|
107
|
+
message can be verified as authentic (by checking its signature), `Endpoint`
|
108
|
+
will cancel the subscription by visiting the URL in the `UnsubscribeURL` field
|
109
|
+
of the message.
|
110
|
+
|
111
|
+
If you would rather make decision about on-topic and off-topic notifications in
|
112
|
+
your own code, simply pass `Proc.new { true }` as the topic filter, and all
|
113
|
+
messages will be treated as on topic. Be aware that it is dangerous to leave
|
114
|
+
`:auto_confirm` enabled with a permissive topic filter, as this will allow
|
115
|
+
anyone to subscribe your web app to any SNS notification.
|
116
|
+
|
117
|
+
## Receiving multiple notification topics
|
118
|
+
|
119
|
+
If you are receiving multiple notifications at multiple endpoint URLs, you should
|
120
|
+
only include one instance of the Endpoint in your middleware stack, and ensure
|
121
|
+
that its topic filter allows all the notifications you are interested in to pass
|
122
|
+
through.
|
123
|
+
|
124
|
+
`Endpoint` does not interact with the URL path at all; if you want your
|
125
|
+
subscriptions to go to different URLs, simply set them up that way.
|
126
|
+
|
127
|
+
## Questions?
|
128
|
+
|
129
|
+
You can send me an email at <ben@benzado.com> or Twitter [@benzado][]
|
130
|
+
|
131
|
+
[@benzado]: https://twitter.com/benzado
|
132
|
+
|
133
|
+
[](https://travis-ci.org/benzado/heroic-sns)
|
data/lib/heroic/sns.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
require 'heroic/sns/message'
|
2
|
+
require 'heroic/sns/endpoint'
|
3
|
+
|
4
|
+
module Heroic
|
5
|
+
module SNS
|
6
|
+
|
7
|
+
class Error < ::StandardError
|
8
|
+
|
9
|
+
# The message that triggered this error, if available.
|
10
|
+
attr_reader :sns_message
|
11
|
+
|
12
|
+
def initialize(error_message, sns_message = nil)
|
13
|
+
super(error_message)
|
14
|
+
@sns_message = sns_message
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
# Heroic::SNS::Endpoint is Rack middleware which intercepts messages from
|
5
|
+
# Amazon's Simple Notification Service (SNS). It makes the parsed and verified
|
6
|
+
# message available to your application in the Rack environment under the
|
7
|
+
# 'sns.message' key. If an error occurred during message handling, the error
|
8
|
+
# is available in the Rack environment in the 'sns.error' key.
|
9
|
+
|
10
|
+
# Endpoint is to be initialized with a hash of options. It understands three
|
11
|
+
# different options:
|
12
|
+
# - :topic (or :topics) specifies a filter that defines what SNS topics are
|
13
|
+
# handled by this endpoint ("on-topic"). You can supply any of the following:
|
14
|
+
# - a topic ARN as a String
|
15
|
+
# - a list of topic ARNs as an Array of Strings
|
16
|
+
# - a regular expression matching on-topic ARNs
|
17
|
+
# - a Proc which takes a topic ARN as a String and returns true or false.
|
18
|
+
# You must specify a topic filter. Use Proc.new { true } if you insist on
|
19
|
+
# indiscriminately accepting all notifications.
|
20
|
+
# - :auto_confirm determines how SubscriptionConfirmation messages are handled.
|
21
|
+
# If true, the subscription is confirmed and your app is not notified.
|
22
|
+
# If false, the subscription is ignored and your app is not notified.
|
23
|
+
# If nil, the message is passed along to your app.
|
24
|
+
|
25
|
+
# You can install this in your config.ru:
|
26
|
+
# use Heroic::SNS::Endpoint, :topics => /whatever/
|
27
|
+
|
28
|
+
# For Rails, you can also install it in /config/initializers/sns_endpoint.rb:
|
29
|
+
# Rails.application.config.middleware.use Heroic::SNS::Endpoint, :topic => ...
|
30
|
+
|
31
|
+
module Heroic
|
32
|
+
module SNS
|
33
|
+
|
34
|
+
SUBSCRIPTION_ARN_HTTP_HEADER = 'HTTP_X_AMZ_SNS_SUBSCRIPTION_ARN'
|
35
|
+
|
36
|
+
class Endpoint
|
37
|
+
|
38
|
+
DEFAULT_OPTIONS = { :auto_confirm => true, :auto_resubscribe => false }
|
39
|
+
|
40
|
+
def initialize(app, opt = {})
|
41
|
+
@app = app
|
42
|
+
options = DEFAULT_OPTIONS.merge(opt)
|
43
|
+
@auto_confirm = options[:auto_confirm]
|
44
|
+
@auto_resubscribe = options[:auto_resubscribe]
|
45
|
+
if 1 < [:topic, :topics].count { |k| options.has_key?(k) }
|
46
|
+
raise ArgumentError.new("supply zero or one of :topic, :topics")
|
47
|
+
end
|
48
|
+
@topic_filter = begin
|
49
|
+
case a = options[:topic] || options[:topics]
|
50
|
+
when String then Proc.new { |t| a == t }
|
51
|
+
when Regexp then Proc.new { |t| a.match(t) }
|
52
|
+
when Proc then a
|
53
|
+
when Array
|
54
|
+
unless a.all? { |e| e.is_a? String }
|
55
|
+
raise ArgumentError.new("topic array must be strings")
|
56
|
+
end
|
57
|
+
Proc.new { |t| a.include?(t) }
|
58
|
+
when nil
|
59
|
+
raise ArgumentError.new("must specify a topic filter!")
|
60
|
+
else
|
61
|
+
raise ArgumentError.new("can't use topic filter of type #{a.class}")
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def call(env)
|
67
|
+
if topic_arn = env['HTTP_X_AMZ_SNS_TOPIC_ARN']
|
68
|
+
if @topic_filter.call(topic_arn)
|
69
|
+
call_on_topic(env)
|
70
|
+
else
|
71
|
+
call_off_topic(env)
|
72
|
+
end
|
73
|
+
else
|
74
|
+
@app.call(env)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
OK_RESPONSE = [200, {'Content-Type' => 'text/plain'}, []]
|
81
|
+
|
82
|
+
# Confirms that values specified in HTTP headers match those in the message
|
83
|
+
# itself.
|
84
|
+
def check_headers!(message, env)
|
85
|
+
h = env.values_at 'HTTP_X_AMZ_SNS_MESSAGE_TYPE', 'HTTP_X_AMZ_SNS_MESSAGE_ID', 'HTTP_X_AMZ_SNS_TOPIC_ARN'
|
86
|
+
m = message.type, message.id, message.topic_arn
|
87
|
+
raise Error.new("message does not match HTTP headers", message) unless h == m
|
88
|
+
end
|
89
|
+
|
90
|
+
# Behavior for "on-topic" messages. Notifications are always passed along
|
91
|
+
# to the app. Confirmations are passed along only if their respective
|
92
|
+
# option is nil. If true, the subscription is confirmed; if false, it is
|
93
|
+
# simply ignored.
|
94
|
+
def call_on_topic(env)
|
95
|
+
begin
|
96
|
+
message = Message.new(env['rack.input'].read)
|
97
|
+
check_headers!(message, env)
|
98
|
+
message.verify!
|
99
|
+
case message.type
|
100
|
+
when 'SubscriptionConfirmation'
|
101
|
+
open(message.subscribe_url) if @auto_confirm
|
102
|
+
return OK_RESPONSE unless @auto_confirm.nil?
|
103
|
+
when 'UnsubscribeConfirmation'
|
104
|
+
open(message.subscribe_url) if @auto_resubscribe
|
105
|
+
return OK_RESPONSE unless @auto_resubscribe.nil?
|
106
|
+
end
|
107
|
+
env['sns.message'] = message
|
108
|
+
rescue OpenURI::HTTPError => e
|
109
|
+
env['sns.error'] = Error.new("unable to subscribe: #{e.message}; URL: #{message.subscribe_url}", message)
|
110
|
+
rescue Error => e
|
111
|
+
env['sns.error'] = e
|
112
|
+
end
|
113
|
+
@app.call(env)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Default behavior for "off-topic" messages. Subscription and unsubscribe
|
117
|
+
# confirmations are simply ignored. Notifications, however, indicate that
|
118
|
+
# we are subscribed to a topic we don't know how to deal with. In this
|
119
|
+
# case, we automatically unsubscribe (if the message is authentic).
|
120
|
+
def call_off_topic(env)
|
121
|
+
if env['HTTP_X_AMZ_SNS_MESSAGE_TYPE'] == 'Notification'
|
122
|
+
begin
|
123
|
+
message = Message.new(env['rack.input'].read)
|
124
|
+
message.verify!
|
125
|
+
open(message.unsubscribe_url)
|
126
|
+
rescue => e
|
127
|
+
raise Error.new("error handling off-topic notification: #{e.message}", message)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
OK_RESPONSE
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
module Heroic
|
5
|
+
module SNS
|
6
|
+
|
7
|
+
MAXIMUM_ALLOWED_AGE = 3600 # reject messages older than one hour
|
8
|
+
|
9
|
+
CERTIFICATE_CACHE = Hash.new do |cache, cert_url|
|
10
|
+
begin
|
11
|
+
cert_data = open(cert_url)
|
12
|
+
cache[cert_url] = OpenSSL::X509::Certificate.new(cert_data.read)
|
13
|
+
rescue OpenURI::HTTPError => e
|
14
|
+
raise Error.new("unable to retrieve signing certificate: #{e.message}; URL: #{cert_url}")
|
15
|
+
rescue OpenSSL::X509::CertificateError => e
|
16
|
+
raise Error.new("unable to parse signing certificate: #{e.message}; URL: #{cert_url}")
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Encapsulates an SNS message.
|
21
|
+
# See: http://docs.aws.amazon.com/sns/latest/gsg/json-formats.html
|
22
|
+
class Message
|
23
|
+
|
24
|
+
def initialize(json)
|
25
|
+
@msg = ::JSON.parse(json)
|
26
|
+
rescue JSON::ParserError => e
|
27
|
+
raise Error.new("failed to parse message as JSON: #{e.message}")
|
28
|
+
end
|
29
|
+
|
30
|
+
def type
|
31
|
+
@msg['Type']
|
32
|
+
end
|
33
|
+
|
34
|
+
def topic_arn
|
35
|
+
@msg['TopicArn']
|
36
|
+
end
|
37
|
+
|
38
|
+
def id
|
39
|
+
@msg['MessageId']
|
40
|
+
end
|
41
|
+
|
42
|
+
# The timestamp as a Time object.
|
43
|
+
def timestamp
|
44
|
+
Time.xmlschema(@msg['Timestamp'])
|
45
|
+
end
|
46
|
+
|
47
|
+
def signature_version
|
48
|
+
@msg['SignatureVersion']
|
49
|
+
end
|
50
|
+
|
51
|
+
def signing_cert_url
|
52
|
+
@msg['SigningCertURL']
|
53
|
+
end
|
54
|
+
|
55
|
+
# The message signature data, Base-64 decoded.
|
56
|
+
def signature
|
57
|
+
Base64::decode64(@msg['Signature'])
|
58
|
+
end
|
59
|
+
|
60
|
+
# The message may not have a subject.
|
61
|
+
def subject
|
62
|
+
@msg['Subject']
|
63
|
+
end
|
64
|
+
|
65
|
+
def body
|
66
|
+
@msg['Message']
|
67
|
+
end
|
68
|
+
|
69
|
+
def subscribe_url
|
70
|
+
@msg['SubscribeURL']
|
71
|
+
end
|
72
|
+
|
73
|
+
def unsubscribe_url
|
74
|
+
@msg['UnsubscribeURL']
|
75
|
+
end
|
76
|
+
|
77
|
+
# The token is used to confirm subscriptions via the SNS API. If you visit
|
78
|
+
# the :subscribe_url, you can ignore this field.
|
79
|
+
def token
|
80
|
+
@msg['Token']
|
81
|
+
end
|
82
|
+
|
83
|
+
def ==(other_message)
|
84
|
+
@msg == other_message.instance_variable_get(:@msg)
|
85
|
+
end
|
86
|
+
|
87
|
+
def hash
|
88
|
+
@msg.hash
|
89
|
+
end
|
90
|
+
|
91
|
+
def to_s
|
92
|
+
string = "<SNSMessage:\n"
|
93
|
+
@msg.each do |k,v|
|
94
|
+
string << sprintf(" %s: %s\n", k, v.inspect)
|
95
|
+
end
|
96
|
+
string << ">"
|
97
|
+
end
|
98
|
+
|
99
|
+
def to_json
|
100
|
+
@msg.to_json
|
101
|
+
end
|
102
|
+
|
103
|
+
# Verifies the message signature. Raises an exception if it is not valid.
|
104
|
+
# See: http://docs.aws.amazon.com/sns/latest/gsg/SendMessageToHttp.verify.signature.html
|
105
|
+
def verify!
|
106
|
+
age = Time.now - timestamp
|
107
|
+
raise Errow.new("timestamp is in the future", self) if age < 0
|
108
|
+
raise Error.new("timestamp is too old", self) if age > MAXIMUM_ALLOWED_AGE
|
109
|
+
if signature_version != '1'
|
110
|
+
raise Error.new("unknown signature version: #{signature_version}", self)
|
111
|
+
end
|
112
|
+
if signing_cert_url !~ %r[^https://.*amazonaws\.com/]
|
113
|
+
raise Error.new("signing certificate is not from amazonaws.com", self)
|
114
|
+
end
|
115
|
+
text = string_to_sign # will warn of invalid Type
|
116
|
+
cert = CERTIFICATE_CACHE[signing_cert_url]
|
117
|
+
digest = OpenSSL::Digest::SHA1.new
|
118
|
+
unless cert.public_key.verify(digest, signature, text)
|
119
|
+
raise Error.new("message signature is invalid", self)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
private
|
124
|
+
|
125
|
+
CANONICAL_NOTIFICATION_KEYS = %w[Message MessageId Subject Timestamp TopicArn Type].freeze
|
126
|
+
|
127
|
+
CANONICAL_SUBSCRIPTION_KEYS = %w[Message MessageId SubscribeURL Timestamp Token TopicArn Type].freeze
|
128
|
+
|
129
|
+
CANONICAL_KEYS_FOR_TYPE = {
|
130
|
+
'Notification' => CANONICAL_NOTIFICATION_KEYS,
|
131
|
+
'SubscriptionConfirmation' => CANONICAL_SUBSCRIPTION_KEYS,
|
132
|
+
'UnsubscribeConfirmation' => CANONICAL_SUBSCRIPTION_KEYS
|
133
|
+
}.freeze
|
134
|
+
|
135
|
+
def string_to_sign
|
136
|
+
keys = CANONICAL_KEYS_FOR_TYPE[self.type]
|
137
|
+
raise Error.new("unrecognized message type: #{self.type}", self) unless keys
|
138
|
+
string = String.new
|
139
|
+
keys.each do |key|
|
140
|
+
if @msg.has_key?(key) # in case message has no Subject
|
141
|
+
string << key << "\n" << @msg[key] << "\n"
|
142
|
+
end
|
143
|
+
end
|
144
|
+
return string
|
145
|
+
end
|
146
|
+
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
# Generate a self-signed certificate
|
2
|
+
# 1. openssl genrsa -out sns.key 1024
|
3
|
+
# 2. openssl req -new -key sns.key -out sns.csr
|
4
|
+
# 3. openssl x509 -req -days 3652 -in sns.csr -signkey sns.key -out sns.crt
|
5
|
+
|
6
|
+
module Heroic
|
7
|
+
module SNS
|
8
|
+
|
9
|
+
TEST_CERT_URL = 'https://sns.test.amazonaws.com/self-signed.pem'
|
10
|
+
TEST_CERT_KEY = OpenSSL::PKey::RSA.new(File.read('test/fixtures/sns.key'))
|
11
|
+
|
12
|
+
CERTIFICATE_CACHE[TEST_CERT_URL] = begin
|
13
|
+
# Insert the certificate in the cache so that these tests aren't dependent
|
14
|
+
# on network access (or the fact that the certificate is fake).
|
15
|
+
cert_data = File.read('test/fixtures/sns.crt')
|
16
|
+
OpenSSL::X509::Certificate.new(cert_data)
|
17
|
+
end
|
18
|
+
|
19
|
+
class Message
|
20
|
+
|
21
|
+
def update_timestamp!(t = Time.now)
|
22
|
+
@msg['Timestamp'] = t.utc.xmlschema(3)
|
23
|
+
end
|
24
|
+
|
25
|
+
def sign!
|
26
|
+
@msg['SigningCertURL'] = TEST_CERT_URL
|
27
|
+
signature = TEST_CERT_KEY.sign(OpenSSL::Digest::SHA1.new, string_to_sign)
|
28
|
+
@msg['Signature'] = Base64::encode64(signature)
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
require 'heroic/sns'
|
3
|
+
require 'helper'
|
4
|
+
|
5
|
+
NULL_APP = Proc.new { |env| [0, {}, []] }
|
6
|
+
|
7
|
+
class EndpointTest < Test::Unit::TestCase
|
8
|
+
|
9
|
+
def sns(name)
|
10
|
+
json = File.read("test/fixtures/#{name}.json")
|
11
|
+
@msg = Heroic::SNS::Message.new(json)
|
12
|
+
@msg.update_timestamp!
|
13
|
+
@msg.sign!
|
14
|
+
@env = {
|
15
|
+
'HTTP_X_AMZ_SNS_MESSAGE_TYPE' => @msg.type,
|
16
|
+
'HTTP_X_AMZ_SNS_MESSAGE_ID' => @msg.id,
|
17
|
+
'HTTP_X_AMZ_SNS_TOPIC_ARN' => @msg.topic_arn,
|
18
|
+
'HTTP_X_AMZ_SNS_SUBSCRIPTION_ARN' => "#{@msg.topic_arn}:af0d2f29-f4c3-4df2-b7e2-5a096fc772f6",
|
19
|
+
'rack.input' => StringIO.new(@msg.to_json)
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_no_topic
|
24
|
+
assert_raises ArgumentError do
|
25
|
+
Heroic::SNS::Endpoint.new
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_receive_message
|
30
|
+
result = [0, {}, []]
|
31
|
+
test = Proc.new do |env|
|
32
|
+
assert_equal env['sns.message'], @msg
|
33
|
+
result
|
34
|
+
end
|
35
|
+
sns('notification')
|
36
|
+
app = Heroic::SNS::Endpoint.new test, :topic => @msg.topic_arn
|
37
|
+
assert_equal result, app.call(@env)
|
38
|
+
end
|
39
|
+
|
40
|
+
def test_ignore_message
|
41
|
+
test = Proc.new { |env| raise "should be unreachable!" }
|
42
|
+
sns('notification')
|
43
|
+
app = Heroic::SNS::Endpoint.new test, :topic => "different-topic"
|
44
|
+
assert_nothing_raised { app.call(@env) }
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'test/unit'
|
2
|
+
require 'heroic/sns'
|
3
|
+
require 'helper'
|
4
|
+
|
5
|
+
class MessageTest < Test::Unit::TestCase
|
6
|
+
|
7
|
+
def sns(name, timestamp = Time.now)
|
8
|
+
json = File.read("test/fixtures/#{name}.json")
|
9
|
+
msg = Heroic::SNS::Message.new(json)
|
10
|
+
msg.update_timestamp!(timestamp)
|
11
|
+
msg.sign!
|
12
|
+
return msg
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_invalid_json
|
16
|
+
assert_raises Heroic::SNS::Error do
|
17
|
+
Heroic::SNS::Message.new("INV4L!D JS0N")
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_notification
|
22
|
+
msg = sns("notification")
|
23
|
+
assert_equal 'Notification', msg.type
|
24
|
+
assert_equal 'arn:aws:sns:us-east-1:777594007835:racktest', msg.topic_arn
|
25
|
+
assert_equal 128, msg.signature.length
|
26
|
+
assert_nothing_raised { msg.verify! }
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_subscription
|
30
|
+
msg = sns("subscription")
|
31
|
+
assert_equal 'SubscriptionConfirmation', msg.type
|
32
|
+
assert_equal 'arn:aws:sns:us-east-1:777594007835:racktest', msg.topic_arn
|
33
|
+
assert_equal 128, msg.signature.length
|
34
|
+
assert_nothing_raised { msg.verify! }
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_unsubscribe
|
38
|
+
msg = sns("unsubscribe")
|
39
|
+
assert_equal 'UnsubscribeConfirmation', msg.type
|
40
|
+
assert_equal 'arn:aws:sns:us-east-1:777594007835:racktest', msg.topic_arn
|
41
|
+
assert_equal 128, msg.signature.length
|
42
|
+
assert_nothing_raised { msg.verify! }
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_tampered
|
46
|
+
json = sns("notification").to_json
|
47
|
+
msg = Heroic::SNS::Message.new(json.gsub(/booyakasha/, 'cowabunga'))
|
48
|
+
assert_equal 'cowabunga!', msg.body
|
49
|
+
assert_raises Heroic::SNS::Error do
|
50
|
+
msg.verify!
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def test_expired
|
55
|
+
t = Time.utc(1984, 5)
|
56
|
+
msg = sns("notification", t)
|
57
|
+
assert_equal t, msg.timestamp
|
58
|
+
assert_raises Heroic::SNS::Error do
|
59
|
+
msg.verify!
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
metadata
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: heroic-sns
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Benjamin Ragheb
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-05-05 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rack
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.4'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.4'
|
27
|
+
description: ! 'Lightweight Rack middleware for AWS Simple Notification Service (SNS)
|
28
|
+
endpoints.
|
29
|
+
|
30
|
+
SNS messages are intercepted, parsed, verified, and passed along to the Rack
|
31
|
+
|
32
|
+
application in the ''sns.message'' environment key. The gem is designed to be
|
33
|
+
|
34
|
+
lightweight and does not depend on any other gem besides ''rack'' (specifically,
|
35
|
+
|
36
|
+
the ''aws-sdk'' gem is not required).
|
37
|
+
|
38
|
+
'
|
39
|
+
email: ben@benzado.com
|
40
|
+
executables: []
|
41
|
+
extensions: []
|
42
|
+
extra_rdoc_files: []
|
43
|
+
files:
|
44
|
+
- README.md
|
45
|
+
- LICENSE
|
46
|
+
- lib/heroic/sns/endpoint.rb
|
47
|
+
- lib/heroic/sns/message.rb
|
48
|
+
- lib/heroic/sns.rb
|
49
|
+
- test/helper.rb
|
50
|
+
- test/test_endpoint.rb
|
51
|
+
- test/test_message.rb
|
52
|
+
homepage: https://github.com/benzado/heroic-sns
|
53
|
+
licenses:
|
54
|
+
- Apache
|
55
|
+
metadata: {}
|
56
|
+
post_install_message: Thanks for installing!
|
57
|
+
rdoc_options: []
|
58
|
+
require_paths:
|
59
|
+
- lib
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
61
|
+
requirements:
|
62
|
+
- - ! '>='
|
63
|
+
- !ruby/object:Gem::Version
|
64
|
+
version: 1.8.7
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
requirements: []
|
71
|
+
rubyforge_project:
|
72
|
+
rubygems_version: 2.0.3
|
73
|
+
signing_key:
|
74
|
+
specification_version: 4
|
75
|
+
summary: Lightweight Rack middleware for AWS SNS endpoints
|
76
|
+
test_files: []
|
77
|
+
has_rdoc:
|