Projet

Général

Profil

0001-sms-sign-request-to-SMS-gateway-if-it-s-a-known-serv.patch

Serghei Mihai (congés, retour 15/05), 12 janvier 2018 20:10

Télécharger (7,79 ko)

Voir les différences:

Subject: [PATCH] sms: sign request to SMS gateway if it's a known service
 (#21004)

 corbo/utils.py             | 94 ++++++++++++++++++++++++++++++++++++++++++++--
 tests/test_broadcasting.py | 31 +++++++++++++++
 tests/test_manager.py      |  3 +-
 3 files changed, 123 insertions(+), 5 deletions(-)
corbo/utils.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17

  
18
import base64
19
import datetime
17 20
import os
18 21
import logging
19
import requests
20 22
import urlparse
21 23
import hashlib
24
import hmac
25
import random
22 26
from html2text import HTML2Text
23 27
from emails.django import Message
24 28
from lxml import etree
25 29

  
30
from requests import Response, Session as RequestsSession, RequestException
31

  
26 32
from django.conf import settings
27 33
from django.template import loader
28 34
from django.utils.translation import activate
35
from django.utils.http import urlencode, quote
29 36
from django.core.files.storage import DefaultStorage
30 37
from django.core.urlresolvers import reverse
31 38
from django.core import signing
32 39

  
33 40

  
41
# Simple signature scheme for query strings
42

  
43
def sign_url(url, key, algo='sha256', timestamp=None, nonce=None):
44
    parsed = urlparse.urlparse(url)
45
    new_query = sign_query(parsed.query, key, algo, timestamp, nonce)
46
    return urlparse.urlunparse(parsed[:4] + (new_query,) + parsed[5:])
47

  
48
def sign_query(query, key, algo='sha256', timestamp=None, nonce=None):
49
    if timestamp is None:
50
        timestamp = datetime.datetime.utcnow()
51
    timestamp = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
52
    if nonce is None:
53
        nonce = hex(random.getrandbits(128))[2:]
54
    new_query = query
55
    if new_query:
56
        new_query += '&'
57
    new_query += urlencode((
58
        ('algo', algo),
59
        ('timestamp', timestamp),
60
        ('nonce', nonce)))
61
    signature = base64.b64encode(sign_string(new_query, key, algo=algo))
62
    new_query += '&signature=' + quote(signature)
63
    return new_query
64

  
65
def sign_string(s, key, algo='sha256', timedelta=30):
66
    digestmod = getattr(hashlib, algo)
67
    hash = hmac.HMAC(str(key), digestmod=digestmod, msg=s)
68
    return hash.digest()
69

  
70

  
71
class Requests(RequestsSession):
72
    def request(self, method, url, **kwargs):
73
        logger = logging.getLogger(__name__)
74
        log_errors = kwargs.pop('log_errors', True)
75

  
76
        remote_service = None
77
        scheme, netloc, path, params, query, fragment = urlparse.urlparse(url)
78
        for services in getattr(settings, 'KNOWN_SERVICES', {}).values():
79
            for service in services.values():
80
                remote_url = service.get('url')
81
                remote_scheme, remote_netloc, r_path, r_params, r_query, r_fragment = \
82
                        urlparse.urlparse(remote_url)
83
                if remote_scheme == scheme and remote_netloc == netloc:
84
                    remote_service = service
85
                    break
86
            else:
87
                continue
88
            break
89
        if remote_service:
90
            # only keeps the path (URI) in url parameter, scheme and netloc are
91
            # in remote_service
92
            url = urlparse.urlunparse(('', '', path, params, query, fragment))
93
        else:
94
            logger.warning('service not found in settings.KNOWN_SERVICES for %s', url)
95

  
96
        if remote_service:
97
            query_params = {'orig': remote_service.get('orig')}
98

  
99
            remote_service_base_url = remote_service.get('url')
100
            scheme, netloc, old_path, params, old_query, fragment = urlparse.urlparse(
101
                    remote_service_base_url)
102

  
103
            query = urlencode(query_params)
104
            if '?' in url:
105
                path, old_query = url.split('?')
106
                query += '&' + old_query
107
            else:
108
                path = url
109

  
110
            url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
111

  
112
        if remote_service: # sign
113
            url = sign_url(url, remote_service.get('secret'))
114

  
115
        response = super(Requests, self).request(method, url, **kwargs)
116
        if log_errors and (response.status_code // 100 != 2):
117
            logger.error('failed to %s %s (%s)', method, url, response.status_code)
118
        return response
119

  
120
requests = Requests()
121

  
122

  
34 123
UNSUBSCRIBE_LINK_PLACEHOLDER = '%%UNSUBSCRIBE_LINK_PLACEHOLDER%%'
35 124

  
36 125

  
......
89 178
                'from': settings.SMS_EXPEDITOR}
90 179
        try:
91 180
            response = requests.post(settings.SMS_GATEWAY_URL, json=data, proxies=settings.REQUESTS_PROXIES)
92
            response.raise_for_status()
93 181
            if not response.json()['err']:
94 182
                # if no error returned by SMS gateway presume the that content
95 183
                # was delivered to all destinations
96 184
                sent = len(destinations)
97 185
            else:
98 186
                logger.warning('Error occured while sending sms: %s', response.json()['err_desc'])
99
        except requests.RequestException as e:
187
        except RequestException as e:
100 188
            logger.warning('Failed to reach SMS gateway: %s', e)
101 189
            return sent
102 190
    else:
tests/test_broadcasting.py
241 241
            assert mocked_post.call_args[1]['json']['from'] == 'Corbo'
242 242
            assert isinstance(mocked_post.call_args[1]['json']['to'], list)
243 243
            assert broadcast.delivery_count == 3
244

  
245
@mock.patch('corbo.utils.RequestsSession.request')
246
def test_sms_send_with_signed_webservice_call(mocked_post, app, categories, announces):
247
    services = {
248
        'passerelle': {
249
            'default': {'title': 'test', 'url': 'http://passerelle.example.org',
250
                        'secret': 'corbo', 'orig': 'corbo'}
251
        },
252
        'corbo': {
253
            'announces': {
254
                'title': 'announces', 'url': 'http://corbo.example.org',
255
                'secret': 'corbo', 'orig': 'corbo',
256
            }
257
        }
258
    }
259
    for category in categories:
260
        for i in range(3):
261
            uuid = uuid4()
262
            Subscription.objects.create(category=category,
263
                            identifier='sms:%s' % get_random_number(), uuid=uuid)
264
    for announce in announces:
265
        broadcast = Broadcast.objects.get(announce=announce)
266
        with override_settings(SMS_GATEWAY_URL='http://passerelle.example.org', KNOWN_SERVICES=services):
267
            mocked_response = mock.Mock(status_code=200)
268
            mocked_response.json.return_value = {'err': 0, 'err_desc': None, 'data': 'gateway response'}
269
            mocked_post.return_value = mocked_response
270
            broadcast.send()
271
            assert 'http://passerelle.example.org' in mocked_post.call_args[0][1]
272
            assert 'orig=corbo' in mocked_post.call_args[0][1]
273
            assert 'signature=' in mocked_post.call_args[0][1]
274
            assert 'nonce=' in mocked_post.call_args[0][1]
tests/test_manager.py
338 338
    form['mobile'] = '0607080900'
339 339
    resp = form.submit()
340 340
    records = caplog.records
341
    assert len(records) == 1
341
    assert len(records) == 2
342 342
    for record in records:
343 343
        assert record.name == 'corbo.utils'
344 344
        assert record.levelno == logging.WARNING
345
        assert 'Invalid URL' in record.getMessage()
346
-