From 4f5bb849d5e589c37c09d53a69ab9e517dade192 Mon Sep 17 00:00:00 2001 From: Serghei Mihai Date: Fri, 12 Jan 2018 17:35:10 +0100 Subject: [PATCH] sms: sign request to SMS gateway if it's a known service (#21004) --- corbo/utils.py | 94 +++++++++++++++++++++++++++++++++++++- tests/test_broadcasting.py | 38 +++++++++++++++ tests/test_manager.py | 4 +- 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/corbo/utils.py b/corbo/utils.py index 3c626b7..5547291 100644 --- a/corbo/utils.py +++ b/corbo/utils.py @@ -14,24 +14,114 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + +import base64 +import datetime import os import re import logging -import requests import urlparse import hashlib +import hmac +import random +import requests from html2text import HTML2Text from emails.django import Message from lxml import etree +from requests import RequestException + from django.conf import settings from django.template import loader, Context from django.utils.translation import activate +from django.utils.http import urlencode, quote from django.core.files.storage import DefaultStorage from django.core.urlresolvers import reverse from django.core import signing +# Simple signature scheme for query strings + +def sign_url(url, key, algo='sha256', timestamp=None, nonce=None): + parsed = urlparse.urlparse(url) + new_query = sign_query(parsed.query, key, algo, timestamp, nonce) + return urlparse.urlunparse(parsed[:4] + (new_query,) + parsed[5:]) + +def sign_query(query, key, algo='sha256', timestamp=None, nonce=None): + if timestamp is None: + timestamp = datetime.datetime.utcnow() + timestamp = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ') + if nonce is None: + nonce = hex(random.getrandbits(128))[2:] + new_query = query + if new_query: + new_query += '&' + new_query += urlencode(( + ('algo', algo), + ('timestamp', timestamp), + ('nonce', nonce))) + signature = base64.b64encode(sign_string(new_query, key, algo=algo)) + new_query += '&signature=' + quote(signature) + return new_query + +def sign_string(s, key, algo='sha256', timedelta=30): + digestmod = getattr(hashlib, algo) + hash = hmac.HMAC(str(key), digestmod=digestmod, msg=s) + return hash.digest() + + +class Requests(requests.Session): + def request(self, method, url, **kwargs): + logger = logging.getLogger(__name__) + log_errors = kwargs.pop('log_errors', True) + + remote_service = None + scheme, netloc, path, params, query, fragment = urlparse.urlparse(url) + for services in getattr(settings, 'KNOWN_SERVICES', {}).values(): + for service in services.values(): + remote_url = service.get('url') + remote_scheme, remote_netloc, r_path, r_params, r_query, r_fragment = \ + urlparse.urlparse(remote_url) + if remote_scheme == scheme and remote_netloc == netloc: + remote_service = service + break + else: + continue + break + if remote_service: + # only keeps the path (URI) in url parameter, scheme and netloc are + # in remote_service + url = urlparse.urlunparse(('', '', path, params, query, fragment)) + else: + logger.warning('service not found in settings.KNOWN_SERVICES for %s', url) + + if remote_service: + query_params = {'orig': remote_service.get('orig')} + + remote_service_base_url = remote_service.get('url') + scheme, netloc, old_path, params, old_query, fragment = urlparse.urlparse( + remote_service_base_url) + + query = urlencode(query_params) + if '?' in url: + path, old_query = url.split('?') + query += '&' + old_query + else: + path = url + + url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment)) + + if remote_service: # sign + url = sign_url(url, remote_service.get('secret')) + + response = super(Requests, self).request(method, url, **kwargs) + if log_errors and (response.status_code // 100 != 2): + logger.error('failed to %s %s (%s)', method, url, response.status_code) + return response + +requests = Requests() + + UNSUBSCRIBE_LINK_PLACEHOLDER = '%%UNSUBSCRIBE_LINK_PLACEHOLDER%%' @@ -98,7 +188,7 @@ def send_sms(content, destinations): sent = len(destinations) else: logger.warning('Error occured while sending sms: %s', response.json()['err_desc']) - except requests.RequestException as e: + except RequestException as e: logger.warning('Failed to reach SMS gateway: %s', e) return sent else: diff --git a/tests/test_broadcasting.py b/tests/test_broadcasting.py index 4b4fa74..775c4bb 100644 --- a/tests/test_broadcasting.py +++ b/tests/test_broadcasting.py @@ -241,3 +241,41 @@ def test_send_sms(mocked_post, app, categories, announces): assert mocked_post.call_args[1]['json']['from'] == 'Corbo' assert isinstance(mocked_post.call_args[1]['json']['to'], list) assert broadcast.delivery_count == 3 + +@mock.patch('requests.Session.request') +def test_sms_send_with_signed_webservice_call(mocked_post, app, categories, announces, caplog): + services = { + 'passerelle': { + 'default': {'title': 'test', 'url': 'http://passerelle.example.org', + 'secret': 'corbo', 'orig': 'corbo'} + }, + 'corbo': { + 'announces': { + 'title': 'announces', 'url': 'http://corbo.example.org', + 'secret': 'corbo', 'orig': 'corbo', + } + } + } + for category in categories: + for i in range(3): + uuid = uuid4() + Subscription.objects.create(category=category, + identifier='sms:%s' % get_random_number(), uuid=uuid) + for announce in announces: + broadcast = Broadcast.objects.get(announce=announce) + with override_settings(SMS_GATEWAY_URL='http://passerelle.example.org', KNOWN_SERVICES=services): + mocked_response = mock.Mock(status_code=200) + mocked_response.json.return_value = {'err': 0, 'err_desc': None, 'data': 'gateway response'} + mocked_post.return_value = mocked_response + broadcast.send() + assert 'http://passerelle.example.org' in mocked_post.call_args[0][1] + assert 'orig=corbo' in mocked_post.call_args[0][1] + assert 'signature=' in mocked_post.call_args[0][1] + assert 'nonce=' in mocked_post.call_args[0][1] + + mocked_response.raise_for_status.side_effect = requests.exceptions.HTTPError('Error 500') + broadcast.send() + for record in caplog.records: + assert record.name == 'corbo.utils' + assert record.levelno == logging.WARNING + assert record.getMessage() == 'Failed to reach SMS gateway: Error 500' diff --git a/tests/test_manager.py b/tests/test_manager.py index 85a2996..386f78a 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -291,6 +291,7 @@ def test_sms_announce(mocked_post, app, admin_user, settings): # add mellon attribute to web session session = app.session session['mellon_session'] = {'mobile': ['00000000']} + app.set_cookie(str(settings.SESSION_COOKIE_NAME), session.session_key) session.save() app.set_cookie(settings.SESSION_COOKIE_NAME, session.session_key) resp = resp.click('First announce') @@ -347,8 +348,7 @@ def test_sms_announce_with_invalid_gateway_url(app, admin_user, settings, caplog form['mobile'] = '0607080900' resp = form.submit() records = caplog.records - assert len(records) == 1 + assert len(records) == 2 for record in records: assert record.name == 'corbo.utils' assert record.levelno == logging.WARNING - assert 'Invalid URL' in record.getMessage() -- 2.17.0