Repository: cherrypy/cherrypy

Issue:
Undisclosed security issue
This issue was reported to security@cherrypy.dev as a security issue on 2022-06-18.

I'm documenting the investigation in a hackmd.io document, which I will put here after the resolution is concluded.

Comments:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 697d77ef..35b3f895 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,12 @@
+v18.8.0
+-------
+
+* :issue:`1974`: Dangerous characters received in a host header
+  encoded using RFC 2047 are now elided by default. Currently,
+  dangerous characters are defined as CR and LF. The original
+  value is still available as ``cherrypy.request.headers['Host'].raw``
+  if needed.
+
 v18.7.0
 -------
 
diff --git a/cherrypy/_cprequest.py b/cherrypy/_cprequest.py
index b380bb75..a661112c 100644
--- a/cherrypy/_cprequest.py
+++ b/cherrypy/_cprequest.py
@@ -742,6 +742,9 @@ class Request(object):
             if self.protocol >= (1, 1):
                 msg = "HTTP/1.1 requires a 'Host' request header."
                 raise cherrypy.HTTPError(400, msg)
+        else:
+            headers['Host'] = httputil.SanitizedHost(dict.get(headers, 'Host'))
+
         host = dict.get(headers, 'Host')
         if not host:
             host = self.local.name or self.local.ip
diff --git a/cherrypy/lib/httputil.py b/cherrypy/lib/httputil.py
index eedf8d89..ced310a0 100644
--- a/cherrypy/lib/httputil.py
+++ b/cherrypy/lib/httputil.py
@@ -516,3 +516,33 @@ class Host(object):
 
     def __repr__(self):
         return 'httputil.Host(%r, %r, %r)' % (self.ip, self.port, self.name)
+
+
+class SanitizedHost(str):
+    r"""
+    Wraps a raw host header received from the network in
+    a sanitized version that elides dangerous characters.
+
+    >>> SanitizedHost('foo\nbar')
+    'foobar'
+    >>> SanitizedHost('foo\nbar').raw
+    'foo\nbar'
+
+    A SanitizedInstance is only returned if sanitization was performed.
+
+    >>> isinstance(SanitizedHost('foobar'), SanitizedHost)
+    False
+    """
+    dangerous = re.compile(r'[\n\r]')
+
+    def __new__(cls, raw):
+        sanitized = cls._sanitize(raw)
+        if sanitized == raw:
+            return raw
+        instance = super().__new__(cls, sanitized)
+        instance.raw = raw
+        return instance
+
+    @classmethod
+    def _sanitize(cls, raw):
+        return cls.dangerous.sub('', raw)


Test patch:
diff --git a/cherrypy/test/test_request_obj.py b/cherrypy/test/test_request_obj.py
index 3aaa8e81..2478aabe 100644
--- a/cherrypy/test/test_request_obj.py
+++ b/cherrypy/test/test_request_obj.py
@@ -756,6 +756,16 @@ class RequestObjectTests(helper.CPWebCase):
                      headers=[('Content-type', 'application/json')])
         self.assertBody('application/json')
 
+    def test_dangerous_host(self):
+        """
+        Dangerous characters like newlines should be elided.
+        Ref #1974.
+        """
+        # foo\nbar
+        encoded = '=?iso-8859-1?q?foo=0Abar?='
+        self.getPage('/headers/Host', headers=[('Host', encoded)])
+        self.assertBody('foobar')
+
     def test_basic_HTTPMethods(self):
         helper.webtest.methods_with_bodies = ('POST', 'PUT', 'PROPFIND',
                                               'PATCH')