From 82cca892dc85079a9d06a43bb3c379f15a2de1b1 Mon Sep 17 00:00:00 2001 From: Roger Aiudi Date: Tue, 10 Aug 2021 22:42:51 -0400 Subject: [PATCH 1/2] Implement initial proxy support --- httpx_gssapi/gssapi_.py | 55 +++++++++--- requirements.txt | 1 + setup.cfg | 4 + tests/conftest.py | 30 +++++-- tests/test_end_to_end.py | 15 +++- tests/test_proxy.py | 185 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 273 insertions(+), 17 deletions(-) create mode 100644 tests/test_proxy.py diff --git a/httpx_gssapi/gssapi_.py b/httpx_gssapi/gssapi_.py index cdd613b..731ba48 100644 --- a/httpx_gssapi/gssapi_.py +++ b/httpx_gssapi/gssapi_.py @@ -2,7 +2,7 @@ import logging from itertools import chain from functools import wraps -from typing import Generator, Optional, List, Any +from typing import Generator, Optional, List, Any, Dict from base64 import b64encode, b64decode @@ -33,12 +33,40 @@ OPTIONAL = 2 DISABLED = 3 +AUTHENTICATE_HEADERS = { + 401: 'www-authenticate', + 407: 'proxy-authenticate', +} + +AUTHORIZATION_HEADERS = { + 401: 'Authorization', + 407: 'Proxy-Authorization', +} + _find_auth = re.compile(r'Negotiate\s*([^,]*)', re.I).search +def _authenticate_header(response: Response = None) -> str: + """Get the proper authenticate header for the given response.""" + return _status_header(AUTHENTICATE_HEADERS, response) + + +def _authorization_header(response: Response = None) -> str: + """Get the proper authorization header for the given response.""" + return _status_header(AUTHORIZATION_HEADERS, response) + + +def _status_header(headers: Dict[int, str], response: Response = None) -> str: + """Helper function to get the right header for the given response.""" + try: + return headers[response.status_code] + except (KeyError, AttributeError): + return headers[401] + + def _negotiate_value(response: Response) -> Optional[bytes]: """Extracts the gssapi authentication token from the appropriate header""" - authreq = response.headers.get('www-authenticate', None) + authreq = response.headers.get(_authenticate_header(response), None) if authreq: match_obj = _find_auth(authreq) if match_obj: @@ -161,10 +189,11 @@ def auth_flow(self, request: Request) -> FlowGen: def handle_response(self, response: Response, ctx: SecurityContext = None) -> FlowGen: - num_401s = 0 - while response.status_code == 401 and num_401s < 2: - num_401s += 1 - log.debug("Handling 401 response, total seen: %d", num_401s) + sc = response.status_code + count = 0 + while sc in AUTHENTICATE_HEADERS and count < 2: + count += 1 + log.debug("Handling %d response, total seen: %d", sc, count) if _negotiate_value(response) is None: log.debug("GSSAPI is not supported") @@ -178,16 +207,19 @@ def handle_response(self, # Try request again, hopefully with a new auth header response = yield response.request + if response.status_code != sc: # Status code changed! + count = 0 + sc = response.status_code - if response.status_code == 401 or ctx is None: - log.debug("Failed to authenticate, returning 401 response") + if sc in AUTHENTICATE_HEADERS or ctx is None: + log.debug("Failed to authenticate, returning %d response", sc) return self.handle_mutual_auth(response, ctx) def handle_mutual_auth(self, response: Response, ctx: SecurityContext): """ - Handles all responses with the exception of 401s. + Handles all responses with the exception of 401s and 407s. This is necessary so that we can authenticate responses if requested """ @@ -249,7 +281,9 @@ def set_auth_header(self, auth_header, ) - request.headers['Authorization'] = auth_header + # Clear token from possible previous proxy auth to avoid replay errors + request.headers.pop(AUTHORIZATION_HEADERS[407], None) + request.headers[_authorization_header(response)] = auth_header return ctx @_handle_gsserror(gss_stage="stepping", result=False) @@ -281,6 +315,7 @@ def _make_context(self, request: Request) -> SecurityContext: used if it isn't included in :py:attr:`target_name`. """ name = self.target_name + # FIXME: Determine proxy host for 407 if type(name) != gssapi.Name: # type(name) is str if '@' not in name: name += f"@{request.url.host}" diff --git a/requirements.txt b/requirements.txt index 1e51b97..fc4b05d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ httpx>=0.16,<0.19 gssapi pytest k5test +proxy.py diff --git a/setup.cfg b/setup.cfg index c8bb8b2..1a182b0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,6 +29,7 @@ install_requires = tests_require = pytest k5test + proxy.py [options.package_data] * = @@ -80,6 +81,9 @@ deps = commands = python -m pytest +[tool:pytest] +log_cli = 1 + [versioneer] VCS = git style = pep440 diff --git a/tests/conftest.py b/tests/conftest.py index f3d5855..0e19401 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,9 @@ import contextlib import multiprocessing as mp from time import sleep -from base64 import b64decode +from base64 import b64decode, b64encode from http.server import HTTPServer, BaseHTTPRequestHandler +from typing import Callable import pytest import k5test @@ -56,12 +57,17 @@ def _unauthorized(self, neg_token=None): def _respond(self, code, msg, neg_token=None): self.send_response(code) self.send_header('Content-Type', 'text/plain') + # Required to work around proxy test issue + # https://github.com/abhinavsingh/proxy.py/issues/398 + self.send_header('Content-Length', str(len(msg.encode()))) self._set_www_auth(neg_token) self.end_headers() self.wfile.write(msg.encode()) def _set_www_auth(self, token=None): - www_auth = f'{NEGOTIATE} {token}' if token else NEGOTIATE + www_auth = NEGOTIATE + if token: + www_auth += f' {b64encode(token).decode()}' self.send_header(WWW_AUTHENTICATE, www_auth) def _get_context(self): @@ -101,10 +107,22 @@ def krb_realm() -> k5test.K5Realm: @pytest.fixture(scope='session') -def http_server_port() -> int: - with contextlib.closing(socket.socket()) as sock: - sock.bind(('127.0.0.1', 0)) - return sock.getsockname()[1] +def free_port_factory() -> Callable[[], int]: + def _get_free_port() -> int: + with contextlib.closing(socket.socket()) as sock: + sock.bind(('127.0.0.1', 0)) + return sock.getsockname()[1] + return _get_free_port + + +@pytest.fixture +def free_port(free_port_factory) -> int: + return free_port_factory() + + +@pytest.fixture(scope='session') +def http_server_port(free_port_factory) -> int: + return free_port_factory() @pytest.fixture(scope='session') diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py index 96e5cc8..fe91825 100644 --- a/tests/test_end_to_end.py +++ b/tests/test_end_to_end.py @@ -7,7 +7,8 @@ import httpx_gssapi -def test_end_to_end(http_server, http_creds, krb_realm, http_server_port): +@pytest.mark.usefixtures('http_server', 'krb_realm') +def test_end_to_end(http_creds, http_server_port): auth = httpx_gssapi.HTTPSPNEGOAuth(creds=http_creds) with httpx.Client(auth=auth, timeout=500) as client: for i in range(2): @@ -15,5 +16,17 @@ def test_end_to_end(http_server, http_creds, krb_realm, http_server_port): assert resp.status_code == 200 +@pytest.mark.usefixtures('http_server', 'krb_realm') +def test_mutual_auth(http_creds, http_server_port): + auth = httpx_gssapi.HTTPSPNEGOAuth( + creds=http_creds, + mutual_authentication=True, + ) + with httpx.Client(auth=auth, timeout=500) as client: + for i in range(2): + resp = client.get(f'http://localhost:{http_server_port}/') + assert resp.status_code == 200 + + if __name__ == '__main__': pytest.main() diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..761c512 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python +"""Tests for httpx_gssapi proxy support.""" + +import os +import re +import multiprocessing as mp +from base64 import b64decode, b64encode +from time import sleep +from typing import Optional + +import httpx +import pytest +import k5test +import gssapi + +from proxy import main as proxy_main +from proxy.http.parser import HttpParser +from proxy.http.codes import httpStatusCodes +from proxy.http.proxy import HttpProxyBasePlugin +from proxy.http.exception import ProxyAuthenticationFailed +from proxy.common.flag import flags +from proxy.common.utils import build_http_response +from proxy.common.constants import \ + PROXY_AGENT_HEADER_VALUE, PROXY_AGENT_HEADER_KEY + +import httpx_gssapi + +AUTHENTICATE = b'Proxy-Authenticate' +AUTHORIZATION = b'Proxy-Authorization' +NEGOTIATE = b'Negotiate' + +_find_auth = re.compile(rb'Negotiate\s*([^,]*)', re.I).search + +flags.add_argument( + '--krb5-realm', + type=str, + default='KRBTEST.COM', + help='Kerberos realm for GSSAPI auth.', +) + + +class GSSAPIProxyAuthFailed(ProxyAuthenticationFailed): + """ + Exception raised when Http Proxy GSSAPI auth is enabled and + incoming request fails authentication. + """ + + def __init__(self, neg_token: bytes = None): + self.neg_token = neg_token + + def response(self, _request: HttpParser) -> memoryview: + return memoryview(build_http_response( + httpStatusCodes.PROXY_AUTH_REQUIRED, + reason=b'Proxy Authentication Required', + headers={ + PROXY_AGENT_HEADER_KEY: PROXY_AGENT_HEADER_VALUE, + AUTHENTICATE: _format_neg(self.neg_token), + }, + body=b'Proxy Authentication Required', + )) + + +class GSSAPIAuthPlugin(HttpProxyBasePlugin): + """Performs proxy authentication.""" + + def before_upstream_connection(self, + request: HttpParser) -> Optional[HttpParser]: + if self.flags.krb5_realm: + in_token = _get_auth_header(request) + if not in_token: + raise GSSAPIProxyAuthFailed() + + ctx = self._get_context() + out_token = ctx.step(in_token) + if not ctx.complete: + raise GSSAPIProxyAuthFailed(b64encode(out_token)) + + request.add_header(AUTHENTICATE, _format_neg(out_token)) + return request + + def _get_context(self): + service_name = gssapi.Name( + f'HTTP/localhost@{self.flags.krb5_realm}' + ) + server_cred = gssapi.Credentials(name=service_name, usage='accept') + return gssapi.SecurityContext(creds=server_cred) + + def handle_client_request(self, + request: HttpParser) -> Optional[HttpParser]: + return request + + def handle_upstream_chunk(self, chunk: memoryview) -> memoryview: + return chunk + + def on_upstream_connection_close(self) -> None: + pass + + +def _get_auth_header(request: HttpParser) -> Optional[bytes]: + auth_key = AUTHORIZATION.lower() + if auth_key not in request.headers: + return + match = _find_auth(request.headers[auth_key][1]) + if match: + return b64decode(match.group(1)) + + +def _format_neg(token: bytes = None) -> bytes: + header = NEGOTIATE + if token: + header += b' ' + token + return header + + +def start_proxy(realm: k5test.K5Realm, + host: str = '127.0.0.1', + port: int = 8080): + princ = f'HTTP/localhost@{realm.realm}' + realm.addprinc(princ) + realm.extract_keytab(princ, realm.keytab) + realm.ccache = realm.env['KRB5CCNAME'] \ + = os.path.join(realm.tmpdir, 'service_ccache') + realm.kinit(princ, flags=['-k', '-t', realm.keytab]) + + os.environ.update(realm.env) + + proxy_main([ + f'--krb5-realm={realm.realm}', + f'--hostname={host}', + f'--port={port}', + ], plugins=[GSSAPIAuthPlugin]) + + +@pytest.fixture +def proxy_port(free_port_factory) -> int: + return free_port_factory() + + +@pytest.fixture +def proxy(request, krb_realm, proxy_port): + ps = mp.Process( + target=start_proxy, + args=(krb_realm,), + kwargs={'port': proxy_port}, + ) + ps.start() + + sleep(1) + + @request.addfinalizer + def cleanup(): + if ps.is_alive(): + ps.terminate() + + +@pytest.fixture +@pytest.mark.usefixtures('proxy', 'http_server') +def client(http_creds, proxy_port) -> httpx.Client: + auth = httpx_gssapi.HTTPSPNEGOAuth(creds=http_creds) + with httpx.Client( + auth=auth, + timeout=500, + proxies={'http://': f'http://localhost:{proxy_port}'}, + ) as client: + yield client + + +@pytest.mark.xfail("Can't determine the proper proxy host") +@pytest.mark.usefixtures('proxy') +def test_proxy_external(client): + for i in range(2): + # Use neverssl.com to avoid worrying about SSL with the proxy + resp = client.get('http://neverssl.com/') + assert resp.status_code == 200 + + +@pytest.mark.usefixtures('proxy', 'http_server') +def test_proxy_local(client, http_server_port): + for i in range(2): + resp = client.get(f'http://localhost:{http_server_port}/') + assert resp.status_code == 200 + + +if __name__ == '__main__': + pytest.main() From a29dce28f152287ff626579d8b75732de95ef9ce Mon Sep 17 00:00:00 2001 From: Roger Aiudi Date: Fri, 20 Aug 2021 12:47:06 -0400 Subject: [PATCH 2/2] Specify reason for xfail --- tests/test_proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 761c512..c25f837 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -165,7 +165,7 @@ def client(http_creds, proxy_port) -> httpx.Client: yield client -@pytest.mark.xfail("Can't determine the proper proxy host") +@pytest.mark.xfail(reason="Can't determine the proper proxy host") @pytest.mark.usefixtures('proxy') def test_proxy_external(client): for i in range(2):