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), 25 avril 2018 11:06

Télécharger (7,76 ko)

Voir les différences:

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

 corbo/utils.py             | 96 +++++++++++++++++++++++++++++++++++++-
 tests/test_broadcasting.py | 38 +++++++++++++++
 tests/test_manager.py      |  3 +-
 3 files changed, 133 insertions(+), 4 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
26
import requests
22 27
from html2text import HTML2Text
23 28
from emails.django import Message
24 29
from lxml import etree
25 30

  
31
from requests import RequestException
32

  
33

  
34

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

  
33 43

  
44
# Simple signature scheme for query strings
45

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

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

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

  
73

  
74
class Requests(requests.Session):
75
    def request(self, method, url, **kwargs):
76
        logger = logging.getLogger(__name__)
77
        log_errors = kwargs.pop('log_errors', True)
78

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

  
99
        if remote_service:
100
            query_params = {'orig': remote_service.get('orig')}
101

  
102
            remote_service_base_url = remote_service.get('url')
103
            scheme, netloc, old_path, params, old_query, fragment = urlparse.urlparse(
104
                    remote_service_base_url)
105

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

  
113
            url = urlparse.urlunparse((scheme, netloc, path, params, query, fragment))
114

  
115
        if remote_service: # sign
116
            url = sign_url(url, remote_service.get('secret'))
117

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

  
123
requests = Requests()
124

  
125

  
34 126
UNSUBSCRIBE_LINK_PLACEHOLDER = '%%UNSUBSCRIBE_LINK_PLACEHOLDER%%'
35 127

  
36 128

  
......
97 189
                sent = len(destinations)
98 190
            else:
99 191
                logger.warning('Error occured while sending sms: %s', response.json()['err_desc'])
100
        except requests.RequestException as e:
192
        except RequestException as e:
101 193
            logger.warning('Failed to reach SMS gateway: %s', e)
102 194
            return sent
103 195
    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('requests.Session.request')
246
def test_sms_send_with_signed_webservice_call(mocked_post, app, categories, announces, caplog):
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]
275

  
276
            mocked_response.raise_for_status.side_effect = requests.exceptions.HTTPError('Error 500')
277
            broadcast.send()
278
            for record in caplog.records:
279
                assert record.name == 'corbo.utils'
280
                assert record.levelno == logging.WARNING
281
                assert record.getMessage() == 'Failed to reach SMS gateway: Error 500'
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
-