Projet

Général

Profil

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

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 | 38 +++++++++++++++
 tests/test_manager.py      |  4 +-
 3 files changed, 132 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 re
19 22
import logging
20
import requests
21 23
import urlparse
22 24
import hashlib
25
import hmac
26
import random
27
import requests
23 28
from html2text import HTML2Text
24 29
from emails.django import Message
25 30
from lxml import etree
26 31

  
32
from requests import RequestException
33

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

  
34 42

  
43
# Simple signature scheme for query strings
44

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

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

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

  
72

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

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

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

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

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

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

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

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

  
122
requests = Requests()
123

  
124

  
35 125
UNSUBSCRIBE_LINK_PLACEHOLDER = '%%UNSUBSCRIBE_LINK_PLACEHOLDER%%'
36 126

  
37 127

  
......
98 188
                sent = len(destinations)
99 189
            else:
100 190
                logger.warning('Error occured while sending sms: %s', response.json()['err_desc'])
101
        except requests.RequestException as e:
191
        except RequestException as e:
102 192
            logger.warning('Failed to reach SMS gateway: %s', e)
103 193
            return sent
104 194
    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
291 291
    # add mellon attribute to web session
292 292
    session = app.session
293 293
    session['mellon_session'] = {'mobile': ['00000000']}
294
    app.set_cookie(str(settings.SESSION_COOKIE_NAME), session.session_key)
294 295
    session.save()
295 296
    app.set_cookie(settings.SESSION_COOKIE_NAME, session.session_key)
296 297
    resp = resp.click('First announce')
......
347 348
    form['mobile'] = '0607080900'
348 349
    resp = form.submit()
349 350
    records = caplog.records
350
    assert len(records) == 1
351
    assert len(records) == 2
351 352
    for record in records:
352 353
        assert record.name == 'corbo.utils'
353 354
        assert record.levelno == logging.WARNING
354
        assert 'Invalid URL' in record.getMessage()
355
-