From b07230e9dedde3d857b485a558491c7ee8bc1602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Coelho?= Date: Fri, 21 Sep 2018 15:56:50 +0100 Subject: [PATCH] Add request origin validation --- .env.dist | 1 + README.md | 61 ++++++-------- src/definitions.py | 4 +- src/handlers.py | 194 ++++++++++++++++++++++++++++++++------------- src/responder.py | 9 +++ 5 files changed, 174 insertions(+), 95 deletions(-) diff --git a/.env.dist b/.env.dist index cf563c3..1c79967 100644 --- a/.env.dist +++ b/.env.dist @@ -9,3 +9,4 @@ APP_PORT_EXTERNAL= # Slack related SLACK_SUPPORT_CHANNEL_ID= +SLACK_SIGNING_SECRET= diff --git a/README.md b/README.md index 57d7fb6..900bf25 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,28 @@ # neecathon-slack-bot A slack bot for the NEECathon! -## Valid commands: -```./criar-equipa [team name]``` - Creates a new team, if the name doesn't exists already.\ -Returns the team ID, a key to later join the team and the team name. - - -```./entrar [team-code]``` - Joins the team with the defined code, if exists.\ -TODO: Join the user to channels. - - -```./saldo``` - Shows the current balance (team-wise). - - -```./compra [@user] [qtd] [description]``` - Allows to buy something from another user. -TODO: Posts a message to channels. - - -```./movimentos ``` - List transactions. \ -This command can be used by either users and admins. \ -If the user has a team, list the last `qtd` transactions of his team. \ -**TODO:** \ -If performed by an admin, list the last `qtd` transactions of all teams. \ -An admin can add the `team-id` as a argument to view the transactions of only one team. - +## Valid commands +### Create team +`/criar-equipa [team name]` +Creates a new team, if the name doesn't exists already. Returns the newly created team information: The name, ID and a access key, which allows users to enter the team using that code. Reports an error stating that a team cannot be created if something fails. If the team name already exists the team isn't created and an error message appears in the chat. +### Join team +`/entrar [entry-code]` +Joins the team with the defined `entry-code`, if exists. If the `entry-code` is valid, the user receives a message and joins the team. If it's invalid, an error message pops up. +### Balance check +`/saldo` +Shows the team-wise current balance. If the user does not have a team, an error message appears stating how to join a team. +### Buy +`/compra [@destination_user] [qty] [description]` +Allows to buy something from another user. It performs a transfer, between the command caller and the `destination_user`, by giving him `qty` credits. A short description must be provided to describe the transaction. If `destination_user` isn't enrolled in a team, an error message will be displayed stating that. If `qty` is invalid (unparsable, negative, null or above team actual balance), the user will get an error message explaining the problem. +### List last transactions +`/movimentos ` +List transactions. If the user has a team, list the last `qty` transactions of his team. If the current user doesn't have a team, an error message appears stating how to join a team. + + +## Current features: +- Request origin verification/validation ## To be added - - - ```./ver-equipas``` - List all teams. \ Can only be performed by admins. Used to list all teams. @@ -41,30 +35,21 @@ Can only be performed by admins. Used to list all details of a team. The `team-i Can only be performed by admins. Used to change all teams balances. -```./adicionar-informacao ``` \ -Can be performed by users, to add some personal information on his account (name and email). \ -An admin can also use this command on other user, by providing the `@user` has a first argument. - -```./informacoes``` \ -If performed by an user/admin, returns all informations related to his account. \ -An admin can also use this command to retrieve all information on some user by providing the `@user`. - - ```./tornar-admin <@user>``` \ Can only be performed by admins. Used to make `@user` an admin. - - ## Features to add -- Verify requests origin and validation - Auto add users to channels - Report logs to channel - Report money receival on buy operation - Permissions system +- Error codes ## Problems found - How to create first admin. +- Implementation: Log levels aren't well defined. +- IDs are not being verified as unique. ## Bug list - ... \ No newline at end of file diff --git a/src/definitions.py b/src/definitions.py index 4056568..3792d94 100644 --- a/src/definitions.py +++ b/src/definitions.py @@ -22,4 +22,6 @@ "LIST_TRANSACTIONS": "/movimentos", } -INITIAL_TEAM_BALANCE = 200 \ No newline at end of file +INITIAL_TEAM_BALANCE = 200 + +SLACK_REQUEST_TIMESTAMP_MAX_GAP_MINUTES = 1.0 diff --git a/src/handlers.py b/src/handlers.py index cef96b2..dcb8e19 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -3,8 +3,15 @@ import dispatcher from threading import Thread from bottle import request -from definitions import SLACK_REQUEST_DATA_KEYS +from definitions import SLACK_REQUEST_DATA_KEYS, SLACK_REQUEST_TIMESTAMP_MAX_GAP_MINUTES import common +import time +import database +import exceptions +import hmac +import hashlib +import os + common.setup_logger() @@ -13,98 +20,173 @@ def create_team(): log.debug("New create team request.") request_data = dict(request.POST) - if all_elements_on_request(request_data): - # Procceed with request. - log.debug("Request with correct fields, add to queue.") - if dispatcher.add_request_to_queue(request_data): - # Request was added to queue - return responder.confirm_create_team_command_reception() + if check_request_origin(request): + if all_elements_on_request(request_data): + # Procceed with request. + log.debug("Request with correct fields, add to queue.") + if dispatcher.add_request_to_queue(request_data): + # Request was added to queue + return responder.confirm_create_team_command_reception() + else: + # Request wasn't added to queue + return responder.overloaded_error() else: - # Request wasn't added to queue - return responder.overloaded_error() + # Inform user of incomplete request. + log.warn("Request with invalid payload was sent.") + return responder.default_error() else: - # Inform user of incomplete request. - log.warn("Request with invalid payload was sent.") - return responder.default_error() + # Could not validate user request + log.error("Slack request origin verification failed.") + try: + database.save_request_log(request_data, False, "Unverified origin.") + except exceptions.SaveRequestLogError: + log.error("Failed to save request log.") + return responder.unverified_origin_error() def join_team(): """Handler to join team request.""" log.debug("New join team request.") request_data = dict(request.POST) - if all_elements_on_request(request_data): - # Procceed with request. - log.debug("Request with correct fields, add to queue.") - if dispatcher.add_request_to_queue(request_data): - # Request was added to queue - return responder.confirm_join_team_command_reception() + if check_request_origin(request): + if all_elements_on_request(request_data): + # Procceed with request. + log.debug("Request with correct fields, add to queue.") + if dispatcher.add_request_to_queue(request_data): + # Request was added to queue + return responder.confirm_join_team_command_reception() + else: + # Request wasn't added to queue + return responder.overloaded_error() else: - # Request wasn't added to queue - return responder.overloaded_error() + # Inform user of incomplete request. + log.warn("Request with invalid payload was sent.") + return responder.default_error() else: - # Inform user of incomplete request. - log.warn("Request with invalid payload was sent.") - return responder.default_error() + # Could not validate user request + log.error("Slack request origin verification failed.") + try: + database.save_request_log(request_data, False, "Unverified origin.") + except exceptions.SaveRequestLogError: + log.error("Failed to save request log.") + return responder.unverified_origin_error() def check_balance(): """Handler to check balance request.""" log.debug("New check balance request.") request_data = dict(request.POST) - if all_elements_on_request(request_data): - # Procceed with request. - log.debug("Request with correct fields, add to queue.") - if dispatcher.add_request_to_queue(request_data): - # Request was added to queue - return responder.confirm_check_balance_command_reception() + if check_request_origin(request): + if all_elements_on_request(request_data): + # Procceed with request. + log.debug("Request with correct fields, add to queue.") + if dispatcher.add_request_to_queue(request_data): + # Request was added to queue + return responder.confirm_check_balance_command_reception() + else: + # Request wasn't added to queue + return responder.overloaded_error() else: - # Request wasn't added to queue - return responder.overloaded_error() + # Inform user of incomplete request. + log.warn("Request with invalid payload was sent.") + return responder.default_error() else: - # Inform user of incomplete request. - log.warn("Request with invalid payload was sent.") - return responder.default_error() + # Could not validate user request + log.error("Slack request origin verification failed.") + try: + database.save_request_log(request_data, False, "Unverified origin.") + except exceptions.SaveRequestLogError: + log.error("Failed to save request log.") + return responder.unverified_origin_error() def buy(): """Handler to buy request.""" log.debug("New buy request.") request_data = dict(request.POST) - if all_elements_on_request(request_data): - # Procceed with request. - log.debug("Request with correct fields, add to queue.") - if dispatcher.add_request_to_queue(request_data): - # Request was added to queue - return responder.confirm_buy_command_reception() + if check_request_origin(request): + if all_elements_on_request(request_data): + # Procceed with request. + log.debug("Request with correct fields, add to queue.") + if dispatcher.add_request_to_queue(request_data): + # Request was added to queue + return responder.confirm_buy_command_reception() + else: + # Request wasn't added to queue + return responder.overloaded_error() else: - # Request wasn't added to queue - return responder.overloaded_error() + # Inform user of incomplete request. + log.warn("Request with invalid payload was sent.") + return responder.default_error() else: - # Inform user of incomplete request. - log.warn("Request with invalid payload was sent.") - return responder.default_error() + # Could not validate user request + log.error("Slack request origin verification failed.") + try: + database.save_request_log(request_data, False, "Unverified origin.") + except exceptions.SaveRequestLogError: + log.error("Failed to save request log.") + return responder.unverified_origin_error() def list_transactions(): """Handler to list transactions request.""" log.debug("New list transactions request.") request_data = dict(request.POST) - if all_elements_on_request(request_data): - # Procceed with request. - log.debug("Request with correct fields, add to queue.") - if dispatcher.add_request_to_queue(request_data): - # Request was added to queue - return responder.confirm_list_transactions_command_reception() + if check_request_origin(request): + if all_elements_on_request(request_data): + # Procceed with request. + log.debug("Request with correct fields, add to queue.") + if dispatcher.add_request_to_queue(request_data): + # Request was added to queue + return responder.confirm_list_transactions_command_reception() + else: + # Request wasn't added to queue + return responder.overloaded_error() else: - # Request wasn't added to queue - return responder.overloaded_error() + # Inform user of incomplete request. + log.warn("Request with invalid payload was sent.") + return responder.default_error() else: - # Inform user of incomplete request. - log.warn("Request with invalid payload was sent.") - return responder.default_error() + # Could not validate user request + log.error("Slack request origin verification failed.") + try: + database.save_request_log(request_data, False, "Unverified origin.") + except exceptions.SaveRequestLogError: + log.error("Failed to save request log.") + return responder.unverified_origin_error() def all_elements_on_request(request_data): """Check if all elements (keys) are present in the request dictionary""" if all(k in request_data for k in SLACK_REQUEST_DATA_KEYS): return True return False + +def check_request_origin(request): + """Check if a request origin matches Slack definitions.""" + request_timestamp = request.get_header("X-Slack-Request-Timestamp") + if request_timestamp: + slack_signature = request.get_header("X-Slack-Signature") + if slack_signature: + if abs(float(request_timestamp) - time.time()) < SLACK_REQUEST_TIMESTAMP_MAX_GAP_MINUTES: + # Request within gap + request_body = request.body.read().decode("utf-8") + signing_secret = bytes(os.getenv("SLACK_SIGNING_SECRET"), "utf-8") + base_string = "v0:{}:{}".format(request_timestamp, request_body).encode("utf-8") + computed_signature = "v0=" + hmac.new(signing_secret, msg = base_string, digestmod=hashlib.sha256).hexdigest() + + if hmac.compare_digest(slack_signature, computed_signature): + log.debug("Request origin verified.") + return True + else: + log.critical("'X-Slack-Signature' header value and computed signature don't match.") + return False + else: + log.critical("Header 'X-Slack-Request-Timestamp' value is different than handler server. Refusing request.") + return False + else: + # No header + log.critical("Header 'X-Slack-Signature' not present. Refusing request.") + return False + else: + log.critical("Header 'X-Slack-Request-Timestamp' not present. Refusing request.") + return False diff --git a/src/responder.py b/src/responder.py index eb371e9..b1306ff 100644 --- a/src/responder.py +++ b/src/responder.py @@ -366,6 +366,15 @@ def overloaded_error(): } return json.dumps(response_content, ensure_ascii=False).encode("utf-8") +def unverified_origin_error(): + """Immediate default response to an overloaded error.""" + response.add_header("Content-Type", "application/json") + response_content = { + "text": "O teu pedido tem origens suspeitas...\nO teu pedido não pode ser processado.\nTenta novamente mais tarde ou pede ajuda no <#{}|suporte>." + .format(get_support_channel_id()), + } + return json.dumps(response_content, ensure_ascii=False).encode("utf-8") + def get_support_channel_id(): """Get slack support channel id.""" return os.getenv("SLACK_SUPPORT_CHANNEL_ID")