From d71b15bb07f9654403b84738733c135c1808fd66 Mon Sep 17 00:00:00 2001 From: Andrey Kislyuk Date: Sun, 10 Apr 2022 17:24:30 -0700 Subject: [PATCH] Add require_components to verify; expand docs --- README.rst | 15 +++++- requests_http_signature/__init__.py | 80 ++++++++++++++++++++++------- test/test.py | 13 +++-- 3 files changed, 85 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index 27f612a..0b6e977 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,20 @@ incoming requests: HTTPSignatureAuth.verify(request, signature_algorithm=algorithms.HMAC_SHA256, key_resolver=key_resolver) -See the `API documentation `_ for full detials. +.. admonition:: See what is signed + + It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP message + signatures. The gist of this rule is: if your application neglects to verify that the information it trusts is + what was actually signed, the attacker can supply a valid signature but point you to malicious data that wasn't signed + by that signature. Failure to follow this rule can lead to vulnerability against signature wrapping and substitution + attacks. + + In requests-http-signature, you can ensure that the information signed is what you expect to be signed by only trusting + the data returned by the ``verify()`` method:: + + verify_result = HTTPSignatureAuth.verify(request, ...) + +See the `API documentation `_ for full details. Asymmetric key algorithms ~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/requests_http_signature/__init__.py b/requests_http_signature/__init__.py index 3949672..c27685a 100644 --- a/requests_http_signature/__init__.py +++ b/requests_http_signature/__init__.py @@ -10,7 +10,6 @@ import requests from requests.exceptions import RequestException from http_message_signatures import (algorithms, HTTPSignatureComponentResolver, HTTPSignatureKeyResolver, # noqa: F401 HTTPMessageSigner, HTTPMessageVerifier, HTTPSignatureAlgorithm, InvalidSignature) -from http_message_signatures.structures import CaseInsensitiveDict class RequestsHttpSignatureException(RequestException): @@ -147,14 +146,38 @@ class HTTPSignatureAuth(requests.auth.AuthBase): @classmethod def verify(cls, request, *, + require_components: List[str] = ("@method", "@authority", "@target-uri"), signature_algorithm: HTTPSignatureAlgorithm, key_resolver: HTTPSignatureKeyResolver, component_resolver_class: type = HTTPSignatureComponentResolver): """ Verify an HTTP message signature. + .. admonition:: See what is signed + + It is important to understand and follow the best practice rule of "See what is signed" when verifying HTTP + message signatures. The gist of this rule is: if your application neglects to verify that the information it + trusts is what was actually signed, the attacker can supply a valid signature but point you to malicious data + that wasn't signed by that signature. Failure to follow this rule can lead to vulnerability against signature + wrapping and substitution attacks. + + You can ensure that the information signed is what you expect to be signed by only trusting the *VerifyResult* + tuple returned by ``verify()``. + + :param request: The HTTP request to verify. + :param require_components: + A list of lowercased header names or derived component IDs ("@method", "@target-uri", "@authority", + "@scheme", "@request-target", "@path", "@query", "@query-params", "@status", or "@request-response" as + specified in the standard) to require to be covered by the signature. If the "content-digest" is + specified here (recommended for requests that have a body), it will be verified by matching it against the + digest hash computed on the body of the request (expected to be bytes). + + If this parameter is not specified, ``verify()`` will set it to ("@method", "@authority", "@target-uri") + for requests without a body, and ("@method", "@authority", "@target-uri", "content-digest") for requests + with a body. :param signature_algorithm: - One of ``requests_http_signature.algorithms.HMAC_SHA256``, + The algorithm expected to be used by the signature. Any signature not using the expected algorithm will be + rejected. One of ``requests_http_signature.algorithms.HMAC_SHA256``, ``requests_http_signature.algorithms.ECDSA_P256_SHA256``, ``requests_http_signature.algorithms.ED25519``, ``requests_http_signature.algorithms.RSA_PSS_SHA512``, or @@ -168,25 +191,44 @@ class HTTPSignatureAuth(requests.auth.AuthBase): :param component_resolver_class: Use this to subclass ``http_message_signatures.HTTPSignatureComponentResolver`` and customize header and derived component retrieval if needed. + + :returns: *VerifyResult*, a namedtuple with the following attributes: + + * ``label`` (str): The label for the signature + * ``algorithm``: (same as ``signature_algorithm`` above) + * ``covered_components``: A mapping of component names to their values, as covered by the signature + * ``parameters``: A mapping of signature parameters to their values, as covered by the signature + + :raises: ``InvalidSignature`` - raised whenever signature validation fails for any reason. """ + if request.body is not None: + if "content-digest" not in require_components and '"content-digest"' not in require_components: + require_components = list(require_components) + ["content-digest"] + verifier = HTTPMessageVerifier(signature_algorithm=signature_algorithm, key_resolver=key_resolver, component_resolver_class=component_resolver_class) - verify_result = verifier.verify(request) - # TODO: get content-digest from verify result, not from independent parsing of headers - # TODO: add options to require specific components - headers = CaseInsensitiveDict(request.headers) - if "content-digest" in headers: - if request.body is None: - raise InvalidSignature("Found a content-digest header in a request with no body") - digest = http_sfv.Dictionary() - digest.parse(headers["content-digest"].encode()) - for k, v in digest.items(): - if k not in cls._digest_hashers: - raise InvalidSignature(f'Unsupported digest algorithm "{k}"') - raw_digest = v.value - hasher = cls._digest_hashers[k] - expect_digest = hasher(request.body).digest() - if raw_digest != expect_digest: - raise InvalidSignature("The content-digest header does not match the request body") + verify_results = verifier.verify(request) + if len(verify_results) != 1: + raise InvalidSignature("Multiple signatures are not supported.") + verify_result = verify_results[0] + for component_name in require_components: + component_key = component_name + if not component_key.startswith('"'): + component_key = str(http_sfv.List([http_sfv.Item(component_name)])) + if component_key not in verify_result.covered_components: + raise InvalidSignature(f"A required component, {component_key}, was not covered by the signature.") + if component_key == '"content-digest"': + if request.body is None: + raise InvalidSignature("Found a content-digest header in a request with no body") + digest = http_sfv.Dictionary() + digest.parse(verify_result.covered_components[component_key].encode()) + for k, v in digest.items(): + if k not in cls._digest_hashers: + raise InvalidSignature(f'Unsupported digest algorithm "{k}"') + raw_digest = v.value + hasher = cls._digest_hashers[k] + expect_digest = hasher(request.body).digest() + if raw_digest != expect_digest: + raise InvalidSignature("The content-digest header does not match the request body") return verify_result diff --git a/test/test.py b/test/test.py index 0ed53ff..7d0dbe3 100755 --- a/test/test.py +++ b/test/test.py @@ -23,9 +23,16 @@ class TestAdapter(HTTPAdapter): self.client_auth = auth def send(self, request, *args, **kwargs): - HTTPSignatureAuth.verify(request, - signature_algorithm=self.client_auth.signer.signature_algorithm, - key_resolver=self.client_auth.signer.key_resolver) + verify_args = dict(signature_algorithm=self.client_auth.signer.signature_algorithm, + key_resolver=self.client_auth.signer.key_resolver) + HTTPSignatureAuth.verify(request, **verify_args) + if request.body is not None: + request.body = request.body[::-1] + try: + HTTPSignatureAuth.verify(request, **verify_args) + raise Exception("Expected InvalidSignature to be raised") + except InvalidSignature: + pass response = requests.Response() response.status_code = requests.codes.ok response.url = request.url