From 79ef1765248463b12414af15b15993e1d76b0bd3 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Mon, 14 Jul 2025 08:36:43 -0400 Subject: [PATCH 1/3] Support GSSAPI channel bindings This change adds support for GSSAPI channel bindings, which helps to protect against man-in-the-middle relay attacks by tying the authentication to the underlying secure channel. A `channel_bindings` parameter is added to `HTTPSPNEGOAuth`. When set to 'tls- server-end-point', the server's TLS certificate is retrieved from the socket, hashed, and used to create the GSSAPI channel bindings. This feature requires the `cryptography` library as an optional dependency. If it's not available, channel bindings cannot be used and a warning is logged. Co-authored-by: Gemini Signed-off-by: Simo Sorce --- src/requests_gssapi/gssapi_.py | 46 ++++++++++++++++++++++++- tests/test_requests_gssapi.py | 62 +++++++++++++++++++++++++++++----- 2 files changed, 99 insertions(+), 9 deletions(-) mode change 100644 => 100755 tests/test_requests_gssapi.py diff --git a/src/requests_gssapi/gssapi_.py b/src/requests_gssapi/gssapi_.py index 0d3c9f6..f90ca95 100644 --- a/src/requests_gssapi/gssapi_.py +++ b/src/requests_gssapi/gssapi_.py @@ -107,6 +107,10 @@ class HTTPSPNEGOAuth(AuthBase): `sanitize_mutual_error_response` controls whether we should clean up server responses. See the `SanitizedResponse` class. + `channel_bindings` can be used to pass channel bindings to GSSAPI. + The only accepted value is 'tls-server-end-point' which uses the TLS + server's certificate for the channel bindings. Default is `None`. + """ def __init__( @@ -118,6 +122,7 @@ def __init__( creds=None, mech=SPNEGO, sanitize_mutual_error_response=True, + channel_bindings=None, ): self.context = {} self.pos = None @@ -128,6 +133,9 @@ def __init__( self.creds = creds self.mech = mech if mech else SPNEGO self.sanitize_mutual_error_response = sanitize_mutual_error_response + if channel_bindings not in (None, "tls-server-end-point"): + raise ValueError("channel_bindings must be None or 'tls-server-end-point'") + self.channel_bindings = channel_bindings def generate_request_header(self, response, host, is_preemptive=False): """ @@ -144,6 +152,37 @@ def generate_request_header(self, response, host, is_preemptive=False): if self.mutual_authentication != DISABLED: gssflags.append(gssapi.RequirementFlag.mutual_authentication) + gss_cb = None + if self.channel_bindings == "tls-server-end-point": + if is_preemptive: + log.warning("channel_bindings were requested, but are unavailable for opportunistic authentication") + # The 'connection' attribute on raw is a public urllib3 API + # and can be None if the connection has been released. + elif getattr(response.raw, "connection", None) and getattr(response.raw.connection, "sock", None): + try: + # Defer import so it's not a hard dependency. + from cryptography import x509 + + sock = response.raw.connection.sock + + der_cert = sock.getpeercert(binary_form=True) + cert = x509.load_der_x509_certificate(der_cert) + hash = cert.signature_hash_algorithm + cert_hash = cert.fingerprint(hash) + + app_data = b"tls-server-end-point:" + cert_hash + gss_cb = gssapi.raw.ChannelBindings(application_data=app_data) + log.debug("generate_request_header(): Successfully retrieved channel bindings") + except ImportError: + log.warning("Could not import cryptography, python-cryptography is required for this feature.") + except Exception: + log.warning( + "Failed to get channel bindings from socket", + exc_info=True, + ) + else: + log.warning("channel_bindings were requested, but a socket could not be retrieved from the response") + try: gss_stage = "initiating context" name = self.target_name @@ -153,7 +192,12 @@ def generate_request_header(self, response, host, is_preemptive=False): name = gssapi.Name(name, gssapi.NameType.hostbased_service) self.context[host] = gssapi.SecurityContext( - usage="initiate", flags=gssflags, name=name, creds=self.creds, mech=self.mech + usage="initiate", + flags=gssflags, + name=name, + creds=self.creds, + mech=self.mech, + channel_bindings=gss_cb, ) gss_stage = "stepping context" diff --git a/tests/test_requests_gssapi.py b/tests/test_requests_gssapi.py old mode 100644 new mode 100755 index dabfa0d..76edc25 --- a/tests/test_requests_gssapi.py +++ b/tests/test_requests_gssapi.py @@ -98,7 +98,12 @@ def test_generate_request_header(self): auth = requests_gssapi.HTTPKerberosAuth() self.assertEqual(auth.generate_request_header(response, host), b64_negotiate_response) fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), creds=None, mech=SPNEGO, flags=gssflags, usage="initiate" + name=gssapi_sname("HTTP@www.example.org"), + creds=None, + mech=SPNEGO, + flags=gssflags, + usage="initiate", + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -113,7 +118,12 @@ def test_generate_request_header_init_error(self): requests_gssapi.exceptions.SPNEGOExchangeError, auth.generate_request_header, response, host ) fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), usage="initiate", flags=gssflags, creds=None, mech=SPNEGO + name=gssapi_sname("HTTP@www.example.org"), + usage="initiate", + flags=gssflags, + creds=None, + mech=SPNEGO, + channel_bindings=None, ) def test_generate_request_header_step_error(self): @@ -127,7 +137,12 @@ def test_generate_request_header_step_error(self): requests_gssapi.exceptions.SPNEGOExchangeError, auth.generate_request_header, response, host ) fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), usage="initiate", flags=gssflags, creds=None, mech=SPNEGO + name=gssapi_sname("HTTP@www.example.org"), + usage="initiate", + flags=gssflags, + creds=None, + mech=SPNEGO, + channel_bindings=None, ) fail_resp.assert_called_with(b"token") @@ -162,7 +177,12 @@ def test_authenticate_user(self): connection.send.assert_called_with(request) raw.release_conn.assert_called_with() fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), flags=gssflags, usage="initiate", creds=None, mech=SPNEGO + name=gssapi_sname("HTTP@www.example.org"), + flags=gssflags, + usage="initiate", + creds=None, + mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -197,7 +217,12 @@ def test_handle_401(self): connection.send.assert_called_with(request) raw.release_conn.assert_called_with() fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), creds=None, mech=SPNEGO, flags=gssflags, usage="initiate" + name=gssapi_sname("HTTP@www.example.org"), + creds=None, + mech=SPNEGO, + flags=gssflags, + usage="initiate", + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -402,7 +427,12 @@ def test_handle_response_401(self): connection.send.assert_called_with(request) raw.release_conn.assert_called_with() fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), usage="initiate", flags=gssflags, creds=None, mech=SPNEGO + name=gssapi_sname("HTTP@www.example.org"), + usage="initiate", + flags=gssflags, + creds=None, + mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -443,7 +473,12 @@ def connection_send(self, *args, **kwargs): connection.send.assert_called_with(request) raw.release_conn.assert_called_with() fake_init.assert_called_with( - name=gssapi_sname("HTTP@www.example.org"), usage="initiate", flags=gssflags, creds=None, mech=SPNEGO + name=gssapi_sname("HTTP@www.example.org"), + usage="initiate", + flags=gssflags, + creds=None, + mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -456,7 +491,12 @@ def test_generate_request_header_custom_service(self): auth = requests_gssapi.HTTPKerberosAuth(service="barfoo") auth.generate_request_header(response, host), fake_init.assert_called_with( - name=gssapi_sname("barfoo@www.example.org"), usage="initiate", flags=gssflags, creds=None, mech=SPNEGO + name=gssapi_sname("barfoo@www.example.org"), + usage="initiate", + flags=gssflags, + creds=None, + mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -496,6 +536,7 @@ def test_delegation(self): flags=gssdelegflags, creds=None, mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -522,6 +563,7 @@ def test_principal_override(self): flags=gssflags, creds=b"fake creds", mech=SPNEGO, + channel_bindings=None, ) def test_realm_override(self): @@ -538,6 +580,7 @@ def test_realm_override(self): flags=gssflags, creds=None, mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -569,6 +612,7 @@ def test_explicit_creds(self): flags=gssflags, creds=b"fake creds", mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -589,6 +633,7 @@ def test_explicit_mech(self): flags=gssflags, creds=None, mech=b"fake mech", + channel_bindings=None, ) fake_resp.assert_called_with(b"token") @@ -606,6 +651,7 @@ def test_target_name(self): flags=gssflags, creds=None, mech=SPNEGO, + channel_bindings=None, ) fake_resp.assert_called_with(b"token") From 7288f99cbea312d43f0114c586237e96ac29ee26 Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Wed, 16 Jul 2025 05:18:18 -0400 Subject: [PATCH 2/3] Raise exceptions if CBs are requested but not available Signed-off-by: Simo Sorce --- src/requests_gssapi/gssapi_.py | 42 +++++++++++++++------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/src/requests_gssapi/gssapi_.py b/src/requests_gssapi/gssapi_.py index f90ca95..cb24378 100644 --- a/src/requests_gssapi/gssapi_.py +++ b/src/requests_gssapi/gssapi_.py @@ -155,33 +155,29 @@ def generate_request_header(self, response, host, is_preemptive=False): gss_cb = None if self.channel_bindings == "tls-server-end-point": if is_preemptive: - log.warning("channel_bindings were requested, but are unavailable for opportunistic authentication") + raise SPNEGOExchangeError( + "channel_bindings were requested, but are unavailable for opportunistic authentication" + ) # The 'connection' attribute on raw is a public urllib3 API # and can be None if the connection has been released. elif getattr(response.raw, "connection", None) and getattr(response.raw.connection, "sock", None): - try: - # Defer import so it's not a hard dependency. - from cryptography import x509 - - sock = response.raw.connection.sock - - der_cert = sock.getpeercert(binary_form=True) - cert = x509.load_der_x509_certificate(der_cert) - hash = cert.signature_hash_algorithm - cert_hash = cert.fingerprint(hash) - - app_data = b"tls-server-end-point:" + cert_hash - gss_cb = gssapi.raw.ChannelBindings(application_data=app_data) - log.debug("generate_request_header(): Successfully retrieved channel bindings") - except ImportError: - log.warning("Could not import cryptography, python-cryptography is required for this feature.") - except Exception: - log.warning( - "Failed to get channel bindings from socket", - exc_info=True, - ) + # Defer import so it's not a hard dependency. + from cryptography import x509 + + sock = response.raw.connection.sock + + der_cert = sock.getpeercert(binary_form=True) + cert = x509.load_der_x509_certificate(der_cert) + hash = cert.signature_hash_algorithm + cert_hash = cert.fingerprint(hash) + + app_data = b"tls-server-end-point:" + cert_hash + gss_cb = gssapi.raw.ChannelBindings(application_data=app_data) + log.debug("generate_request_header(): Successfully retrieved channel bindings") else: - log.warning("channel_bindings were requested, but a socket could not be retrieved from the response") + raise SPNEGOExchangeError( + "channel_bindings were requested, but a socket could not be retrieved from the response" + ) try: gss_stage = "initiating context" From d529deb042e676efb08d9cabf6bb2b7607f8b02a Mon Sep 17 00:00:00 2001 From: Simo Sorce Date: Wed, 16 Jul 2025 05:27:35 -0400 Subject: [PATCH 3/3] Mention Channel Bindings support in the README Signed-off-by: Simo Sorce --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.rst b/README.rst index 6c787ca..1e7ee03 100644 --- a/README.rst +++ b/README.rst @@ -241,3 +241,21 @@ If you are having difficulty we suggest you configure logging. Issues with the underlying GSSAPI libraries will be made apparent. Additionally, copious debug information is made available which may assist in troubleshooting if you increase your log level all the way up to debug. + +Channel Bindings +---------------- + +Optional simplified support for channel bindings is available, but limited to +the 'tls-server-end-point' bindings type (manual construction of different +channel bindings can be achieved using the raw API). When requesting this kind +of bindings python-cryptography must be available as request-gssapi will try +to import its x509 module to process the peer certificate. + +.. code-block:: python + + >>> import requests + >>> from requests_gssapi import HTTPSPNEGOAuth + >>> gssapi_auth = HTTPSPNEGOAuth(channel_bindings='tls-server-end-point') + >>> r = requests.get("https://windows.example.org/wsman", auth=gssapi_auth) + ... +