Skip to content

Commit 6a269ed

Browse files
authored
Improve security by adding request signing (#34)
* Added request signing * rearranged things to reflect the order of checks * Updated README.rst * Updated screenshot * Fixed test request signature creation * Fixed Flask exception handling test * Added hash matching for Python 2.7.6 and add 2.7.6 to tox environments * Making hash comparisons work in 2.7.6, 2.7.7 and 3.6.5…
1 parent 79949e6 commit 6a269ed

File tree

10 files changed

+215
-41
lines changed

10 files changed

+215
-41
lines changed

.travis.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ sudo: false
22
dist: trusty
33
language: python
44
python:
5+
- "2.7.6"
56
- "2.7"
67
- "3.3"
78
- "3.4"

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ user has authorized your app.
7272

7373
.. code:: python
7474
75-
SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"]
75+
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
7676
7777
Create a Slack Event Adapter for receiving actions via the Events API
7878
-----------------------------------------------------------------------
@@ -83,7 +83,7 @@ Create a Slack Event Adapter for receiving actions via the Events API
8383
from slackeventsapi import SlackEventAdapter
8484
8585
86-
slack_events_adapter = SlackEventAdapter(SLACK_VERIFICATION_TOKEN, endpoint="/slack/events")
86+
slack_events_adapter = SlackEventAdapter(SLACK_SIGNING_SECRET, endpoint="/slack/events")
8787
8888
8989
# Create an event listener for "reaction_added" events and print the emoji name
@@ -118,7 +118,7 @@ Create a Slack Event Adapter for receiving actions via the Events API
118118
119119
# Bind the Events API route to your existing Flask app by passing the server
120120
# instance as the last param, or with `server=app`.
121-
slack_events_adapter = SlackEventAdapter(SLACK_VERIFICATION_TOKEN, "/slack/events", app)
121+
slack_events_adapter = SlackEventAdapter(SLACK_SIGNING_SECRET, "/slack/events", app)
122122
123123
124124
# Create an event listener for "reaction_added" events and print the emoji name

example/README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,13 @@ Copy your app's **Bot User OAuth Access Token** and add it to your python enviro
7373
7474
Next, go back to your app's **Basic Information** page
7575

76-
.. image:: https://cloud.githubusercontent.com/assets/32463/24877833/950dd53c-1de5-11e7-984f-deb26e8b9482.png
76+
.. image:: https://user-images.githubusercontent.com/32463/43932347-63b21eca-9bf8-11e8-8b30-0a848c263bb1.png
7777

78-
Add your app's **Verification Token** to your python environmental variables
78+
Add your app's **Signing Secret** to your python environmental variables
7979

8080
.. code::
8181
82-
export SLACK_VERIFICATION_TOKEN=xxxxxxxxXxxXxxXxXXXxxXxxx
82+
export SLACK_SIGNING_SECRET=xxxxxxxxXxxXxxXxXXXxxXxxx
8383
8484
8585
**🤖 Start ngrok**

example/example.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
import os
44

55
# Our app's Slack Event Adapter for receiving actions via the Events API
6-
SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"]
7-
slack_events_adapter = SlackEventAdapter(SLACK_VERIFICATION_TOKEN, "/slack/events")
6+
slack_signing_secret = os.environ["SLACK_SIGNING_SECRET"]
7+
slack_events_adapter = SlackEventAdapter(slack_signing_secret, "/slack/events")
88

99
# Create a SlackClient for your bot to use for Web API requests
10-
SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
11-
CLIENT = SlackClient(SLACK_BOT_TOKEN)
10+
slack_bot_token = os.environ["SLACK_BOT_TOKEN"]
11+
slack_client = SlackClient(slack_bot_token)
1212

1313
# Example responder to greetings
1414
@slack_events_adapter.on("message")
@@ -18,7 +18,7 @@ def handle_message(event_data):
1818
if message.get("subtype") is None and "hi" in message.get('text'):
1919
channel = message["channel"]
2020
message = "Hello <@%s>! :tada:" % message["user"]
21-
CLIENT.api_call("chat.postMessage", channel=channel, text=message)
21+
slack_client.api_call("chat.postMessage", channel=channel, text=message)
2222

2323

2424
# Example reaction emoji echo
@@ -28,7 +28,12 @@ def reaction_added(event_data):
2828
emoji = event["reaction"]
2929
channel = event["item"]["channel"]
3030
text = ":%s:" % emoji
31-
CLIENT.api_call("chat.postMessage", channel=channel, text=text)
31+
slack_client.api_call("chat.postMessage", channel=channel, text=text)
32+
33+
# Error events
34+
@slack_events_adapter.on("error")
35+
def error_handler(err):
36+
print("ERROR: " + str(err))
3237

3338
# Once we have our event listeners configured, we can start the
3439
# Flask server with the default `/events` endpoint on port 3000

slackeventsapi/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
class SlackEventAdapter(EventEmitter):
66
# Initialize the Slack event server
77
# If no endpoint is provided, default to listening on '/slack/events'
8-
def __init__(self, verification_token, endpoint="/slack/events", server=None):
8+
def __init__(self, signing_secret, endpoint="/slack/events", server=None, **kwargs):
99
EventEmitter.__init__(self)
10-
self.verification_token = verification_token
11-
self.server = SlackServer(verification_token, endpoint, self, server)
10+
self.signing_secret = signing_secret
11+
self.server = SlackServer(signing_secret, endpoint, self, server, **kwargs)
1212

1313
def start(self, host='127.0.0.1', port=None, debug=False, **kwargs):
1414
"""

slackeventsapi/server.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
import json
33
import platform
44
import sys
5+
import hmac
6+
import hashlib
7+
from time import time
58
from .version import __version__
69

710

811
class SlackServer(Flask):
9-
def __init__(self, verification_token, endpoint, emitter, server):
10-
self.verification_token = verification_token
12+
def __init__(self, signing_secret, endpoint, emitter, server):
13+
self.signing_secret = signing_secret
1114
self.emitter = emitter
1215
self.endpoint = endpoint
1316
self.package_info = self.get_package_info()
@@ -41,32 +44,91 @@ def get_package_info(self):
4144

4245
return " ".join(ua_string)
4346

47+
def verify_signature(self, timestamp, signature):
48+
# Verify the request signature of the request sent from Slack
49+
# Generate a new hash using the app's signing secret and request data
50+
51+
# Compare the generated hash and incoming request signature
52+
# Python 2.7.6 doesn't support compare_digest
53+
# It's recommended to use Python 2.7.7+
54+
# noqa See https://docs.python.org/2/whatsnew/2.7.html#pep-466-network-security-enhancements-for-python-2-7
55+
if hasattr(hmac, "compare_digest"):
56+
req = str.encode('v0:' + str(timestamp) + ':') + request.data
57+
request_hash = 'v0=' + hmac.new(
58+
str.encode(self.signing_secret),
59+
req, hashlib.sha256
60+
).hexdigest()
61+
# Compare byte strings for Python 2
62+
if (sys.version_info[0] == 2):
63+
return hmac.compare_digest(bytes(request_hash), bytes(signature))
64+
else:
65+
return hmac.compare_digest(request_hash, signature)
66+
else:
67+
# So, we'll compare the signatures explicitly
68+
req = str.encode('v0:' + str(timestamp) + ':') + request.data
69+
request_hash = 'v0=' + hmac.new(
70+
str.encode(self.signing_secret),
71+
req, hashlib.sha256
72+
).hexdigest()
73+
74+
if len(request_hash) != len(signature):
75+
return False
76+
result = 0
77+
if isinstance(request_hash, bytes) and isinstance(signature, bytes):
78+
for x, y in zip(request_hash, signature):
79+
result |= x ^ y
80+
else:
81+
for x, y in zip(request_hash, signature):
82+
result |= ord(x) ^ ord(y)
83+
return result == 0
84+
4485
def bind_route(self, server):
4586
@server.route(self.endpoint, methods=['GET', 'POST'])
4687
def event():
4788
# If a GET request is made, return 404.
4889
if request.method == 'GET':
4990
return make_response("These are not the slackbots you're looking for.", 404)
5091

92+
# Each request comes with request timestamp and request signature
93+
# emit an error if the timestamp is out of range
94+
req_timestamp = request.headers.get('X-Slack-Request-Timestamp')
95+
if abs(time() - int(req_timestamp)) > 60 * 5:
96+
slack_exception = SlackEventAdapterException('Invalid request timestamp')
97+
self.emitter.emit('error', slack_exception)
98+
return make_response("", 403)
99+
100+
# Verify the request signature using the app's signing secret
101+
# emit an error if the signature can't be verified
102+
req_signature = request.headers.get('X-Slack-Signature')
103+
if not self.verify_signature(req_timestamp, req_signature):
104+
slack_exception = SlackEventAdapterException('Invalid request signature')
105+
self.emitter.emit('error', slack_exception)
106+
return make_response("", 403)
107+
51108
# Parse the request payload into JSON
52109
event_data = json.loads(request.data.decode('utf-8'))
53110

54-
# Echo the URL verification challenge code
111+
# Echo the URL verification challenge code back to Slack
55112
if "challenge" in event_data:
56113
return make_response(
57114
event_data.get("challenge"), 200, {"content_type": "application/json"}
58115
)
59116

60-
# Verify the request token
61-
request_token = event_data.get("token")
62-
if self.verification_token != request_token:
63-
self.emitter.emit('error', Exception('invalid verification token'))
64-
return make_response("Request contains invalid Slack verification token", 403)
65-
66117
# Parse the Event payload and emit the event to the event listener
67118
if "event" in event_data:
68119
event_type = event_data["event"]["type"]
69120
self.emitter.emit(event_type, event_data)
70121
response = make_response("", 200)
71122
response.headers['X-Slack-Powered-By'] = self.package_info
72123
return response
124+
125+
126+
class SlackEventAdapterException(Exception):
127+
"""
128+
Base exception for all errors raised by the SlackClient library
129+
"""
130+
def __init__(self, msg=None):
131+
if msg is None:
132+
# default error message
133+
msg = "An error occurred in the SlackEventsApiAdapter library"
134+
super(SlackEventAdapterException, self).__init__(msg)

tests/conftest.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
1-
import pytest
21
import json
2+
import hashlib
3+
import hmac
4+
import pytest
35
from slackeventsapi import SlackEventAdapter
46

57

8+
def create_signature(secret, timestamp, data):
9+
req = str.encode('v0:' + str(timestamp) + ':') + str.encode(data)
10+
request_signature= 'v0='+hmac.new(
11+
str.encode(secret),
12+
req, hashlib.sha256
13+
).hexdigest()
14+
return request_signature
15+
16+
617
def load_event_fixture(event, as_string=True):
718
filename = "tests/data/{}.json".format(event)
819
with open(filename) as json_data:
@@ -23,12 +34,14 @@ def pytest_namespace():
2334
return {
2435
'reaction_event_fixture': load_event_fixture('reaction_added'),
2536
'url_challenge_fixture': load_event_fixture('url_challenge'),
26-
'bad_token_fixture': event_with_bad_token()
37+
'bad_token_fixture': event_with_bad_token(),
38+
'create_signature': create_signature
2739
}
2840

2941

3042
@pytest.fixture
3143
def app():
32-
adapter = SlackEventAdapter("vFO9LARnLI7GflLR8tGqHgdy")
44+
adapter = SlackEventAdapter("SIGNING_SECRET")
3345
app = adapter.server
46+
app.testing = True
3447
return app

tests/test_events.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
1+
import time
12
import pytest
23
from slackeventsapi import SlackEventAdapter
34

4-
ADAPTER = SlackEventAdapter('vFO9LARnLI7GflLR8tGqHgdy')
5-
5+
ADAPTER = SlackEventAdapter('SIGNING_SECRET')
66

77
def test_event_emission(client):
88
# Events should trigger an event
9-
data = pytest.reaction_event_fixture
10-
119
@ADAPTER.on('reaction_added')
1210
def event_handler(event):
1311
assert event["reaction"] == 'grinning'
1412

13+
data = pytest.reaction_event_fixture
14+
timestamp = int(time.time())
15+
signature = pytest.create_signature(ADAPTER.signing_secret, timestamp, data)
16+
1517
res = client.post(
1618
'/slack/events',
1719
data=data,
18-
content_type='application/json'
20+
content_type='application/json',
21+
headers={
22+
'X-Slack-Request-Timestamp': timestamp,
23+
'X-Slack-Signature': signature
24+
}
1925
)
2026

2127
assert res.status_code == 200

0 commit comments

Comments
 (0)