Skip to content

Commit

Permalink
Improve security by adding request signing (#34)
Browse files Browse the repository at this point in the history
* 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…
  • Loading branch information
Roach authored Aug 15, 2018
1 parent 79949e6 commit 6a269ed
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 41 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ sudo: false
dist: trusty
language: python
python:
- "2.7.6"
- "2.7"
- "3.3"
- "3.4"
Expand Down
6 changes: 3 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ user has authorized your app.

.. code:: python
SLACK_VERIFICATION_TOKEN = os.environ["SLACK_VERIFICATION_TOKEN"]
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
Create a Slack Event Adapter for receiving actions via the Events API
-----------------------------------------------------------------------
Expand All @@ -83,7 +83,7 @@ Create a Slack Event Adapter for receiving actions via the Events API
from slackeventsapi import SlackEventAdapter
slack_events_adapter = SlackEventAdapter(SLACK_VERIFICATION_TOKEN, endpoint="/slack/events")
slack_events_adapter = SlackEventAdapter(SLACK_SIGNING_SECRET, endpoint="/slack/events")
# Create an event listener for "reaction_added" events and print the emoji name
Expand Down Expand Up @@ -118,7 +118,7 @@ Create a Slack Event Adapter for receiving actions via the Events API
# Bind the Events API route to your existing Flask app by passing the server
# instance as the last param, or with `server=app`.
slack_events_adapter = SlackEventAdapter(SLACK_VERIFICATION_TOKEN, "/slack/events", app)
slack_events_adapter = SlackEventAdapter(SLACK_SIGNING_SECRET, "/slack/events", app)
# Create an event listener for "reaction_added" events and print the emoji name
Expand Down
6 changes: 3 additions & 3 deletions example/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,13 @@ Copy your app's **Bot User OAuth Access Token** and add it to your python enviro
Next, go back to your app's **Basic Information** page

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

Add your app's **Verification Token** to your python environmental variables
Add your app's **Signing Secret** to your python environmental variables

.. code::
export SLACK_VERIFICATION_TOKEN=xxxxxxxxXxxXxxXxXXXxxXxxx
export SLACK_SIGNING_SECRET=xxxxxxxxXxxXxxXxXXXxxXxxx
**🤖 Start ngrok**
Expand Down
17 changes: 11 additions & 6 deletions example/example.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import os

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

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

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


# Example reaction emoji echo
Expand All @@ -28,7 +28,12 @@ def reaction_added(event_data):
emoji = event["reaction"]
channel = event["item"]["channel"]
text = ":%s:" % emoji
CLIENT.api_call("chat.postMessage", channel=channel, text=text)
slack_client.api_call("chat.postMessage", channel=channel, text=text)

# Error events
@slack_events_adapter.on("error")
def error_handler(err):
print("ERROR: " + str(err))

# Once we have our event listeners configured, we can start the
# Flask server with the default `/events` endpoint on port 3000
Expand Down
6 changes: 3 additions & 3 deletions slackeventsapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
class SlackEventAdapter(EventEmitter):
# Initialize the Slack event server
# If no endpoint is provided, default to listening on '/slack/events'
def __init__(self, verification_token, endpoint="/slack/events", server=None):
def __init__(self, signing_secret, endpoint="/slack/events", server=None, **kwargs):
EventEmitter.__init__(self)
self.verification_token = verification_token
self.server = SlackServer(verification_token, endpoint, self, server)
self.signing_secret = signing_secret
self.server = SlackServer(signing_secret, endpoint, self, server, **kwargs)

def start(self, host='127.0.0.1', port=None, debug=False, **kwargs):
"""
Expand Down
80 changes: 71 additions & 9 deletions slackeventsapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@
import json
import platform
import sys
import hmac
import hashlib
from time import time
from .version import __version__


class SlackServer(Flask):
def __init__(self, verification_token, endpoint, emitter, server):
self.verification_token = verification_token
def __init__(self, signing_secret, endpoint, emitter, server):
self.signing_secret = signing_secret
self.emitter = emitter
self.endpoint = endpoint
self.package_info = self.get_package_info()
Expand Down Expand Up @@ -41,32 +44,91 @@ def get_package_info(self):

return " ".join(ua_string)

def verify_signature(self, timestamp, signature):
# Verify the request signature of the request sent from Slack
# Generate a new hash using the app's signing secret and request data

# Compare the generated hash and incoming request signature
# Python 2.7.6 doesn't support compare_digest
# It's recommended to use Python 2.7.7+
# noqa See https://docs.python.org/2/whatsnew/2.7.html#pep-466-network-security-enhancements-for-python-2-7
if hasattr(hmac, "compare_digest"):
req = str.encode('v0:' + str(timestamp) + ':') + request.data
request_hash = 'v0=' + hmac.new(
str.encode(self.signing_secret),
req, hashlib.sha256
).hexdigest()
# Compare byte strings for Python 2
if (sys.version_info[0] == 2):
return hmac.compare_digest(bytes(request_hash), bytes(signature))
else:
return hmac.compare_digest(request_hash, signature)
else:
# So, we'll compare the signatures explicitly
req = str.encode('v0:' + str(timestamp) + ':') + request.data
request_hash = 'v0=' + hmac.new(
str.encode(self.signing_secret),
req, hashlib.sha256
).hexdigest()

if len(request_hash) != len(signature):
return False
result = 0
if isinstance(request_hash, bytes) and isinstance(signature, bytes):
for x, y in zip(request_hash, signature):
result |= x ^ y
else:
for x, y in zip(request_hash, signature):
result |= ord(x) ^ ord(y)
return result == 0

def bind_route(self, server):
@server.route(self.endpoint, methods=['GET', 'POST'])
def event():
# If a GET request is made, return 404.
if request.method == 'GET':
return make_response("These are not the slackbots you're looking for.", 404)

# Each request comes with request timestamp and request signature
# emit an error if the timestamp is out of range
req_timestamp = request.headers.get('X-Slack-Request-Timestamp')
if abs(time() - int(req_timestamp)) > 60 * 5:
slack_exception = SlackEventAdapterException('Invalid request timestamp')
self.emitter.emit('error', slack_exception)
return make_response("", 403)

# Verify the request signature using the app's signing secret
# emit an error if the signature can't be verified
req_signature = request.headers.get('X-Slack-Signature')
if not self.verify_signature(req_timestamp, req_signature):
slack_exception = SlackEventAdapterException('Invalid request signature')
self.emitter.emit('error', slack_exception)
return make_response("", 403)

# Parse the request payload into JSON
event_data = json.loads(request.data.decode('utf-8'))

# Echo the URL verification challenge code
# Echo the URL verification challenge code back to Slack
if "challenge" in event_data:
return make_response(
event_data.get("challenge"), 200, {"content_type": "application/json"}
)

# Verify the request token
request_token = event_data.get("token")
if self.verification_token != request_token:
self.emitter.emit('error', Exception('invalid verification token'))
return make_response("Request contains invalid Slack verification token", 403)

# Parse the Event payload and emit the event to the event listener
if "event" in event_data:
event_type = event_data["event"]["type"]
self.emitter.emit(event_type, event_data)
response = make_response("", 200)
response.headers['X-Slack-Powered-By'] = self.package_info
return response


class SlackEventAdapterException(Exception):
"""
Base exception for all errors raised by the SlackClient library
"""
def __init__(self, msg=None):
if msg is None:
# default error message
msg = "An error occurred in the SlackEventsApiAdapter library"
super(SlackEventAdapterException, self).__init__(msg)
19 changes: 16 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import pytest
import json
import hashlib
import hmac
import pytest
from slackeventsapi import SlackEventAdapter


def create_signature(secret, timestamp, data):
req = str.encode('v0:' + str(timestamp) + ':') + str.encode(data)
request_signature= 'v0='+hmac.new(
str.encode(secret),
req, hashlib.sha256
).hexdigest()
return request_signature


def load_event_fixture(event, as_string=True):
filename = "tests/data/{}.json".format(event)
with open(filename) as json_data:
Expand All @@ -23,12 +34,14 @@ def pytest_namespace():
return {
'reaction_event_fixture': load_event_fixture('reaction_added'),
'url_challenge_fixture': load_event_fixture('url_challenge'),
'bad_token_fixture': event_with_bad_token()
'bad_token_fixture': event_with_bad_token(),
'create_signature': create_signature
}


@pytest.fixture
def app():
adapter = SlackEventAdapter("vFO9LARnLI7GflLR8tGqHgdy")
adapter = SlackEventAdapter("SIGNING_SECRET")
app = adapter.server
app.testing = True
return app
16 changes: 11 additions & 5 deletions tests/test_events.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import time
import pytest
from slackeventsapi import SlackEventAdapter

ADAPTER = SlackEventAdapter('vFO9LARnLI7GflLR8tGqHgdy')

ADAPTER = SlackEventAdapter('SIGNING_SECRET')

def test_event_emission(client):
# Events should trigger an event
data = pytest.reaction_event_fixture

@ADAPTER.on('reaction_added')
def event_handler(event):
assert event["reaction"] == 'grinning'

data = pytest.reaction_event_fixture
timestamp = int(time.time())
signature = pytest.create_signature(ADAPTER.signing_secret, timestamp, data)

res = client.post(
'/slack/events',
data=data,
content_type='application/json'
content_type='application/json',
headers={
'X-Slack-Request-Timestamp': timestamp,
'X-Slack-Signature': signature
}
)

assert res.status_code == 200
Loading

0 comments on commit 6a269ed

Please sign in to comment.