-
-
Notifications
You must be signed in to change notification settings - Fork 99
Added TLSSocket abstraction for uniform SSL/TLS handling #799
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
| def _create_pyopenssl_connection(self, raw_socket): | ||
| """Create PyOpenSSL connection object.""" | ||
| try: | ||
| ssl_object = ssl_conn_type(self.context, raw_socket) |
Check failure
Code scanning / CodeQL
Use of insecure SSL/TLS version High
call to SSL.Context
Insecure SSL/TLS protocol version TLSv1_1 allowed by
call to SSL.Context
Insecure SSL/TLS protocol version SSLv2 allowed by
call to SSL.Context
Insecure SSL/TLS protocol version SSLv3 allowed by
call to SSL.Context
| (bind_host, actual_port), | ||
| timeout=_CONNECTION_TIMEOUT_SECONDS, | ||
| ) | ||
| client_sock = context.wrap_socket(sock, server_hostname=bind_host) |
Check failure
Code scanning / CodeQL
Use of insecure SSL/TLS version High test
call to ssl.create_default_context
Insecure SSL/TLS protocol version TLSv1_1 allowed by
call to ssl.create_default_context
| ) | ||
|
|
||
| # 2. Wrapping with SSL and completing handshake | ||
| client_sock = context.wrap_socket(sock, server_hostname=bind_host) |
Check failure
Code scanning / CodeQL
Use of insecure SSL/TLS version High test
call to ssl.create_default_context
Insecure SSL/TLS protocol version TLSv1_1 allowed by
call to ssl.create_default_context
| environ['SSL_COMPRESS_METHOD'] = compression | ||
| except AttributeError: | ||
| # TLSSocket might not have compression method | ||
| ... |
Check notice
Code scanning / CodeQL
Statement has no effect Note
| if server_hostname: | ||
| environ['SSL_TLS_SNI'] = server_hostname | ||
| except AttributeError: | ||
| ... |
Check notice
Code scanning / CodeQL
Statement has no effect Note
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #799 +/- ##
===========================================
- Coverage 77.69% 38.58% -39.12%
===========================================
Files 41 13 -28
Lines 4672 635 -4037
Branches 544 1 -543
===========================================
- Hits 3630 245 -3385
+ Misses 900 390 -510
+ Partials 142 0 -142 |
83a6da2 to
7447077
Compare
7447077 to
2c8e1a1
Compare
webknjaz
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are a few superficial observations. We'll have to work towards moving as much as possible into multiple small PRs so that this one will be reviewable. But one thing I'd want to request already is avoiding intercepting broad exception class and sticking to what's known and expected. I marked some things that are mostly ready for extraction out of this patch.
cheroot/connections.py
Outdated
| if hasattr( | ||
| self.server.ssl_adapter, | ||
| '_check_for_plain_http', | ||
| ) and self.server.ssl_adapter._check_for_plain_http(s): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Better move this into the wrap method of the adapter and raise a NoSSLError so that it's handled in the same except block below for both adapters.
This way, the adapter internals won't leak into this layer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Much better yes!
cheroot/connections.py
Outdated
| response_bytes = ''.join(response_parts).encode('ISO-8859-1') | ||
|
|
||
| try: | ||
| sock.sendall(response_bytes) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need a modern enough PyOpenSSL for sendall() to work well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I changed to:
if hasattr(sock, 'sendall'):
sock.sendall(response_bytes)
else:
# Fallback for older PyOpenSSL or SSL objects
sock.send(response_bytes)Is that safe enough?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know. The fallback might need a loop or something. I was just mostly asking because you worked with that lib recently and I didn't. If it needs a newer PyOpenSSL, I'd maybe think of starting to provide a pyopenssl extra in the core packaging metadata.
cheroot/connections.py
Outdated
| s.settimeout(self.server.timeout) | ||
| except errors.NoSSLError: | ||
| # this only arises for PyOpenSSL as | ||
| # we handle http over https pre-emptivley |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| # we handle http over https pre-emptivley | |
| # we handle http over https pre-emptively |
(although, with the suggestion above we could just drop the entire comment)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes gone now
| def _send_bad_request_plain_http_error(self, sock, addr): | ||
| """Send Bad Request 400 response, and close the socket.""" | ||
| self.server.error_log( | ||
| f'Client {addr!s} attempted to speak plain HTTP into ' | ||
| 'a TCP connection configured for TLS-only traffic — ' | ||
| 'Sending 400 Bad Request.', | ||
| ) | ||
|
|
||
| msg = ( | ||
| 'The client sent a plain HTTP request, but this server ' | ||
| 'only speaks HTTPS on this port.' | ||
| ) | ||
|
|
||
| response_parts = [ | ||
| f'{self.server.protocol} 400 Bad Request\r\n', | ||
| 'Content-Type: text/plain\r\n', | ||
| f'Content-Length: {len(msg)}\r\n', | ||
| 'Connection: close\r\n', | ||
| '\r\n', | ||
| msg, | ||
| ] | ||
| response_bytes = ''.join(response_parts).encode('ISO-8859-1') | ||
|
|
||
| try: | ||
| sock.sendall(response_bytes) | ||
| sock.shutdown(socket.SHUT_WR) | ||
| except OSError as ex: | ||
| if ex.args[0] not in errors.socket_errors_to_ignore: | ||
| raise | ||
|
|
||
| sock.close() | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method (or a simplified/standalone variant) could go into a separate PR so that we'd accept it earlier and have a smaller patch here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure. Will do.
cheroot/makefile.py
Outdated
| import _pyio as io | ||
| import socket | ||
|
|
||
| from .ssl.tls_socket import TLSSocket |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure we should be bringing TLS internals into the generic makefile. I'll have to think more about this..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. Maybe this is better?
# Check for wrapped socket
if hasattr(sock, 'read') and hasattr(sock, 'write'):
raw_io = sock # Already wrapped (could be TLSSocket)
else:
# Standard socket - wrap with SocketIO
raw_io = socket.SocketIO(sock, mode)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the @classmethod suggestion (#799 (comment)), this will be redundant.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the @classmethod suggestion (#799 (comment)), this will be redundant.
| f'Fatal SSL error during handshake: {error}', | ||
| ) from error | ||
|
|
||
| @property |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This could probably be a cached properly.
| with open(self.certificate, 'rb') as cert_file: | ||
| cert_data = cert_file.read() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should just use pathlib for these.
|
|
||
| env = { | ||
| env_prefix: ','.join(dn), | ||
| env_prefix: '/%s' % (dn_string,), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use f-strings instead of old-style interpolation.
| def bind(self, sock): ... | ||
| def wrap(self, sock): ... | ||
| def get_environ(self, sock): ... | ||
| def get_environ(self, conn) -> dict: ... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This public API change might be dangerous for CherryPy. Will have to check if it uses this.
| This module provides a TLSSocket class that abstracts over | ||
| different SSL/TLS implementations, such as Python's built-in ssl module | ||
| and pyOpenSSL. It offers a consistent interface for the rest of |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This module should only have a generic base class and sockets for different libs would be subclasses. Trying to put multiple corner cases from multiple libs is going to be difficult to maintain or extend.
d0753ec to
ae8afd4
Compare
Introduces a new `TLSSocket` class to act as a unified wrapper for SSL/TLS connections, regardless of the underlying adapter (`builtin`, `pyOpenSSL`). This refactoring aims to: 1. Simplify adapter logic by centralizing common TLS socket properties and methods (e.g., cipher details, certificate paths). 2. Improve consistency when populating WSGI environment variables. 3. Centralize error handling in the adapters.
ae8afd4 to
21b753e
Compare
|
|
||
|
|
||
| @pytest.fixture | ||
| def tls_certificate_passwd_private_key_pem_path( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is only used in one test module. Unless it's used in more places, it should remain in that module.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put moving these into a separate PR.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename to cheroot/test/ssl/test_builtin.py
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename to cheroot/test/ssl/test_ssl_pyopenssl.py
| IS_LINUX, | ||
| IS_MACOS, | ||
| IS_PYPY, | ||
| IS_SOLARIS, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If this var isn't used elsewhere, we might want to drop it from compat.
| @@ -1,5 +1,5 @@ | |||
| [mypy] | |||
| python_version = 3.8 | |||
| python_version = 3.9 | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would have to go into a separate PR and be coupled with changes to the core packaging metadata, linting configuration, dev env and CI setup.
|
|
||
| # prefer slower Python-based io module | ||
| import _pyio as io | ||
| import io as stdlib_io |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is this needed?
| # Wrap raw socket with SocketIO | ||
| raw_io = socket.SocketIO(sock, 'rb') | ||
|
|
||
| super().__init__(raw_io, bufsize) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is still checking obscure properties of objects to infer TLS relation. We shouldn't be relying on whether something is TLS or not in here. This is an abstraction leak no matter the method of checking.
The callers should decide if something needs wrapping and this is best done through providing alternative constructors.
| def __init__(self, sock, mode='w', bufsize=io.DEFAULT_BUFFER_SIZE): | ||
| """Initialize socket stream writer.""" | ||
| super().__init__(socket.SocketIO(sock, mode), bufsize) | ||
| def __init__(self, sock, bufsize=io.DEFAULT_BUFFER_SIZE): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing arguments or changing their order is a backwards incompatible change. We should avoid those and at least have a deprecation period to allow for downstream adoption. Deprecations would have to be dedicated multi-stage coordinated processes.
| Args: | ||
| components: Iterable of (key, value) tuples | ||
| key_prefix: 'SSL_CLIENT' or 'SSL_SERVER' | ||
| dn_type: 'S' for subject or 'I' for issuer | ||
| Returns: | ||
| dict: ``DN`` and ``CN`` environment variables |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't use napoleon that is not integrated. Use Sphinx-native param lists.
| Args: | |
| components: Iterable of (key, value) tuples | |
| key_prefix: 'SSL_CLIENT' or 'SSL_SERVER' | |
| dn_type: 'S' for subject or 'I' for issuer | |
| Returns: | |
| dict: ``DN`` and ``CN`` environment variables | |
| :param components: Iterable of (key, value) tuples | |
| :param key_prefix: 'SSL_CLIENT' or 'SSL_SERVER' | |
| :param dn_type: 'S' for subject or 'I' for issuer | |
| :returns: ``DN`` and ``CN`` environment variables | |
| :rtype: dict |
Currently, it's not rendered well and Sphinx objects aren't recorded for params: https://cheroot--799.org.readthedocs.build/en/799/pkg/cheroot.ssl/#cheroot.ssl._parse_dn_components.
When done correctly, the params are clearly marked and well-formatted. As well as return values and raised exceptions. Example: https://cheroot--799.org.readthedocs.build/en/799/pkg/cheroot._compat/#cheroot._compat.extract_bytes.
|
|
||
| def _parse_dn_components(components, key_prefix, dn_type): | ||
| """ | ||
| Parse Distinguished Name components into environ dict. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use full words in docstrings. Not "environ" or "dict", because docstrings are user-facing. Additionally, it's possible to link objects Sphinx knows of, like :class:`dict`.
Within the scope of this function, it doesn't really matter how the caller will use the dict. So claiming that it's an "environ dict" doesn't make sense. It's just a dict and that's it. The caller might put it into a context.
Perhaps, this would be better:
| Parse Distinguished Name components into environ dict. | |
| Transform Distinguished Name components list into a dictionary. |
| # Fields set by ConnectionManager. | ||
| last_used = None | ||
|
|
||
| def __init__(self, server, sock, makefile=MakeFile): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll probably have to keep this for a while (with a sentinel default). And issue a warning when the value isn't that sentinel.
| try: | ||
| resp_sock = self.socket._sock | ||
| except AttributeError: | ||
| # self.socket is of OpenSSL.SSL.Connection type |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, look — another abstraction leak to fight..
|
@julianz- the PR is so big that it can't be reviewed efficiently. I just can't keep up. Let's focus on finding all the things that can be merged as standalone PRs first. I'm trying to spot some of those but maybe, you'll see a few opportunities too. |
Introduces a new
TLSSocketclass to act as a unified wrapper for SSL/TLS connections, regardless of the underlying adapter (builtin,pyOpenSSL).This refactoring aims to: