CVE-2020-11078: Improper Neutralization of CRLF Sequences ('CRLF Injection') (CWE-93)
value: In httplib2 before version 0.18.0, an attacker controlling unescaped part of uri for `httplib2.Http.request()` could change request headers and body, send additional hidden requests to same server. This vulnerability impacts software that uses httplib2 with uri constructed by string concatenation, as opposed to proper urllib building with escaping. This has been fixed in 0.18.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: 6.8
Published: 2020-05-20T16:15Z
Repository: https://github.com/httplib2/httplib2
Language: Python
Files changed: python2/httplib2/__init__.py, python3/httplib2/__init__.py, tests/__init__.py, tests/test_http.py
Commit date: 2020-05-20 15:00:24 +0300
Commit message: IMPORTANT security vulnerability CWE-93 CRLF injection

Force %xx quote of space, CR, LF characters in uri.

Special thanks to Recar https://github.com/Ciyfly for discrete notification.

https://cwe.mitre.org/data/definitions/93.html

Diff: diff --git a/python2/httplib2/__init__.py b/python2/httplib2/__init__.py
index 97e06c1..34281b7 100644
--- a/python2/httplib2/__init__.py
+++ b/python2/httplib2/__init__.py
@@ -1983,10 +1983,13 @@ class Http(object):
 
             if "user-agent" not in headers:
                 headers["user-agent"] = "Python-httplib2/%s (gzip)" % __version__
 
             uri = iri2uri(uri)
+            # Prevent CWE-75 space injection to manipulate request via part of uri.
+            # Prevent CWE-93 CRLF injection to modify headers via part of uri.
+            uri = uri.replace(" ", "%20").replace("\r", "%0D").replace("\n", "%0A")
 
             (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
 
             proxy_info = self._get_proxy_info(scheme, authority)
 
diff --git a/python3/httplib2/__init__.py b/python3/httplib2/__init__.py
index 8785cc1..c0b1418 100644
--- a/python3/httplib2/__init__.py
+++ b/python3/httplib2/__init__.py
@@ -1788,10 +1788,13 @@ a string that contains the response entity body.
 
             if "user-agent" not in headers:
                 headers["user-agent"] = "Python-httplib2/%s (gzip)" % __version__
 
             uri = iri2uri(uri)
+            # Prevent CWE-75 space injection to manipulate request via part of uri.
+            # Prevent CWE-93 CRLF injection to modify headers via part of uri.
+            uri = uri.replace(" ", "%20").replace("\r", "%0D").replace("\n", "%0A")
 
             (scheme, authority, request_uri, defrag_uri) = urlnorm(uri)
 
             conn_key = scheme + ":" + authority
             conn = self.connections.get(conn_key)
diff --git a/tests/__init__.py b/tests/__init__.py
index a15db9e..02a3ecf 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -73,11 +73,11 @@ class BufferedReader(object):
             # crutch to enable HttpRequest.from_bytes
             if self._sock is None:
                 chunk = b""
             else:
                 chunk = self._sock.recv(8 << 10)
-            # print('!!! recv', chunk)
+            # print("!!! recv", chunk)
             if not chunk:
                 self._end = True
                 if untilend:
                     return
                 else:
diff --git a/tests/test_http.py b/tests/test_http.py
index df99016..f61992c 100644
--- a/tests/test_http.py
+++ b/tests/test_http.py
@@ -701,5 +701,35 @@ def test_custom_redirect_codes():
     http.redirect_codes = set([300])
     with tests.server_const_http(status=301, request_count=1) as uri:
         response, content = http.request(uri, "GET")
         assert response.status == 301
         assert response.previous is None
+
+
+def test_cwe93_inject_crlf():
+    # https://cwe.mitre.org/data/definitions/93.html
+    # GET /?q= HTTP/1.1      <- injected "HTTP/1.1" from attacker
+    # injected: attack
+    # ignore-http: HTTP/1.1  <- nominal "HTTP/1.1" from library
+    # Host: localhost:57285
+    http = httplib2.Http()
+    with tests.server_reflect() as uri:
+        danger_url = urllib.parse.urljoin(
+            uri, "?q= HTTP/1.1\r\ninjected: attack\r\nignore-http:"
+        )
+        response, content = http.request(danger_url, "GET")
+        assert response.status == 200
+        req = tests.HttpRequest.from_bytes(content)
+        assert req.headers.get("injected") is None
+
+
+def test_inject_space():
+    # Injecting space into request line is precursor to CWE-93 and possibly other injections
+    http = httplib2.Http()
+    with tests.server_reflect() as uri:
+        # "\r\nignore-http:" suffix is nuance for current server implementation
+        # please only pay attention to space after "?q="
+        danger_url = urllib.parse.urljoin(uri, "?q= HTTP/1.1\r\nignore-http:")
+        response, content = http.request(danger_url, "GET")
+        assert response.status == 200
+        req = tests.HttpRequest.from_bytes(content)
+        assert req.uri == "/?q=%20HTTP/1.1%0D%0Aignore-http:"

Vulnerable code:             # print('!!! recv', chunk)
Fixed code:             # Prevent CWE-75 space injection to manipulate request via part of uri.
            # Prevent CWE-93 CRLF injection to modify headers via part of uri.
            uri = uri.replace(" ", "%20").replace("\r", "%0D").replace("\n", "%0A")
            # Prevent CWE-75 space injection to manipulate request via part of uri.
            # Prevent CWE-93 CRLF injection to modify headers via part of uri.
            uri = uri.replace(" ", "%20").replace("\r", "%0D").replace("\n", "%0A")
            # print("!!! recv", chunk)


def test_cwe93_inject_crlf():
    # https://cwe.mitre.org/data/definitions/93.html
    # GET /?q= HTTP/1.1      <- injected "HTTP/1.1" from attacker
    # injected: attack
    # ignore-http: HTTP/1.1  <- nominal "HTTP/1.1" from library
    # Host: localhost:57285
    http = httplib2.Http()
    with tests.server_reflect() as uri:
        danger_url = urllib.parse.urljoin(
            uri, "?q= HTTP/1.1\r\ninjected: attack\r\nignore-http:"
        )
        response, content = http.request(danger_url, "GET")
        assert response.status == 200
        req = tests.HttpRequest.from_bytes(content)
        assert req.headers.get("injected") is None


def test_inject_space():
    # Injecting space into request line is precursor to CWE-93 and possibly other injections
    http = httplib2.Http()
    with tests.server_reflect() as uri:
        # "\r\nignore-http:" suffix is nuance for current server implementation
        # please only pay attention to space after "?q="
        danger_url = urllib.parse.urljoin(uri, "?q= HTTP/1.1\r\nignore-http:")
        response, content = http.request(danger_url, "GET")
        assert response.status == 200
        req = tests.HttpRequest.from_bytes(content)
        assert req.uri == "/?q=%20HTTP/1.1%0D%0Aignore-http:"