CVE-2023-49082: Improper Neutralization of CRLF Sequences ('CRLF Injection') (CWE-93)
value: aiohttp is an asynchronous HTTP client/server framework for asyncio and Python. Improper validation makes it possible for an attacker to modify the HTTP request (e.g. insert a new header) or even create a new HTTP request if the attacker controls the HTTP method. The vulnerability occurs only if the attacker can control the HTTP method (GET, POST etc.) of the request. If the attacker can control the HTTP version of the request it will be able to modify the request (request smuggling). This issue has been patched in version 3.9.0.
CWE description: The product uses CRLF (carriage return line feeds) as a special element, e.g. to separate lines or records, but it does not neutralize or incorrectly neutralizes CRLF sequences from inputs.
CVSS v3 base score: 5.3
Published: 2023-11-29T20:15Z
Repository: https://github.com/aio-libs/aiohttp
Language: Python
Files changed: CHANGES/6533.feature, aiohttp/client_reqrep.py, tests/test_client_request.py, tests/test_web_request.py
Commit date: 2023-11-08 19:25:05 +0000
Commit message: Add HTTP method validation (#6533) (#7806)

(cherry picked from commit 75fca0b00b4297d0a30c51ae97a65428336eb2c1)

Co-authored-by: Andrew Svetlov <andrew.svetlov@gmail.com>
Diff: diff --git a/CHANGES/6533.feature b/CHANGES/6533.feature
new file mode 100644
index 000000000..36bcbeb21
--- /dev/null
+++ b/CHANGES/6533.feature
@@ -0,0 +1 @@
+Add HTTP method validation.
diff --git a/aiohttp/client_reqrep.py b/aiohttp/client_reqrep.py
index 6aa45aacd..79bcea15d 100644
--- a/aiohttp/client_reqrep.py
+++ b/aiohttp/client_reqrep.py
@@ -79,10 +79,11 @@ if TYPE_CHECKING:  # pragma: no cover
     from .client import ClientSession
     from .connector import Connection
     from .tracing import Trace
 
 
+_CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]")
 json_re = re.compile(r"^application/(?:[\w.+-]+?\+)?json")
 
 
 def _gen_default_accept_encoding() -> str:
     return "gzip, deflate, br" if HAS_BROTLI else "gzip, deflate"
@@ -273,14 +274,20 @@ class ClientRequest:
         proxy_headers: Optional[LooseHeaders] = None,
         traces: Optional[List["Trace"]] = None,
         trust_env: bool = False,
         server_hostname: Optional[str] = None,
     ):
-
         if loop is None:
             loop = asyncio.get_event_loop()
 
+        match = _CONTAINS_CONTROL_CHAR_RE.search(method)
+        if match:
+            raise ValueError(
+                f"Method cannot contain non-token characters {method!r} "
+                "(found at least {match.group()!r})"
+            )
+
         assert isinstance(url, URL), url
         assert isinstance(proxy, (URL, type(None))), proxy
         # FIXME: session is None in tests only, need to fix tests
         # assert session is not None
         self._session = cast("ClientSession", session)
diff --git a/tests/test_client_request.py b/tests/test_client_request.py
index 7842fe15a..0794bfd60 100644
--- a/tests/test_client_request.py
+++ b/tests/test_client_request.py
@@ -87,10 +87,15 @@ def test_method2(make_request) -> None:
 def test_method3(make_request) -> None:
     req = make_request("HEAD", "http://python.org/")
     assert req.method == "HEAD"
 
 
+def test_method_invalid(make_request) -> None:
+    with pytest.raises(ValueError, match="Method cannot contain non-token characters"):
+        make_request("METHOD WITH\nWHITESPACES", "http://python.org/")
+
+
 def test_version_1_0(make_request) -> None:
     req = make_request("get", "http://python.org/", version="1.0")
     assert req.version == (1, 0)
 
 
diff --git a/tests/test_web_request.py b/tests/test_web_request.py
index f6f444ba2..c6398ac18 100644
--- a/tests/test_web_request.py
+++ b/tests/test_web_request.py
@@ -40,11 +40,14 @@ def test_base_ctor() -> None:
         message, mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()
     )
 
     assert "GET" == req.method
     assert HttpVersion(1, 1) == req.version
-    assert req.host == socket.getfqdn()
+    # MacOS may return CamelCased host name, need .lower()
+    # FQDN can be wider than host, e.g.
+    # 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
+    assert req.host.lower() in socket.getfqdn().lower()
     assert "/path/to?a=1&b=2" == req.path_qs
     assert "/path/to" == req.path
     assert "a=1&b=2" == req.query_string
     assert CIMultiDict() == req.headers
     assert () == req.raw_headers
@@ -63,11 +66,13 @@ def test_ctor() -> None:
     req = make_mocked_request("GET", "/path/to?a=1&b=2")
 
     assert "GET" == req.method
     assert HttpVersion(1, 1) == req.version
     # MacOS may return CamelCased host name, need .lower()
-    assert req.host.lower() == socket.getfqdn().lower()
+    # FQDN can be wider than host, e.g.
+    # 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
+    assert req.host.lower() in socket.getfqdn().lower()
     assert "/path/to?a=1&b=2" == req.path_qs
     assert "/path/to" == req.path
     assert "a=1&b=2" == req.query_string
     assert CIMultiDict() == req.headers
     assert () == req.raw_headers

Vulnerable code: 
    assert req.host == socket.getfqdn()
    assert req.host.lower() == socket.getfqdn().lower()
Fixed code: Add HTTP method validation.
_CONTAINS_CONTROL_CHAR_RE = re.compile(r"[^-!#$%&'*+.^_`|~0-9a-zA-Z]")
        match = _CONTAINS_CONTROL_CHAR_RE.search(method)
        if match:
            raise ValueError(
                f"Method cannot contain non-token characters {method!r} "
                "(found at least {match.group()!r})"
            )

def test_method_invalid(make_request) -> None:
    with pytest.raises(ValueError, match="Method cannot contain non-token characters"):
        make_request("METHOD WITH\nWHITESPACES", "http://python.org/")


    # MacOS may return CamelCased host name, need .lower()
    # FQDN can be wider than host, e.g.
    # 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
    assert req.host.lower() in socket.getfqdn().lower()
    # FQDN can be wider than host, e.g.
    # 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
    assert req.host.lower() in socket.getfqdn().lower()