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.

Discussion:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 0c75d0b0..a44f278c 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -59,12 +59,3 @@ repos:
     rev: v1.1.0
     hooks:
     -   id: forbid-html-img-without-alt-text
-
-- repo: https://github.com/adrienverge/yamllint.git
-  rev: v1.27.1
-  hooks:
-  - id: yamllint
-    files: \.(yaml|yml)$
-    types: [file, yaml]
-    args:
-    - --strict
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 3eb1a2b4..ee2c84c4 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -3,6 +3,6 @@ build:
 python:
   version: 3.6
   extra_requirements:
-  - docs
-  - testing
+    - docs
+    - testing
   pip_install: true
diff --git a/.yamllint b/.yamllint
index 434c9f9b..97c08cfc 100644
--- a/.yamllint
+++ b/.yamllint
@@ -1,4 +1,2 @@
-rules:
-  indentation:
-    level: error
-    indent-sequences: false
+indentation:
+  indent-sequences: false
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')