Skip to content

Commit cb21cb4

Browse files
committed
Unified handling of http over https errors
Added logic including a new method (_ensure_peer_speaks_https) in the Adapter base class to identify when a client attempts to speak unencrypted HTTP on an HTTPS port. When a plaintext connection is detected, the server now attempts to access the raw underlying TCP socket (if wrapped) to send a "400 Bad Request" error response before any TLS failure. This unifies the behavior of the 'builtin' and 'pyopenssl' adapters for this specific error condition.
1 parent c94db0c commit cb21cb4

File tree

8 files changed

+211
-77
lines changed

8 files changed

+211
-77
lines changed

cheroot/connections.py

Lines changed: 45 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Utilities to manage open connections."""
22

3-
import io
3+
import contextlib as _cm
44
import os
55
import selectors
66
import socket
77
import threading
88
import time
9-
from contextlib import suppress
9+
from http import HTTPStatus as _HTTPStatus
10+
from http.client import responses as _http_responses
1011

1112
from . import errors
1213
from ._compat import IS_WINDOWS
@@ -112,6 +113,16 @@ def close(self):
112113
self._selector.close()
113114

114115

116+
@_cm.contextmanager
117+
def _suppress_socket_io_errors(socket, /):
118+
"""Suppress known socket I/O errors."""
119+
try:
120+
yield
121+
except OSError as ex:
122+
if ex.args[0] not in errors.socket_errors_to_ignore:
123+
raise
124+
125+
115126
class ConnectionManager:
116127
"""Class which manages HTTPConnection objects.
117128
@@ -281,7 +292,7 @@ def _remove_invalid_sockets(self):
281292
# One of the reason on why a socket could cause an error
282293
# is that the socket is already closed, ignore the
283294
# socket error if we try to close it at this point.
284-
with suppress(OSError):
295+
with _cm.suppress(OSError):
285296
conn.close()
286297

287298
def _from_server_socket(self, server_socket): # noqa: C901 # FIXME
@@ -297,7 +308,8 @@ def _from_server_socket(self, server_socket): # noqa: C901 # FIXME
297308
ssl_env = {}
298309
# if ssl cert and key are set, we try to be a secure HTTP server
299310
if self.server.ssl_adapter is not None:
300-
try:
311+
# FIXME: WPS505 -- too many nested blocks
312+
try: # noqa: WPS505
301313
s, ssl_env = self.server.ssl_adapter.wrap(s)
302314
except errors.FatalSSLAlert as tls_connection_drop_error:
303315
self.server.error_log(
@@ -313,23 +325,7 @@ def _from_server_socket(self, server_socket): # noqa: C901 # FIXME
313325
'trying to send back a plain HTTP error response: '
314326
f'{http_over_https_err!s}',
315327
)
316-
msg = (
317-
'The client sent a plain HTTP request, but '
318-
'this server only speaks HTTPS on this port.'
319-
)
320-
buf = [
321-
'%s 400 Bad Request\r\n' % self.server.protocol,
322-
'Content-Length: %s\r\n' % len(msg),
323-
'Content-Type: text/plain\r\n\r\n',
324-
msg,
325-
]
326-
327-
wfile = mf(s, 'wb', io.DEFAULT_BUFFER_SIZE)
328-
try:
329-
wfile.write(''.join(buf).encode('ISO-8859-1'))
330-
except OSError as ex:
331-
if ex.args[0] not in errors.socket_errors_to_ignore:
332-
raise
328+
self._send_bad_request_plain_http_error(s)
333329
return None
334330
mf = self.server.ssl_adapter.makefile
335331
# Re-apply our timeout since we may have a new socket object
@@ -403,3 +399,31 @@ def can_add_keepalive_connection(self):
403399
"""Flag whether it is allowed to add a new keep-alive connection."""
404400
ka_limit = self.server.keep_alive_conn_limit
405401
return ka_limit is None or self._num_connections < ka_limit
402+
403+
def _send_bad_request_plain_http_error(self, raw_sock, /):
404+
"""Send Bad Request 400 response, and close the socket."""
405+
msg = (
406+
'The client sent a plain HTTP request, but this server '
407+
'only speaks HTTPS on this port.'
408+
)
409+
410+
http_response_status_line = ' '.join(
411+
(
412+
self.server.protocol,
413+
str(_HTTPStatus.BAD_REQUEST.value),
414+
_http_responses[_HTTPStatus.BAD_REQUEST],
415+
),
416+
)
417+
response_parts = [
418+
f'{http_response_status_line}\r\n',
419+
'Content-Type: text/plain\r\n',
420+
f'Content-Length: {len(msg)}\r\n',
421+
'Connection: close\r\n',
422+
'\r\n',
423+
msg,
424+
]
425+
response_bytes = ''.join(response_parts).encode('ISO-8859-1')
426+
427+
with _suppress_socket_io_errors(raw_sock), _cm.closing(raw_sock):
428+
raw_sock.sendall(response_bytes)
429+
raw_sock.shutdown(socket.SHUT_WR)

cheroot/ssl/__init__.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,58 @@
11
"""Implementation of the SSL adapter base interface."""
22

3+
import socket as _socket
34
from abc import ABC, abstractmethod
45
from warnings import warn as _warn
56

7+
from .. import errors as _errors
8+
9+
10+
def _ensure_peer_speaks_https(raw_socket, /) -> None:
11+
"""
12+
Raise exception if the client sent plain HTTP.
13+
14+
This method probes the TCP stream for signs of the peer having
15+
sent us plaintext HTTP on the HTTPS port by peeking at the
16+
first bytes. If there's no data yet, the method considers the
17+
guess inconclusive and does not error out. This allows the server
18+
to continue until the SSL handshake is attempted, at which point
19+
an error will be caught by the SSL layer if the client
20+
is not speaking TLS.
21+
22+
:raises NoSSLError: When plaintext HTTP is detected on an HTTPS socket
23+
"""
24+
PEEK_BYTES = 16
25+
PEEK_TIMEOUT = 0.5
26+
27+
original_timeout = raw_socket.gettimeout()
28+
raw_socket.settimeout(PEEK_TIMEOUT)
29+
30+
try:
31+
first_bytes = raw_socket.recv(PEEK_BYTES, _socket.MSG_PEEK)
32+
except (OSError, _socket.timeout):
33+
return
34+
finally:
35+
raw_socket.settimeout(original_timeout)
36+
37+
if not first_bytes:
38+
return
39+
40+
http_methods = (
41+
b'GET ',
42+
b'POST ',
43+
b'PUT ',
44+
b'DELETE ',
45+
b'HEAD ',
46+
b'OPTIONS ',
47+
b'PATCH ',
48+
b'CONNECT ',
49+
b'TRACE ',
50+
)
51+
if first_bytes.startswith(http_methods):
52+
raise _errors.NoSSLError(
53+
'Expected HTTPS on the socket but got plain HTTP',
54+
) from None
55+
656

757
class Adapter(ABC):
858
"""Base class for SSL driver library adapters.
@@ -51,7 +101,8 @@ def bind(self, sock):
51101
@abstractmethod
52102
def wrap(self, sock):
53103
"""Wrap the given socket and return WSGI environ entries."""
54-
raise NotImplementedError # pragma: no cover
104+
_ensure_peer_speaks_https(sock)
105+
return sock, {}
55106

56107
@abstractmethod
57108
def get_environ(self):

cheroot/ssl/builtin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ def context(self, context):
305305

306306
def wrap(self, sock):
307307
"""Wrap and return the given socket, plus WSGI environ entries."""
308+
sock, _env = super().wrap( # checks for plaintext http on https port
309+
sock,
310+
)
311+
308312
try:
309313
s = self.context.wrap_socket(
310314
sock,

cheroot/ssl/pyopenssl.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,11 @@ def wrap(self, sock):
339339
# pyOpenSSL doesn't perform the handshake until the first read/write
340340
# forcing the handshake to complete tends to result in the connection
341341
# closing so we can't reliably access protocol/client cert for the env
342+
343+
sock, _env = super().wrap( # checks for plaintext http on https port
344+
sock,
345+
)
346+
342347
conn = SSLConnection(self.context, sock)
343348

344349
conn.set_accept_state() # Tell OpenSSL this is a server connection

0 commit comments

Comments
 (0)