From e95e07be18cbfab4c7547c0a8e4b5884476becb2 Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Tue, 4 Jun 2024 00:38:47 -0400 Subject: [PATCH 1/6] nginx_snippets/orbit: change login redirect to use HTTP 403 401 should be used to request the client to authenitcate (e.g. using http basic auth) and not just to indicate that they are not allowed access to a resource. The HTTP 403 Forbidden status code is better. --- nginx_snippets/server_https/00-orbit-paths.conf | 2 +- nginx_snippets/server_https/01-error-pages.conf | 1 - orbit/radius.py | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/nginx_snippets/server_https/00-orbit-paths.conf b/nginx_snippets/server_https/00-orbit-paths.conf index 84021ad2..129a0a76 100644 --- a/nginx_snippets/server_https/00-orbit-paths.conf +++ b/nginx_snippets/server_https/00-orbit-paths.conf @@ -1,4 +1,4 @@ -error_page 401 @login; +error_page 403 @login; location @login { absolute_redirect off; diff --git a/nginx_snippets/server_https/01-error-pages.conf b/nginx_snippets/server_https/01-error-pages.conf index 1619ef93..cfae3b8a 100644 --- a/nginx_snippets/server_https/01-error-pages.conf +++ b/nginx_snippets/server_https/01-error-pages.conf @@ -1,6 +1,5 @@ error_page 400 - 403 404 405 500 diff --git a/orbit/radius.py b/orbit/radius.py index 20914f6d..0b5f7f5e 100644 --- a/orbit/radius.py +++ b/orbit/radius.py @@ -380,7 +380,7 @@ def handle_stub(rocket, more=[]): def handle_dashboard(rocket): if not rocket.session: - return rocket.raw_respond(HTTPStatus.UNAUTHORIZED) + return rocket.raw_respond(HTTPStatus.FORBIDDEN) submissions = (mailman.db.Submission.select() .where(mailman.db.Submission.user == rocket.session.username) # NOQA: E501 @@ -445,7 +445,7 @@ def form_respond(): def handle_cgit(rocket): if not rocket.session: - return rocket.raw_respond(HTTPStatus.UNAUTHORIZED) + return rocket.raw_respond(HTTPStatus.FORBIDDEN) cgit_env = os.environ.copy() cgit_env['PATH_INFO'] = rocket.path_info.removeprefix('/cgit') cgit_env['QUERY_STRING'] = rocket.env.get('QUERY_STRING', '') From 48887b9759e98e9684be133a417c25619f70baa9 Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Tue, 4 Jun 2024 01:19:18 -0400 Subject: [PATCH 2/6] orbit: cgit: handle cgi response more correctly Without the embedded option in the cgitrc cgit generates a full html page with its own html and body tags that we were embedding within our existing body creating ill-formed html documents. We can also use noheader to suppress the header bar and avoid the need to hide the logo with custom css rules. By parsing the http headers returned from cgit we can also surface information like content type to the client and suppress wrapping the output in our html if it is supposed to be binary data which allows us to bring back the plain view button. --- orbit/cgitrc | 20 +++++--------------- orbit/radius.py | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/orbit/cgitrc b/orbit/cgitrc index 030b37cc..f3888393 100644 --- a/orbit/cgitrc +++ b/orbit/cgitrc @@ -1,24 +1,14 @@ -# Specify the css url -css=/style.css - # Allow http transport git clone enable-http-clone=0 -# Enable caching of up to 1000 output entries -cache-size=0 - -# Use a custom logo -logo=/images/kdlp_logo.png - -# Set the title and heading of the repository index page -root-title=Kernel Development Learning Pipeline Git Repositories - -# Set a subheading for the repository index page -root-desc=Explore the class git repositories here! - # Allow cgit to use git config to set repo-specific settings enable-git-config=1 + +# Create output suitable for embedding within other pages +embedded=1 +noheader=1 + ## ## List of common mimetypes ## diff --git a/orbit/radius.py b/orbit/radius.py index 0b5f7f5e..a7fe1771 100644 --- a/orbit/radius.py +++ b/orbit/radius.py @@ -450,9 +450,11 @@ def handle_cgit(rocket): cgit_env['PATH_INFO'] = rocket.path_info.removeprefix('/cgit') cgit_env['QUERY_STRING'] = rocket.env.get('QUERY_STRING', '') - path_array = cgit_env['PATH_INFO'].split('/') - if len(path_array) > 2 and path_array[2] == 'plain': - return rocket.raw_respond(HTTPStatus.NOT_FOUND) + def cgit_internal_server_error(msg): + print(f'cgit: Error {msg} at path_info "{cgit_env["PATH_INFO"]}"' + f' and query string "{cgit_env["QUERY_STRING"]}"', + file=sys.stderr) + return rocket.raw_respond(HTTPStatus.INTERNAL_SERVER_ERROR) proc = subprocess.Popen(['/usr/share/webapps/cgit/cgit'], stdout=subprocess.PIPE, @@ -460,14 +462,31 @@ def handle_cgit(rocket): env=cgit_env) so, se = proc.communicate() try: - outstring = so.decode() - begin = outstring.index('\n\n') - return rocket.respond(outstring[begin+2:]) - except (UnicodeDecodeError, ValueError) as ex: - print(f'cgit: Error {type(ex)} at path_info "{cgit_env["PATH_INFO"]}"' - f' and query string "{cgit_env["QUERY_STRING"]}"', - file=sys.stderr) - return rocket.raw_respond(HTTPStatus.INTERNAL_SERVER_ERROR) + raw_headers, raw_body = so.split(b'\n\n', maxsplit=1) + headers_text = raw_headers.decode() + headers = [tuple(line.split(': ', maxsplit=1)) + for line in headers_text.split('\n')] + raw_return = False + status = HTTPStatus.OK + if headers[0][0] == 'Status': + status_str = headers[0][1] + status = HTTPStatus(int(status_str.split(' ')[0])) + if status == HTTPStatus.OK: + return cgit_internal_server_error('Unexpected 200 status') + raw_return = True + raw_body = b'' + del headers[0] + if headers[0][0] != 'Content-Type': + return cgit_internal_server_error('missing Content-Type') + if headers[0][1] != 'text/html; charset=UTF-8': + raw_return = True + rocket.headers += headers + if raw_return: + return rocket.raw_respond(status, raw_body) + outstring = raw_body.decode() + return rocket.respond(outstring) + except (UnicodeDecodeError, ValueError, IndexError) as ex: + return cgit_internal_server_error(type(ex)) def handle_error(rocket): From dd89bf0e66e44dfaef307a2e72f75f8bee62720b Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Wed, 4 Sep 2024 18:00:01 -0400 Subject: [PATCH 3/6] orbit: cgit: add support for http clone with authentication We can wire up cgit to request credentials from a git client during a clone operation via the basic authentication scheme that is part of HTTP and verify them so that course materials can be hosted entirely in cgit. --- orbit/cgitrc | 3 --- orbit/radius.py | 20 +++++++++++++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/orbit/cgitrc b/orbit/cgitrc index f3888393..af0b29ec 100644 --- a/orbit/cgitrc +++ b/orbit/cgitrc @@ -1,6 +1,3 @@ -# Allow http transport git clone -enable-http-clone=0 - # Allow cgit to use git config to set repo-specific settings enable-git-config=1 diff --git a/orbit/radius.py b/orbit/radius.py index a7fe1771..ebb50494 100644 --- a/orbit/radius.py +++ b/orbit/radius.py @@ -2,6 +2,7 @@ # # it's all one things now +import base64 import bcrypt import html import markdown @@ -443,9 +444,26 @@ def form_respond():

Password: {password}


''') +def http_basic_auth(rocket): + if (auth_str := rocket.env.get('HTTP_AUTHORIZATION')) is None: + return + if not auth_str.startswith('Basic '): + return + cred_str = base64.b64decode(auth_str.removeprefix('Basic ')) + username, password = cred_str.decode().split(':', maxsplit=1) + if not check_credentials(username, password): + return + return True + + def handle_cgit(rocket): if not rocket.session: - return rocket.raw_respond(HTTPStatus.FORBIDDEN) + if (not (agent := rocket.env.get('HTTP_USER_AGENT')) + or not agent.startswith('git/')): + return rocket.raw_respond(HTTPStatus.FORBIDDEN) + if not http_basic_auth(rocket): + rocket.headers.append(('WWW-Authenticate', 'Basic realm="cgit"')) + return rocket.raw_respond(HTTPStatus.UNAUTHORIZED) cgit_env = os.environ.copy() cgit_env['PATH_INFO'] = rocket.path_info.removeprefix('/cgit') cgit_env['QUERY_STRING'] = rocket.env.get('QUERY_STRING', '') From 9076804c91499f07217cb557934c7dccf56b5ed5 Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:31:25 -0400 Subject: [PATCH 4/6] orbit: Containerfile: move start command to script no functional change, just moving the current one command used to start orbit from being inline within a `CMD` instruction to a separate .sh script to make way for adding more commands. --- orbit/Containerfile | 2 +- orbit/start.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100755 orbit/start.sh diff --git a/orbit/Containerfile b/orbit/Containerfile index fa5b9292..ee6f98e3 100644 --- a/orbit/Containerfile +++ b/orbit/Containerfile @@ -45,4 +45,4 @@ USER 100:100 EXPOSE 9098 -CMD ["uwsgi", "--plugin", "python,http", "./radius.ini"] +CMD ["./start.sh"] diff --git a/orbit/start.sh b/orbit/start.sh new file mode 100755 index 00000000..26448c2a --- /dev/null +++ b/orbit/start.sh @@ -0,0 +1,2 @@ +#!/bin/sh +uwsgi --plugin 'python,http' ./radius.ini From f65af1f7a5b67a584105dddea64a6b7ee8bf4255 Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Wed, 4 Sep 2024 23:00:10 -0400 Subject: [PATCH 5/6] orbit: introduce memcached instance for authentication caching While the clone functionality works it bottlenecked by the speed of the secure cryptographic hash needed to verify passwords. By caching recent successful authentication attempts we can skip hashing the password repeatedly. Memcached is a simple and robust program that implements a memory only cache that can be queried and updated using a text or binary protocol over tcp, udp, or a unix domain socket. While there are python libraries that can act as a client for a memcached daemon, all the libraries I evaluated were unnesecarily bloated and complex as compared to just calling the C library with our own bindings for the three functions we need using the builtin python C foreign function interface module. --- orbit/Containerfile | 6 +++++ orbit/authcache.py | 56 +++++++++++++++++++++++++++++++++++++++++++++ orbit/start.sh | 1 + 3 files changed, 63 insertions(+) create mode 100644 orbit/authcache.py diff --git a/orbit/Containerfile b/orbit/Containerfile index ee6f98e3..a514af99 100644 --- a/orbit/Containerfile +++ b/orbit/Containerfile @@ -23,6 +23,8 @@ RUN apk update && apk upgrade && apk add \ uwsgi-python3 \ uwsgi-http \ cgit \ + libmemcached-dev \ + memcached \ ; WORKDIR /usr/local/share/orbit @@ -39,6 +41,10 @@ RUN mkdir -p /var/lib/orbit/ && \ COPY cgitrc /etc/cgitrc +RUN mkdir /run/orbit && \ + chown 100:100 /run/orbit && \ + : + RUN chown -R 100:100 /var/lib/orbit USER 100:100 diff --git a/orbit/authcache.py b/orbit/authcache.py new file mode 100644 index 00000000..26137606 --- /dev/null +++ b/orbit/authcache.py @@ -0,0 +1,56 @@ +import ctypes +import sys + +_libmemcached = ctypes.CDLL('libmemcached.so') + + +class _impl: + + EXPIRATION_TIME = 2 + + MEMCACHED_NOTFOUND = 16 + MEMCACHED_SUCCESS = 0 + server_config = b'--SOCKET="/run/orbit/memcached.sock" --BINARY-PROTOCOL' + + open = _libmemcached.memcached + set = _libmemcached.memcached_set + exist = _libmemcached.memcached_exist + + +_impl.open.restype = ctypes.c_void_p +_impl.open.argtypes = (ctypes.c_char_p, ctypes.c_size_t) + +_impl.set.restype = ctypes.c_int +_impl.set.argtypes = (ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_char_p, + ctypes.c_size_t, + ctypes.c_time_t, + ctypes.c_uint32) + +_impl.exist.restype = ctypes.c_int +_impl.exist.argtypes = (ctypes.c_void_p, + ctypes.c_char_p, + ctypes.c_size_t) + +_connection = _impl.open(_impl.server_config, len(_impl.server_config)) + + +def add_entry(key): + ret = _impl.set(_connection, key, len(key), None, 0, + _impl.EXPIRATION_TIME, 0) + + if ret != _impl.MEMCACHED_SUCCESS: + print(f'Failed to set cache {ret}', file=sys.stderr) + + return ret == _impl.MEMCACHED_SUCCESS + + +def entry_exists(key): + ret = _impl.exist(_connection, key, len(key)) + + if ret not in (_impl.MEMCACHED_SUCCESS, _impl.MEMCACHED_NOTFOUND): + print(f'Failed to retrieve cache item {ret}', file=sys.stderr) + + return ret == _impl.MEMCACHED_SUCCESS diff --git a/orbit/start.sh b/orbit/start.sh index 26448c2a..afb93844 100755 --- a/orbit/start.sh +++ b/orbit/start.sh @@ -1,2 +1,3 @@ #!/bin/sh +memcached --daemon --unix-socket /run/orbit/memcached.sock uwsgi --plugin 'python,http' ./radius.ini From 9eeac8b5a8ff04599aae2422df48ce926b9f902f Mon Sep 17 00:00:00 2001 From: charliemirabile <46761267+charliemirabile@users.noreply.github.com> Date: Wed, 4 Sep 2024 23:29:17 -0400 Subject: [PATCH 6/6] orbit: radius: cgit: cache recent logins using the authcache In order to minimize the security impact, the cache entries are set to expire after only 2 seconds and are only stored in ram because of how memcached works. Credentials are hashed before being stored and the current timestamp is included in order to prevent the same credentials from always having the same cache entry. --- orbit/radius.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/orbit/radius.py b/orbit/radius.py index ebb50494..5985cd7f 100644 --- a/orbit/radius.py +++ b/orbit/radius.py @@ -444,15 +444,29 @@ def form_respond():

Password: {password}


''') +def determine_cache_entry(cred_str): + import hashlib + import time + hasher = hashlib.sha256() + hasher.update(cred_str) + hasher.update(str(int(time.time())).encode()) + return hasher.digest() + + def http_basic_auth(rocket): + import authcache if (auth_str := rocket.env.get('HTTP_AUTHORIZATION')) is None: return if not auth_str.startswith('Basic '): return cred_str = base64.b64decode(auth_str.removeprefix('Basic ')) + cache_entry = determine_cache_entry(cred_str) + if authcache.entry_exists(cache_entry): + return True username, password = cred_str.decode().split(':', maxsplit=1) if not check_credentials(username, password): return + authcache.add_entry(cache_entry) return True