CVE-2024-24762: Inefficient Regular Expression Complexity (CWE-1333)
value: `python-multipart` is a streaming multipart parser for Python. When using form data, `python-multipart` uses a Regular Expression to parse the HTTP `Content-Type` header, including options. An attacker could send a custom-made `Content-Type` option that is very difficult for the RegEx to process, consuming CPU resources and stalling indefinitely (minutes or more) while holding the main event loop. This means that process can't handle any more requests, leading to regular expression denial of service. This vulnerability has been patched in version 0.0.7.
CWE description: The product uses a regular expression with an inefficient, possibly exponential worst-case computational complexity that consumes excessive CPU cycles.
CVSS v3 base score: 7.5
Published: 2024-02-05T15:15Z
Repository: https://github.com/Kludex/python-multipart
Language: Python
Files changed: multipart/multipart.py
Commit date: 2024-02-03 04:54:23 -0700
Commit message: ♻️ Refactor header option parser to use the standard library instead of a custom RegEx (#75)


Diff: diff --git a/multipart/multipart.py b/multipart/multipart.py
index a9f1f9f..e1d10fc 100644
--- a/multipart/multipart.py
+++ b/multipart/multipart.py
@@ -7,10 +7,12 @@ import sys
 import shutil
 import logging
 import tempfile
 from io import BytesIO
 from numbers import Number
+from email.message import Message
+from typing import Dict, Union, Tuple
 
 # Unique missing object.
 _missing = object()
 
 # States for the querystring parser.
@@ -74,48 +76,48 @@ OPTION_RE_STR = (
 )
 OPTION_RE = re.compile(OPTION_RE_STR)
 QUOTE = b'"'[0]
 
 
-def parse_options_header(value):
+def parse_options_header(value: Union[str, bytes]) -> Tuple[bytes, Dict[bytes, bytes]]:
     """
     Parses a Content-Type header into a value in the following format:
         (content_type, {parameters})
     """
+    # Uses email.message.Message to parse the header as described in PEP 594.
+    # Ref: https://peps.python.org/pep-0594/#cgi
     if not value:
         return (b'', {})
 
-    # If we are passed a string, we assume that it conforms to WSGI and does
-    # not contain any code point that's not in latin-1.
-    if isinstance(value, str):            # pragma: no cover
-        value = value.encode('latin-1')
+    # If we are passed bytes, we assume that it conforms to WSGI, encoding in latin-1.
+    if isinstance(value, bytes):  # pragma: no cover
+        value = value.decode('latin-1')
+
+    # For types
+    assert isinstance(value, str), 'Value should be a string by now'
 
     # If we have no options, return the string as-is.
-    if b';' not in value:
-        return (value.lower().strip(), {})
+    if ";" not in value:
+        return (value.lower().strip().encode('latin-1'), {})
 
     # Split at the first semicolon, to get our value and then options.
-    ctype, rest = value.split(b';', 1)
+    # ctype, rest = value.split(b';', 1)
+    message = Message()
+    message['content-type'] = value
+    params = message.get_params()
+    # If there were no parameters, this would have already returned above
+    assert params, 'At least the content type value should be present'
+    ctype = params.pop(0)[0].encode('latin-1')
     options = {}
-
-    # Parse the options.
-    for match in OPTION_RE.finditer(rest):
-        key = match.group(1).lower()
-        value = match.group(2)
-        if value[0] == QUOTE and value[-1] == QUOTE:
-            # Unquote the value.
-            value = value[1:-1]
-            value = value.replace(b'\\\\', b'\\').replace(b'\\"', b'"')
-
+    for param in params:
+        key, value = param
         # If the value is a filename, we need to fix a bug on IE6 that sends
         # the full file path instead of the filename.
-        if key == b'filename':
-            if value[1:3] == b':\\' or value[:2] == b'\\\\':
-                value = value.split(b'\\')[-1]
-
-        options[key] = value
-
+        if key == 'filename':
+            if value[1:3] == ':\\' or value[:2] == '\\\\':
+                value = value.split('\\')[-1]
+        options[key.encode('latin-1')] = value.encode('latin-1')
     return ctype, options
 
 
 class Field:
     """A Field object represents a (parsed) form field.  It represents a single

Vulnerable code: def parse_options_header(value):
    # If we are passed a string, we assume that it conforms to WSGI and does
    # not contain any code point that's not in latin-1.
    if isinstance(value, str):            # pragma: no cover
        value = value.encode('latin-1')
    if b';' not in value:
        return (value.lower().strip(), {})
    ctype, rest = value.split(b';', 1)

    # Parse the options.
    for match in OPTION_RE.finditer(rest):
        key = match.group(1).lower()
        value = match.group(2)
        if value[0] == QUOTE and value[-1] == QUOTE:
            # Unquote the value.
            value = value[1:-1]
            value = value.replace(b'\\\\', b'\\').replace(b'\\"', b'"')

        if key == b'filename':
            if value[1:3] == b':\\' or value[:2] == b'\\\\':
                value = value.split(b'\\')[-1]

        options[key] = value

Fixed code: from email.message import Message
from typing import Dict, Union, Tuple
def parse_options_header(value: Union[str, bytes]) -> Tuple[bytes, Dict[bytes, bytes]]:
    # Uses email.message.Message to parse the header as described in PEP 594.
    # Ref: https://peps.python.org/pep-0594/#cgi
    # If we are passed bytes, we assume that it conforms to WSGI, encoding in latin-1.
    if isinstance(value, bytes):  # pragma: no cover
        value = value.decode('latin-1')

    # For types
    assert isinstance(value, str), 'Value should be a string by now'
    if ";" not in value:
        return (value.lower().strip().encode('latin-1'), {})
    # ctype, rest = value.split(b';', 1)
    message = Message()
    message['content-type'] = value
    params = message.get_params()
    # If there were no parameters, this would have already returned above
    assert params, 'At least the content type value should be present'
    ctype = params.pop(0)[0].encode('latin-1')
    for param in params:
        key, value = param
        if key == 'filename':
            if value[1:3] == ':\\' or value[:2] == '\\\\':
                value = value.split('\\')[-1]
        options[key.encode('latin-1')] = value.encode('latin-1')