Hello community, here is the log from the commit of package python-mohawk for openSUSE:Factory checked in at 2019-01-11 14:06:07 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-mohawk (Old) and /work/SRC/openSUSE:Factory/.python-mohawk.new.28833 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-mohawk" Fri Jan 11 14:06:07 2019 rev:3 rq:664544 version:1.0.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-mohawk/python-mohawk.changes 2018-12-24 11:39:48.501536420 +0100 +++ /work/SRC/openSUSE:Factory/.python-mohawk.new.28833/python-mohawk.changes 2019-01-11 14:06:58.127710717 +0100 @@ -1,0 +2,24 @@ +Fri Jan 11 06:41:11 UTC 2019 - [email protected] + +- Update to version 1.0.0: + * Security related: Bewit MACs were not compared in constant time + and were thus possibly circumventable by an attacker. + * Breaking change: Escape characters in header values (such as a + back slash) are no longer allowed, potentially breaking clients + that depended on this behavior. + * A sender is allowed to omit the content hash as long as their + request has no content. The `mohawk.Receiver` will skip the + content hash check in this situation, regardless of the value + of accept_untrusted_content. + * Introduced max limit of 4096 characters in the Authorization + header. + * Changed default values of content and content_type arguments to + `mohawk.base.EmptyValue` in order to differentiate between + misconfiguration and cases where these arguments are explicitly + given as None (as with some web frameworks). + * Failing to pass content and content_type arguments to + `mohawk.Receiver` or `mohawk.Sender.accept_response` without + specifying accept_untrusted_content=True will now raise + `mohawk.exc.MissingContent` instead of `ValueError`. + +------------------------------------------------------------------- Old: ---- mohawk-0.3.4.tar.gz New: ---- mohawk-1.0.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-mohawk.spec ++++++ --- /var/tmp/diff_new_pack.Eq5CpO/_old 2019-01-11 14:06:58.583710259 +0100 +++ /var/tmp/diff_new_pack.Eq5CpO/_new 2019-01-11 14:06:58.583710259 +0100 @@ -1,7 +1,7 @@ # # spec file for package python-mohawk # -# Copyright (c) 2018 SUSE LINUX GmbH, Nuernberg, Germany. +# Copyright (c) 2019 SUSE LINUX GmbH, Nuernberg, Germany. # Copyright (c) 2017 The openSUSE Project. # # All modifications and additions to the file contributed by third parties @@ -20,7 +20,7 @@ %{?!python_module:%define python_module() python-%{**} python3-%{**}} %bcond_without test Name: python-mohawk -Version: 0.3.4 +Version: 1.0.0 Release: 0 Summary: Library for Hawk HTTP authorization License: MPL-2.0 ++++++ mohawk-0.3.4.tar.gz -> mohawk-1.0.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/PKG-INFO new/mohawk-1.0.0/PKG-INFO --- old/mohawk-0.3.4/PKG-INFO 2017-01-07 21:39:21.000000000 +0100 +++ new/mohawk-1.0.0/PKG-INFO 2019-01-09 23:01:05.000000000 +0100 @@ -1,12 +1,23 @@ Metadata-Version: 1.1 Name: mohawk -Version: 0.3.4 +Version: 1.0.0 Summary: Library for Hawk HTTP authorization Home-page: https://github.com/kumar303/mohawk Author: Kumar McMillan, Austin King Author-email: [email protected] License: MPL 2.0 (Mozilla Public License) -Description: UNKNOWN +Description: + Hawk lets two parties securely communicate with each other using + messages signed by a shared key. + It is based on HTTP MAC access authentication (which + was based on parts of OAuth 1.0). + + The Mohawk API is a little different from that of the Node library + (i.e. https://github.com/hueniverse/hawk). + It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. + + Read more: https://github.com/kumar303/mohawk/ + Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: Natural Language :: English @@ -15,5 +26,8 @@ Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 -Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 Classifier: Topic :: Internet :: WWW/HTTP diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/README.rst new/mohawk-1.0.0/README.rst --- old/mohawk-0.3.4/README.rst 2016-07-12 22:25:50.000000000 +0200 +++ new/mohawk-1.0.0/README.rst 2019-01-09 06:30:25.000000000 +0100 @@ -20,6 +20,17 @@ Mohawk is an alternate Python implementation of the `Hawk HTTP authorization scheme`_. +Hawk lets two parties securely communicate with each other using +messages signed by a shared key. +It is based on `HTTP MAC access authentication`_ (which +was based on parts of `OAuth 1.0`_). + +The Mohawk API is a little different from that of the Node library +(i.e. `the living Hawk spec <https://github.com/hueniverse/hawk>`_). +It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. + Full documentation: https://mohawk.readthedocs.io/ .. _`Hawk HTTP authorization scheme`: https://github.com/hueniverse/hawk +.. _`HTTP MAC access authentication`: http://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 +.. _`OAuth 1.0`: http://tools.ietf.org/html/rfc5849 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/base.py new/mohawk-1.0.0/mohawk/base.py --- old/mohawk-0.3.4/mohawk/base.py 2016-03-09 18:15:46.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/base.py 2019-01-09 22:03:48.000000000 +0100 @@ -8,7 +8,8 @@ from .exc import (AlreadyProcessed, MacMismatch, MisComputedContentHash, - TokenExpired) + TokenExpired, + MissingContent) from .util import (calculate_mac, calculate_payload_hash, calculate_ts_mac, @@ -21,6 +22,26 @@ log = logging.getLogger(__name__) +class HawkEmptyValue(object): + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __ne__(self, other): + return (not self.__eq__(other)) + + def __nonzero__(self): + return False + + def __bool__(self): + return False + + def __repr__(self): + return 'EmptyValue' + +EmptyValue = HawkEmptyValue() + + class HawkAuthority: def _authorize(self, mac_type, parsed_header, resource, @@ -39,20 +60,30 @@ 'theirs: {theirs}' .format(ours=mac, theirs=their_mac)) - if 'hash' not in parsed_header and accept_untrusted_content: - # The request did not hash its content. - log.debug('NOT calculating/verifiying payload hash ' - '(no hash in header)') - check_hash = False - content_hash = None - else: - check_hash = True - content_hash = resource.gen_content_hash() + check_hash = True - if check_hash and not their_hash: - log.info('request unexpectedly did not hash its content') + if 'hash' not in parsed_header: + # The request did not hash its content. + if not resource.content and not resource.content_type: + # It is acceptable to not receive a hash if there is no content + # to hash. + log.debug('NOT calculating/verifying payload hash ' + '(no hash in header, request body is empty)') + check_hash = False + elif accept_untrusted_content: + # Allow the request, even if it has content. Missing content or + # content_type values will be coerced to the empty string for + # hashing purposes. + log.debug('NOT calculating/verifying payload hash ' + '(no hash in header, accept_untrusted_content=True)') + check_hash = False if check_hash: + if not their_hash: + log.info('request unexpectedly did not hash its content') + + content_hash = resource.gen_content_hash() + if not strings_match(content_hash, their_hash): # The hash declared in the header is incorrect. # Content could have been tampered with. @@ -77,8 +108,8 @@ ts=parsed_header['ts'], id=resource.credentials['id'])) else: - log.warn('seen_nonce was None; not checking nonce. ' - 'You may be vulnerable to replay attacks') + log.warning('seen_nonce was None; not checking nonce. ' + 'You may be vulnerable to replay attacks') their_ts = int(their_timestamp or parsed_header['ts']) @@ -147,14 +178,67 @@ class Resource: """ - Normalized request/response resource. + Normalized request / response resource. + + :param credentials: + A dict of credentials; it must have the keys: + ``id``, ``key``, and ``algorithm``. + See :ref:`sending-request` for an example. + :type credentials_map: dict + + :param url: Absolute URL of the request / response. + :type url: str + + :param method: Method of the request / response. E.G. POST, GET + :type method: str + + :param content=EmptyValue: Byte string of request / response body. + :type content=EmptyValue: str + + :param content_type=EmptyValue: content-type header value for request / response. + :type content_type=EmptyValue: str + + :param always_hash_content=True: + When True, ``content`` and ``content_type`` must be provided. + Read :ref:`skipping-content-checks` to learn more. + :type always_hash_content=True: bool + + :param ext=None: + An external `Hawk`_ string. If not None, this value will be + signed so that the sender can trust it. + :type ext=None: str + + :param app=None: + A `Hawk`_ string identifying an external application. + :type app=None: str + + :param dlg=None: + A `Hawk`_ string identifying a "delegated by" value. + :type dlg=None: str + + :param timestamp=utc_now(): + A unix timestamp integer, in UTC + :type timestamp: int + + :param nonce=None: + A string that when coupled with the timestamp will + uniquely identify this request / response. + :type nonce=None: str + + :param seen_nonce=None: + A callable that returns True if a nonce has been seen. + See :ref:`nonce` for details. + :type seen_nonce=None: callable + + .. _`Hawk`: https://github.com/hueniverse/hawk """ def __init__(self, **kw): self.credentials = kw.pop('credentials') + self.credentials['id'] = prepare_header_val(self.credentials['id']) self.method = kw.pop('method').upper() - self.content = kw.pop('content', None) - self.content_type = kw.pop('content_type', None) + self.content = kw.pop('content', EmptyValue) + self.content_type = kw.pop('content_type', EmptyValue) self.always_hash_content = kw.pop('always_hash_content', True) self.ext = kw.pop('ext', None) self.app = kw.pop('app', None) @@ -192,14 +276,14 @@ return self._content_hash def gen_content_hash(self): - if self.content is None or self.content_type is None: + if self.content == EmptyValue or self.content_type == EmptyValue: if self.always_hash_content: # Be really strict about allowing developers to skip content # hashing. If they get this far they may be unintentiionally # skipping it. - raise ValueError( + raise MissingContent( 'payload content and/or content_type cannot be ' - 'empty without an explicit allowance') + 'empty when always_hash_content is True') log.debug('NOT hashing content') self._content_hash = None else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/bewit.py new/mohawk-1.0.0/mohawk/bewit.py --- old/mohawk-0.3.4/mohawk/bewit.py 2016-02-25 06:18:56.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/bewit.py 2019-01-09 21:26:13.000000000 +0100 @@ -7,7 +7,9 @@ from .base import Resource from .util import (calculate_mac, - utc_now) + strings_match, + utc_now, + validate_header_attr) from .exc import (CredentialsLookupError, InvalidBewit, MacMismatch, @@ -40,25 +42,15 @@ if resource.ext is None: ext = '' else: + validate_header_attr(resource.ext, name='ext') ext = resource.ext - # Strip out \ from the client id - # since that can break parsing the response - # NB that the canonical implementation does not do this as of - # Oct 28, 2015, so this could break compat. - # We can leave \ in ext since validators can limit how many \ they split - # on (although again, the canonical implementation does not do this) - client_id = six.text_type(resource.credentials['id']) - if "\\" in client_id: - log.warn("Stripping backslash character(s) '\\' from client_id") - client_id = client_id.replace("\\", "") - # b64encode works only with bytes in python3, but all of our parameters are # in unicode, so we need to encode them. The cleanest way to do this that # works in both python 2 and 3 is to use string formatting to get a # unicode string, and then explicitly encode it to bytes. inner_bewit = u"{id}\\{exp}\\{mac}\\{ext}".format( - id=client_id, + id=resource.credentials['id'], exp=resource.timestamp, mac=mac, ext=ext, @@ -83,10 +75,10 @@ :type bewit: str """ decoded_bewit = b64decode(bewit).decode('ascii') - bewit_parts = decoded_bewit.split("\\", 3) + bewit_parts = decoded_bewit.split("\\") if len(bewit_parts) != 4: raise InvalidBewit('Expected 4 parts to bewit: %s' % decoded_bewit) - return bewittuple(*decoded_bewit.split("\\", 3)) + return bewittuple(*bewit_parts) def strip_bewit(url): @@ -146,7 +138,7 @@ mac = calculate_mac('bewit', res, None) mac = mac.decode('ascii') - if mac != bewit.mac: + if not strings_match(mac, bewit.mac): raise MacMismatch('bewit with mac {bewit_mac} did not match expected mac {expected_mac}' .format(bewit_mac=bewit.mac, expected_mac=mac)) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/exc.py new/mohawk-1.0.0/mohawk/exc.py --- old/mohawk-0.3.4/mohawk/exc.py 2017-01-07 21:33:03.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/exc.py 2018-11-02 22:44:22.000000000 +0100 @@ -1,6 +1,11 @@ """ If you want to catch any exception that might be raised, catch :class:`mohawk.exc.HawkFail`. + +.. important:: + + Never expose an exception message publicly, say, in an HTTP + response, as it may provide hints to an attacker. """ @@ -96,3 +101,11 @@ The bewit is invalid; e.g. it doesn't contain the right number of parameters. """ + + +class MissingContent(HawkFail): + """ + A payload's `content` or `content_type` were not provided. + + See :ref:`skipping-content-checks` for details. + """ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/receiver.py new/mohawk-1.0.0/mohawk/receiver.py --- old/mohawk-0.3.4/mohawk/receiver.py 2017-01-07 21:33:03.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/receiver.py 2018-11-02 22:44:22.000000000 +0100 @@ -1,7 +1,10 @@ import logging import sys -from .base import default_ts_skew_in_seconds, HawkAuthority, Resource +from .base import (default_ts_skew_in_seconds, + HawkAuthority, + Resource, + EmptyValue) from .exc import CredentialsLookupError, MissingAuthorization from .util import (calculate_mac, parse_authorization_header, @@ -33,17 +36,15 @@ :param method: Method of the request. E.G. POST, GET :type method: str - :param content=None: Byte string of request body. - :type content=None: str + :param content=EmptyValue: Byte string of request body. + :type content=EmptyValue: str - :param content_type=None: content-type header value for request. - :type content_type=None: str + :param content_type=EmptyValue: content-type header value for request. + :type content_type=EmptyValue: str :param accept_untrusted_content=False: - When True, allow requests that do not hash their content or - allow None type ``content`` and ``content_type`` - arguments. Read :ref:`skipping-content-checks` - to learn more. + When True, allow requests that do not hash their content. + Read :ref:`skipping-content-checks` to learn more. :type accept_untrusted_content=False: bool :param localtime_offset_in_seconds=0: @@ -65,8 +66,8 @@ request_header, url, method, - content=None, - content_type=None, + content=EmptyValue, + content_type=EmptyValue, seen_nonce=None, localtime_offset_in_seconds=0, accept_untrusted_content=False, @@ -120,8 +121,8 @@ self.resource = resource def respond(self, - content=None, - content_type=None, + content=EmptyValue, + content_type=EmptyValue, always_hash_content=True, ext=None): """ @@ -130,14 +131,14 @@ This generates the :attr:`mohawk.Receiver.response_header` attribute. - :param content=None: Byte string of response body that will be sent. - :type content=None: str + :param content=EmptyValue: Byte string of response body that will be sent. + :type content=EmptyValue: str - :param content_type=None: content-type header value for response. - :type content_type=None: str + :param content_type=EmptyValue: content-type header value for response. + :type content_type=EmptyValue: str :param always_hash_content=True: - When True, ``content`` and ``content_type`` cannot be None. + When True, ``content`` and ``content_type`` must be provided. Read :ref:`skipping-content-checks` to learn more. :type always_hash_content=True: bool diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/sender.py new/mohawk-1.0.0/mohawk/sender.py --- old/mohawk-0.3.4/mohawk/sender.py 2015-06-21 20:42:01.000000000 +0200 +++ new/mohawk-1.0.0/mohawk/sender.py 2018-11-02 22:44:22.000000000 +0100 @@ -1,6 +1,9 @@ import logging -from .base import default_ts_skew_in_seconds, HawkAuthority, Resource +from .base import (default_ts_skew_in_seconds, + HawkAuthority, + Resource, + EmptyValue) from .util import (calculate_mac, parse_authorization_header, validate_credentials) @@ -23,14 +26,14 @@ :param method: Method of the request. E.G. POST, GET :type method: str - :param content=None: Byte string of request body. - :type content=None: str + :param content=EmptyValue: Byte string of request body. + :type content=EmptyValue: str - :param content_type=None: content-type header value for request. - :type content_type=None: str + :param content_type=EmptyValue: content-type header value for request. + :type content_type=EmptyValue: str :param always_hash_content=True: - When True, ``content`` and ``content_type`` cannot be None. + When True, ``content`` and ``content_type`` must be provided. Read :ref:`skipping-content-checks` to learn more. :type always_hash_content=True: bool @@ -68,8 +71,8 @@ def __init__(self, credentials, url, method, - content=None, - content_type=None, + content=EmptyValue, + content_type=EmptyValue, always_hash_content=True, nonce=None, ext=None, @@ -102,8 +105,8 @@ def accept_response(self, response_header, - content=None, - content_type=None, + content=EmptyValue, + content_type=EmptyValue, accept_untrusted_content=False, localtime_offset_in_seconds=0, timestamp_skew_in_seconds=default_ts_skew_in_seconds, @@ -116,18 +119,16 @@ such as one created by :class:`mohawk.Receiver`. :type response_header: str - :param content=None: Byte string of the response body received. - :type content=None: str + :param content=EmptyValue: Byte string of the response body received. + :type content=EmptyValue: str - :param content_type=None: + :param content_type=EmptyValue: Content-Type header value of the response received. - :type content_type=None: str + :type content_type=EmptyValue: str :param accept_untrusted_content=False: - When True, allow responses that do not hash their content or - allow None type ``content`` and ``content_type`` - arguments. Read :ref:`skipping-content-checks` - to learn more. + When True, allow responses that do not hash their content. + Read :ref:`skipping-content-checks` to learn more. :type accept_untrusted_content=False: bool :param localtime_offset_in_seconds=0: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/tests.py new/mohawk-1.0.0/mohawk/tests.py --- old/mohawk-0.3.4/mohawk/tests.py 2017-01-07 21:33:03.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/tests.py 2019-01-09 21:26:13.000000000 +0100 @@ -1,4 +1,5 @@ import sys +import warnings from unittest import TestCase from base64 import b64decode, urlsafe_b64encode @@ -7,16 +8,18 @@ import six from . import Receiver, Sender -from .base import Resource +from .base import Resource, EmptyValue from .exc import (AlreadyProcessed, BadHeaderValue, CredentialsLookupError, + HawkFail, InvalidCredentials, MacMismatch, MisComputedContentHash, MissingAuthorization, TokenExpired, - InvalidBewit) + InvalidBewit, + MissingContent) from .util import (parse_authorization_header, utc_now, calculate_ts_mac, @@ -27,6 +30,10 @@ parse_bewit) +# Ensure deprecation warnings are turned to exceptions +warnings.filterwarnings('error') + + class Base(TestCase): def setUp(self): @@ -136,29 +143,64 @@ self.receive(sn.request_header, method=method, content=content, content_type='application/json; charset=other') - @raises(ValueError) + @raises(MissingContent) def test_missing_payload_details(self): - self.Sender(method='POST', content=None, content_type=None) + self.Sender(method='POST', content=EmptyValue, + content_type=EmptyValue) def test_skip_payload_hashing(self): method = 'POST' content = '{"bar": "foobs"}' content_type = 'application/json' - sn = self.Sender(method=method, content=None, content_type=None, + sn = self.Sender(method=method, content=EmptyValue, + content_type=EmptyValue, always_hash_content=False) + self.assertFalse('hash="' in sn.request_header) self.receive(sn.request_header, method=method, content=content, content_type=content_type, accept_untrusted_content=True) - @raises(ValueError) + def test_empty_payload_hashing(self): + method = 'GET' + content = None + content_type = None + sn = self.Sender(method=method, content=content, + content_type=content_type) + self.assertTrue('hash="' in sn.request_header) + self.receive(sn.request_header, method=method, content=content, + content_type=content_type) + + def test_empty_payload_hashing_always_hash_false(self): + method = 'GET' + content = None + content_type = None + sn = self.Sender(method=method, content=content, + content_type=content_type, + always_hash_content=False) + self.assertTrue('hash="' in sn.request_header) + self.receive(sn.request_header, method=method, content=content, + content_type=content_type) + + def test_empty_payload_hashing_accept_untrusted(self): + method = 'GET' + content = None + content_type = None + sn = self.Sender(method=method, content=content, + content_type=content_type) + self.assertTrue('hash="' in sn.request_header) + self.receive(sn.request_header, method=method, content=content, + content_type=content_type, + accept_untrusted_content=True) + + @raises(MissingContent) def test_cannot_skip_content_only(self): - self.Sender(method='POST', content=None, + self.Sender(method='POST', content=EmptyValue, content_type='application/json') - @raises(ValueError) + @raises(MissingContent) def test_cannot_skip_content_type_only(self): self.Sender(method='POST', content='{"foo": "bar"}', - content_type=None) + content_type=EmptyValue) @raises(MacMismatch) def test_tamper_with_host(self): @@ -308,17 +350,21 @@ header = sn.request_header.replace('my external data', 'TAMPERED') self.receive(header) + @raises(BadHeaderValue) + def test_duplicate_keys(self): + sn = self.Sender(ext='someext') + header = sn.request_header + ', ext="otherext"' + self.receive(header) + + @raises(BadHeaderValue) def test_ext_with_quotes(self): sn = self.Sender(ext='quotes=""') self.receive(sn.request_header) - parsed = parse_authorization_header(sn.request_header) - eq_(parsed['ext'], 'quotes=""') + @raises(BadHeaderValue) def test_ext_with_new_line(self): sn = self.Sender(ext="new line \n in the middle") self.receive(sn.request_header) - parsed = parse_authorization_header(sn.request_header) - eq_(parsed['ext'], "new line \n in the middle") def test_ext_with_equality_sign(self): sn = self.Sender(ext="foo=bar&foo2=bar2;foo3=bar3") @@ -326,19 +372,47 @@ parsed = parse_authorization_header(sn.request_header) eq_(parsed['ext'], "foo=bar&foo2=bar2;foo3=bar3") + @raises(HawkFail) + def test_non_hawk_scheme(self): + parse_authorization_header('Basic user:base64pw') + + @raises(HawkFail) + def test_invalid_key(self): + parse_authorization_header('Hawk mac="validmac" unknownkey="value"') + + def test_ext_with_all_valid_characters(self): + valid_characters = "!#$%&'()*+,-./:;<=>?@[]^_`{|}~ azAZ09_" + sender = self.Sender(ext=valid_characters) + parsed = parse_authorization_header(sender.request_header) + eq_(parsed['ext'], valid_characters) + @raises(BadHeaderValue) def test_ext_with_illegal_chars(self): self.Sender(ext="something like \t is illegal") + def test_unparseable_header(self): + try: + parse_authorization_header('Hawk mac="somemac", unparseable') + except BadHeaderValue as exc: + error_msg = str(exc) + self.assertTrue("Couldn't parse Hawk header" in error_msg) + self.assertTrue("unparseable" in error_msg) + else: + self.fail('should raise') + @raises(BadHeaderValue) def test_ext_with_illegal_unicode(self): self.Sender(ext=u'Ivan Kristi\u0107') @raises(BadHeaderValue) + def test_too_long_header(self): + sn = self.Sender(ext='a'*5000) + self.receive(sn.request_header) + + @raises(BadHeaderValue) def test_ext_with_illegal_utf8(self): # This isn't allowed because the escaped byte chars are out of - # range. It's a little odd but this is what the Node lib does - # implicitly with its regex. + # range. self.Sender(ext=u'Ivan Kristi\u0107'.encode('utf8')) def test_app_ok(self): @@ -560,24 +634,125 @@ wrong_sender = self.sender self.receive(content='TAMPERED WITH', sender=wrong_sender) + def test_expected_unhashed_empty_content(self): + # This test sets up a scenario where the receiver will receive empty + # strings for content and content_type and no content hash in the auth + # header. + # This is to account for callers that might provide empty strings for + # the payload when in fact there is literally no content. In this case, + # mohawk depends on the presence of the content hash in the auth header + # to determine how to treat the empty strings: no hash in the header + # implies that no hashing is expected to occur on the server. + self.receive(content='', + content_type='', + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, + always_hash_content=False)) + @raises(MisComputedContentHash) - def test_unexpected_unhashed_content(self): - self.receive(sender_kw=dict(content=None, content_type=None, + def test_expected_unhashed_empty_content_with_content_type(self): + # This test sets up a scenario where the receiver will receive an + # empty content string and no content hash in the auth header, but + # some value for content_type. + # This is to confirm that the hash is calculated and compared (to the + # hash of mock empty payload, which should fail) when it appears that + # the sender has sent a 0-length payload body. + self.receive(content='', + content_type='text/plain', + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, always_hash_content=False)) - @raises(ValueError) + @raises(MisComputedContentHash) + def test_expected_unhashed_content_with_empty_content_type(self): + # This test sets up a scenario where the receiver will receive some + # content but the empty string for the content_type and no content hash + # in the auth header. + # This is to confirm that the hash is calculated and compared (to the + # hash of mock empty payload, which should fail) when the sender has + # sent unhashed content. + self.receive(content='some content', + content_type='', + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, + always_hash_content=False)) + + def test_empty_content_with_content_type(self): + # This test sets up a scenario where the receiver will receive an + # empty content string, some value for content_type and a content hash. + # This is to confirm that the hash is calculated and compared correctly + # when the sender has sent a hashed 0-length payload body. + self.receive(content='', + content_type='text/plain', + sender_kw=dict(content='', + content_type='text/plain')) + + def test_expected_unhashed_no_content(self): + # This test sets up a scenario where the receiver will receive None for + # content and content_type and no content hash in the auth header. + # This is like test_expected_unhashed_empty_content(), but tests for + # the less ambiguous case where the caller has explicitly passed in None + # to indicate that there is no content to hash. + self.receive(content=None, + content_type=None, + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, + always_hash_content=False)) + + @raises(MisComputedContentHash) + def test_expected_unhashed_no_content_with_content_type(self): + # This test sets up a scenario where the receiver will receive None for + # content and no content hash in the auth header, but some value for + # content_type. + # In this case, the content will be coerced to the empty string for + # hashing purposes. The request should fail, as the there is no content + # hash in the request to compare against. While this may not be in + # accordance with the js reference spec, it's the safest (ie. most + # secure) way of handling this bizarre set of circumstances. + self.receive(content=None, + content_type='text/plain', + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, + always_hash_content=False)) + + @raises(MisComputedContentHash) + def test_expected_unhashed_content_with_no_content_type(self): + # This test sets up a scenario where the receiver will receive some + # content but no value for the content_type and no content hash in + # the auth header. + # This is to confirm that the hash is calculated and compared (to the + # hash of mock empty payload, which should fail) when the sender has + # sent unhashed content. + self.receive(content='some content', + content_type=None, + sender_kw=dict(content=EmptyValue, + content_type=EmptyValue, + always_hash_content=False)) + + def test_no_content_with_content_type(self): + # This test sets up a scenario where the receiver will receive None for + # the content string, some value for content_type and a content hash. + # This is to confirm that coercing None to the empty string when a hash + # is expected allows the hash to be calculated and compared correctly + # as if the sender has sent a hashed 0-length payload body. + self.receive(content=None, + content_type='text/plain', + sender_kw=dict(content='', + content_type='text/plain')) + + @raises(MissingContent) def test_cannot_receive_empty_content_only(self): content_type = 'text/plain' self.receive(sender_kw=dict(content='<content>', content_type=content_type), - content=None, content_type=content_type) + content=EmptyValue, content_type=content_type) - @raises(ValueError) + @raises(MissingContent) def test_cannot_receive_empty_content_type_only(self): content = '<content>' self.receive(sender_kw=dict(content=content, content_type='text/plain'), - content=content, content_type=None) + content=content, content_type=EmptyValue) @raises(MisComputedContentHash) def test_receive_wrong_content_type(self): @@ -684,19 +859,24 @@ expected = '123456\\1356420707\\kscxwNR2tJpP1T1zDLNPbB5UiKIU9tOSJXTUdG7X9h8=\\xandyandz' eq_(b64decode(bewit).decode('ascii'), expected) - def test_bewit_with_ext_and_backslashes(self): - credentials = self.credentials - credentials['id'] = '123\\456' + @raises(BadHeaderValue) + def test_bewit_with_invalid_ext(self): res = Resource(url='https://example.com/somewhere/over/the/rainbow', method='GET', credentials=self.credentials, timestamp=1356420407 + 300, nonce='', - ext='xand\\yandz' - ) - bewit = get_bewit(res) + ext='xand\\yandz') + get_bewit(res) - expected = '123456\\1356420707\\b82LLIxG5UDkaChLU953mC+SMrbniV1sb8KiZi9cSsc=\\xand\\yandz' - eq_(b64decode(bewit).decode('ascii'), expected) + @raises(BadHeaderValue) + def test_bewit_with_backslashes_in_id(self): + credentials = self.credentials + credentials['id'] = '123\\456' + res = Resource(url='https://example.com/somewhere/over/the/rainbow', + method='GET', credentials=self.credentials, + timestamp=1356420407 + 300, + nonce='') + get_bewit(res) def test_bewit_with_port(self): res = Resource(url='https://example.com:8080/somewhere/over/the/rainbow', @@ -728,8 +908,8 @@ url = "https://example.com/somewhere/over/the/rainbow?bewit={bewit}".format(bewit=bewit) raw_bewit, stripped_url = strip_bewit(url) - self.assertEquals(raw_bewit, bewit) - self.assertEquals(stripped_url, "https://example.com/somewhere/over/the/rainbow") + self.assertEqual(raw_bewit, bewit) + self.assertEqual(stripped_url, "https://example.com/somewhere/over/the/rainbow") @raises(InvalidBewit) def test_strip_url_without_bewit(self): @@ -740,28 +920,25 @@ bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\' bewit = urlsafe_b64encode(bewit).decode('ascii') bewit = parse_bewit(bewit) - self.assertEquals(bewit.id, '123456') - self.assertEquals(bewit.expiration, '1356420707') - self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') - self.assertEquals(bewit.ext, '') + self.assertEqual(bewit.id, '123456') + self.assertEqual(bewit.expiration, '1356420707') + self.assertEqual(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') + self.assertEqual(bewit.ext, '') def test_parse_bewit_with_ext(self): bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xandyandz' bewit = urlsafe_b64encode(bewit).decode('ascii') bewit = parse_bewit(bewit) - self.assertEquals(bewit.id, '123456') - self.assertEquals(bewit.expiration, '1356420707') - self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') - self.assertEquals(bewit.ext, 'xandyandz') + self.assertEqual(bewit.id, '123456') + self.assertEqual(bewit.expiration, '1356420707') + self.assertEqual(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') + self.assertEqual(bewit.ext, 'xandyandz') + @raises(InvalidBewit) def test_parse_bewit_with_ext_and_backslashes(self): bewit = b'123456\\1356420707\\IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=\\xand\\yandz' bewit = urlsafe_b64encode(bewit).decode('ascii') - bewit = parse_bewit(bewit) - self.assertEquals(bewit.id, '123456') - self.assertEquals(bewit.expiration, '1356420707') - self.assertEquals(bewit.mac, 'IGYmLgIqLrCe8CxvKPs4JlWIA+UjWJJouwgARiVhCAg=') - self.assertEquals(bewit.ext, 'xand\\yandz') + parse_bewit(bewit) @raises(InvalidBewit) def test_parse_invalid_bewit_with_only_one_part(self): @@ -793,6 +970,7 @@ }) self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) + @raises(InvalidBewit) def test_validate_bewit_with_ext_and_backslashes(self): bewit = b'123456\\1356420707\\b82LLIxG5UDkaChLU953mC+SMrbniV1sb8KiZi9cSsc=\\xand\\yandz' bewit = urlsafe_b64encode(bewit).decode('ascii') @@ -800,7 +978,7 @@ credential_lookup = self.make_credential_lookup({ self.credentials['id']: self.credentials, }) - self.assertTrue(check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10)) + check_bewit(url, credential_lookup=credential_lookup, now=1356420407 + 10) @raises(TokenExpired) def test_validate_expired_bewit(self): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk/util.py new/mohawk-1.0.0/mohawk/util.py --- old/mohawk-0.3.4/mohawk/util.py 2017-01-07 21:33:03.000000000 +0100 +++ new/mohawk-1.0.0/mohawk/util.py 2017-04-17 22:58:13.000000000 +0200 @@ -19,6 +19,8 @@ HAWK_VER = 1 +HAWK_HEADER_RE = re.compile(r'(?P<key>\w+)=\"(?P<value>[^\"\\]*)\"\s*(?:,\s*|$)') +MAX_LENGTH = 4096 log = logging.getLogger(__name__) allowable_header_keys = set(['id', 'ts', 'tsm', 'nonce', 'hash', 'error', 'ext', 'mac', 'app', 'dlg']) @@ -149,45 +151,42 @@ 'Hawk id="dh37fgj492je", ts="1367076201", nonce="NPHgnG", ext="and welcome!", mac="CeWHy4d9kbLGhDlkyw2Nh3PJ7SDOdZDa267KH4ZaNMY="' """ - attributes = {} + if len(auth_header) > MAX_LENGTH: + raise BadHeaderValue('Header exceeds maximum length of {max_length}'.format( + max_length=MAX_LENGTH)) # Make sure we have a unicode object for consistency. if isinstance(auth_header, six.binary_type): auth_header = auth_header.decode('utf8') - parts = auth_header.split(',') - auth_scheme_parts = parts[0].split(' ') - if 'hawk' != auth_scheme_parts[0].lower(): + scheme, attributes_string = auth_header.split(' ', 1) + + if scheme.lower() != 'hawk': raise HawkFail("Unknown scheme '{scheme}' when parsing header" - .format(scheme=auth_scheme_parts[0].lower())) + .format(scheme=scheme)) + - # Replace 'Hawk key: value' with 'key: value' - # which matches the rest of parts - parts[0] = auth_scheme_parts[1] - - for part in parts: - attr_parts = part.split('=') - key = attr_parts[0].strip() + attributes = {} + + def replace_attribute(match): + """Extract the next key="value"-pair in the header.""" + key = match.group('key') + value = match.group('value') if key not in allowable_header_keys: raise HawkFail("Unknown Hawk key '{key}' when parsing header" .format(key=key)) - - if len(attr_parts) > 2: - attr_parts[1] = '='.join(attr_parts[1:]) - - # Chop of quotation marks - value = attr_parts[1] - - if attr_parts[1].find('"') == 0: - value = attr_parts[1][1:] - - if value.find('"') > -1: - value = value[0:-1] - validate_header_attr(value, name=key) - value = unescape_header_attr(value) + if key in attributes: + raise BadHeaderValue('Duplicate key in header: {key}'.format(key=key)) attributes[key] = value + # Iterate over all the key="value"-pairs in the header, replace them with + # an empty string, and store the extracted attribute in the attributes + # dict. Correctly formed headers will then leave nothing unparsed (''). + unparsed_header = HAWK_HEADER_RE.sub(replace_attribute, attributes_string) + if unparsed_header != '': + raise BadHeaderValue("Couldn't parse Hawk header", unparsed_header) + log.debug('parsed Hawk header: {header} into: \n{parsed}' .format(header=auth_header, parsed=pprint.pformat(attributes))) return attributes @@ -222,7 +221,7 @@ # Allowed value characters: # !#$%&'()*+,-./:;<=>?@[]^_`{|}~ and space, a-z, A-Z, 0-9, \, " _header_attribute_chars = re.compile( - r"^[ a-zA-Z0-9_\!#\$%&'\(\)\*\+,\-\./\:;<\=>\?@\[\]\^`\{\|\}~\"\\]*$") + r"^[ a-zA-Z0-9_\!#\$%&'\(\)\*\+,\-\./\:;<\=>\?@\[\]\^`\{\|\}~]*$") def validate_header_attr(val, name=None): @@ -232,36 +231,14 @@ .format(name=name or '?', val=repr(val))) -def escape_header_attr(val): - - # Ensure we are working with Unicode for consistency. - if isinstance(val, six.binary_type): - val = val.decode('utf8') - - # Escape quotes and slash like the hawk reference code. - val = val.replace('\\', '\\\\') - val = val.replace('"', '\\"') - val = val.replace('\n', '\\n') - return val - - -def unescape_header_attr(val): - # Un-do the hawk escaping. - val = val.replace('\\n', '\n') - val = val.replace('\\\\', '\\').replace('\\"', '"') - return val - - def prepare_header_val(val): - val = escape_header_attr(val) + if isinstance(val, six.binary_type): + val = val.decode('utf-8') validate_header_attr(val) return val def normalize_header_attr(val): - if not val: - val = '' - - # Normalize like the hawk reference code. - val = escape_header_attr(val) + if isinstance(val, six.binary_type): + return val.decode('utf-8') return val diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/mohawk.egg-info/PKG-INFO new/mohawk-1.0.0/mohawk.egg-info/PKG-INFO --- old/mohawk-0.3.4/mohawk.egg-info/PKG-INFO 2017-01-07 21:39:20.000000000 +0100 +++ new/mohawk-1.0.0/mohawk.egg-info/PKG-INFO 2019-01-09 23:01:05.000000000 +0100 @@ -1,12 +1,23 @@ Metadata-Version: 1.1 Name: mohawk -Version: 0.3.4 +Version: 1.0.0 Summary: Library for Hawk HTTP authorization Home-page: https://github.com/kumar303/mohawk Author: Kumar McMillan, Austin King Author-email: [email protected] License: MPL 2.0 (Mozilla Public License) -Description: UNKNOWN +Description: + Hawk lets two parties securely communicate with each other using + messages signed by a shared key. + It is based on HTTP MAC access authentication (which + was based on parts of OAuth 1.0). + + The Mohawk API is a little different from that of the Node library + (i.e. https://github.com/hueniverse/hawk). + It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. + + Read more: https://github.com/kumar303/mohawk/ + Platform: UNKNOWN Classifier: Intended Audience :: Developers Classifier: Natural Language :: English @@ -15,5 +26,8 @@ Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 -Classifier: Programming Language :: Python :: 3.3 +Classifier: Programming Language :: Python :: 3.4 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 Classifier: Topic :: Internet :: WWW/HTTP diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/setup.cfg new/mohawk-1.0.0/setup.cfg --- old/mohawk-0.3.4/setup.cfg 2017-01-07 21:39:21.000000000 +0100 +++ new/mohawk-1.0.0/setup.cfg 2019-01-09 23:01:05.000000000 +0100 @@ -1,5 +1,4 @@ [egg_info] tag_build = tag_date = 0 -tag_svn_revision = 0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/mohawk-0.3.4/setup.py new/mohawk-1.0.0/setup.py --- old/mohawk-0.3.4/setup.py 2017-01-07 21:34:20.000000000 +0100 +++ new/mohawk-1.0.0/setup.py 2019-01-09 22:59:04.000000000 +0100 @@ -2,9 +2,20 @@ setup(name='mohawk', - version='0.3.4', + version='1.0.0', description="Library for Hawk HTTP authorization", - long_description='', + long_description=""" +Hawk lets two parties securely communicate with each other using +messages signed by a shared key. +It is based on HTTP MAC access authentication (which +was based on parts of OAuth 1.0). + +The Mohawk API is a little different from that of the Node library +(i.e. https://github.com/hueniverse/hawk). +It was redesigned to be more intuitive to developers, less prone to security problems, and more Pythonic. + +Read more: https://github.com/kumar303/mohawk/ + """, author='Kumar McMillan, Austin King', author_email='[email protected]', license='MPL 2.0 (Mozilla Public License)', @@ -18,7 +29,10 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', 'Topic :: Internet :: WWW/HTTP', ], packages=find_packages(exclude=['tests']),
