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), 21 avril 2018 14:32

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, Context
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

  
......
90 179
                'from': settings.SMS_EXPEDITOR}
91 180
        try:
92 181
            response = requests.post(settings.SMS_GATEWAY_URL, json=data, proxies=settings.REQUESTS_PROXIES)
93
            response.raise_for_status()
94 182
            if not response.json()['err']:
95 183
                # if no error returned by SMS gateway presume the that content
96 184
                # was delivered to all destinations
97 185
                sent = len(destinations)
98 186
            else:
99 187
                logger.warning('Error occured while sending sms: %s', response.json()['err_desc'])
100
        except requests.RequestException as e:
188
        except RequestException as e:
101 189
            logger.warning('Failed to reach SMS gateway: %s', e)
102 190
            return sent
103 191
    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
347 347
    form['mobile'] = '0607080900'
348 348
    resp = form.submit()
349 349
    records = caplog.records
350
    assert len(records) == 1
350
    assert len(records) == 2
351 351
    for record in records:
352 352
        assert record.name == 'corbo.utils'
353 353
        assert record.levelno == logging.WARNING
354
        assert 'Invalid URL' in record.getMessage()
355
-