VYPR
Critical severityNVD Advisory· Published Apr 4, 2022· Updated Apr 23, 2025

HTTP Request Smuggling in twisted.web

CVE-2022-24801

Description

Twisted is an event-based framework for internet applications, supporting Python 3.6+. Prior to version 22.4.0rc1, the Twisted Web HTTP 1.1 server, located in the twisted.web.http module, parsed several HTTP request constructs more leniently than permitted by RFC 7230. This non-conformant parsing can lead to desync if requests pass through multiple HTTP parsers, potentially resulting in HTTP request smuggling. Users who may be affected use Twisted Web's HTTP 1.1 server and/or proxy and also pass requests through a different HTTP server and/or proxy. The Twisted Web client is not affected. The HTTP 2.0 server uses a different parser, so it is not affected. The issue has been addressed in Twisted 22.4.0rc1. Two workarounds are available: Ensure any vulnerabilities in upstream proxies have been addressed, such as by upgrading them; or filter malformed requests by other means, such as configuration of an upstream proxy.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
TwistedPyPI
< 22.4.022.4.0

Affected products

1

Patches

1
592217e95136

Merge pull request from GHSA-c2jg-hw38-jrqq

https://github.com/twisted/twistedAdi RoibanApr 4, 2022via ghsa
3 files changed · +278 13
  • src/twisted/web/http.py+86 9 modified
    @@ -108,7 +108,7 @@
     import time
     import warnings
     from io import BytesIO
    -from typing import AnyStr, Callable, Optional
    +from typing import AnyStr, Callable, Optional, Tuple
     from urllib.parse import (
         ParseResultBytes,
         unquote_to_bytes as unquote,
    @@ -410,10 +410,39 @@ def toChunk(data):
         return (networkString(f"{len(data):x}"), b"\r\n", data, b"\r\n")
     
     
    -def fromChunk(data):
    +def _ishexdigits(b: bytes) -> bool:
    +    """
    +    Is the string case-insensitively hexidecimal?
    +
    +    It must be composed of one or more characters in the ranges a-f, A-F
    +    and 0-9.
    +    """
    +    for c in b:
    +        if c not in b"0123456789abcdefABCDEF":
    +            return False
    +    return b != b""
    +
    +
    +def _hexint(b: bytes) -> int:
    +    """
    +    Decode a hexadecimal integer.
    +
    +    Unlike L{int(b, 16)}, this raises L{ValueError} when the integer has
    +    a prefix like C{b'0x'}, C{b'+'}, or C{b'-'}, which is desirable when
    +    parsing network protocols.
    +    """
    +    if not _ishexdigits(b):
    +        raise ValueError(b)
    +    return int(b, 16)
    +
    +
    +def fromChunk(data: bytes) -> Tuple[bytes, bytes]:
         """
         Convert chunk to string.
     
    +    Note that this function is not specification compliant: it doesn't handle
    +    chunk extensions.
    +
         @type data: C{bytes}
     
         @return: tuple of (result, remaining) - both C{bytes}.
    @@ -422,7 +451,7 @@ def fromChunk(data):
             byte string.
         """
         prefix, rest = data.split(b"\r\n", 1)
    -    length = int(prefix, 16)
    +    length = _hexint(prefix)
         if length < 0:
             raise ValueError("Chunk length must be >= 0, not %d" % (length,))
         if rest[length : length + 2] != b"\r\n":
    @@ -1790,6 +1819,47 @@ def noMoreData(self):
     maxChunkSizeLineLength = 1024
     
     
    +_chunkExtChars = (
    +    b"\t !\"#$%&'()*+,-./0123456789:;<=>?@"
    +    b"ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`"
    +    b"abcdefghijklmnopqrstuvwxyz{|}~"
    +    b"\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f"
    +    b"\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f"
    +    b"\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf"
    +    b"\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf"
    +    b"\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf"
    +    b"\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf"
    +    b"\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef"
    +    b"\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff"
    +)
    +"""
    +Characters that are valid in a chunk extension.
    +
    +See RFC 7230 section 4.1.1::
    +
    +     chunk-ext      = *( ";" chunk-ext-name [ "=" chunk-ext-val ] )
    +
    +     chunk-ext-name = token
    +     chunk-ext-val  = token / quoted-string
    +
    +And section 3.2.6::
    +
    +     token          = 1*tchar
    +
    +     tchar          = "!" / "#" / "$" / "%" / "&" / "'" / "*"
    +                    / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
    +                    / DIGIT / ALPHA
    +                    ; any VCHAR, except delimiters
    +
    +     quoted-string  = DQUOTE *( qdtext / quoted-pair ) DQUOTE
    +     qdtext         = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
    +     obs-text       = %x80-FF
    +
    +We don't check if chunk extensions are well-formed beyond validating that they
    +don't contain characters outside this range.
    +"""
    +
    +
     class _ChunkedTransferDecoder:
         """
         Protocol for decoding I{chunked} Transfer-Encoding, as defined by RFC 7230,
    @@ -1883,14 +1953,19 @@ def _dataReceived_CHUNK_LENGTH(self) -> bool:
             endOfLengthIndex = self._buffer.find(b";", 0, eolIndex)
             if endOfLengthIndex == -1:
                 endOfLengthIndex = eolIndex
    +        rawLength = self._buffer[0:endOfLengthIndex]
             try:
    -            length = int(self._buffer[0:endOfLengthIndex], 16)
    +            length = _hexint(rawLength)
             except ValueError:
                 raise _MalformedChunkedDataError("Chunk-size must be an integer.")
     
    -        if length < 0:
    -            raise _MalformedChunkedDataError("Chunk-size must not be negative.")
    -        elif length == 0:
    +        ext = self._buffer[endOfLengthIndex + 1 : eolIndex]
    +        if ext and ext.translate(None, _chunkExtChars) != b"":
    +            raise _MalformedChunkedDataError(
    +                f"Invalid characters in chunk extensions: {ext!r}."
    +            )
    +
    +        if length == 0:
                 self.state = "TRAILER"
             else:
                 self.state = "BODY"
    @@ -2246,7 +2321,7 @@ def lineReceived(self, line):
                     self.setRawMode()
             elif line[0] in b" \t":
                 # Continuation of a multi line header.
    -            self.__header = self.__header + b"\n" + line
    +            self.__header += b" " + line.lstrip(b" \t")
             # Regular header line.
             # Processing of header line is delayed to allow accumulating multi
             # line headers.
    @@ -2274,6 +2349,8 @@ def fail():
     
             # Can this header determine the length?
             if header == b"content-length":
    +            if not data.isdigit():
    +                return fail()
                 try:
                     length = int(data)
                 except ValueError:
    @@ -2327,7 +2404,7 @@ def headerReceived(self, line):
                 return False
     
             header = header.lower()
    -        data = data.strip()
    +        data = data.strip(b" \t")
     
             if not self._maybeChooseTransferDecoder(header, data):
                 return False
    
  • src/twisted/web/newsfragments/10323.bugfix+1 0 added
    @@ -0,0 +1 @@
    +twisted.web.http had several several defects in HTTP request parsing that could permit HTTP request smuggling. It now disallows signed Content-Length headers, forbids illegal characters in chunked extensions, forbids 0x prefix to chunk lengths, and only strips spaces and horizontal tab characters from header values. These changes address CVE-2022-24801 and GHSA-c2jg-hw38-jrqq.
    
  • src/twisted/web/test/test_http.py+191 4 modified
    @@ -1279,6 +1279,28 @@ def test_extensions(self):
             p.dataReceived(b"3; x-foo=bar\r\nabc\r\n")
             self.assertEqual(L, [b"abc"])
     
    +    def test_extensionsMalformed(self):
    +        """
    +        L{_ChunkedTransferDecoder.dataReceived} raises
    +        L{_MalformedChunkedDataError} when the chunk extension fields contain
    +        invalid characters.
    +
    +        This is a potential request smuggling vector: see GHSA-c2jg-hw38-jrqq.
    +        """
    +        invalidControl = (
    +            b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\n\x0b\x0c\r\x0e\x0f"
    +            b"\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
    +        )
    +        invalidDelimiter = b"\\"
    +        invalidDel = b"\x7f"
    +        for b in invalidControl + invalidDelimiter + invalidDel:
    +            data = b"3; " + bytes((b,)) + b"\r\nabc\r\n"
    +            p = http._ChunkedTransferDecoder(
    +                lambda b: None,  # pragma: nocov
    +                lambda b: None,  # pragma: nocov
    +            )
    +            self.assertRaises(http._MalformedChunkedDataError, p.dataReceived, data)
    +
         def test_oversizedChunkSizeLine(self):
             """
             L{_ChunkedTransferDecoder.dataReceived} raises
    @@ -1334,6 +1356,22 @@ def test_malformedChunkSizeNegative(self):
                 http._MalformedChunkedDataError, p.dataReceived, b"-3\r\nabc\r\n"
             )
     
    +    def test_malformedChunkSizeHex(self):
    +        """
    +        L{_ChunkedTransferDecoder.dataReceived} raises
    +        L{_MalformedChunkedDataError} when the chunk size is prefixed with
    +        "0x", as if it were a Python integer literal.
    +
    +        This is a potential request smuggling vector: see GHSA-c2jg-hw38-jrqq.
    +        """
    +        p = http._ChunkedTransferDecoder(
    +            lambda b: None,  # pragma: nocov
    +            lambda b: None,  # pragma: nocov
    +        )
    +        self.assertRaises(
    +            http._MalformedChunkedDataError, p.dataReceived, b"0x3\r\nabc\r\n"
    +        )
    +
         def test_malformedChunkEnd(self):
             r"""
             L{_ChunkedTransferDecoder.dataReceived} raises
    @@ -1446,6 +1484,8 @@ def testChunks(self):
                 chunked = b"".join(http.toChunk(s))
                 self.assertEqual((s, b""), http.fromChunk(chunked))
             self.assertRaises(ValueError, http.fromChunk, b"-5\r\nmalformed!\r\n")
    +        self.assertRaises(ValueError, http.fromChunk, b"0xa\r\nmalformed!\r\n")
    +        self.assertRaises(ValueError, http.fromChunk, b"0XA\r\nmalformed!\r\n")
     
         def testConcatenatedChunks(self):
             chunked = b"".join([b"".join(http.toChunk(t)) for t in self.strings])
    @@ -1703,7 +1743,12 @@ def test_headersMultiline(self):
             Line folded headers are handled by L{HTTPChannel} by replacing each
             fold with a single space by the time they are made available to the
             L{Request}. Any leading whitespace in the folded lines of the header
    -        value is preserved.
    +        value is replaced with a single space, per:
    +
    +            A server that receives an obs-fold in a request message ... MUST
    +            ... replace each received obs-fold with one or more SP octets prior
    +            to interpreting the field value or forwarding the message
    +            downstream.
     
             See RFC 7230 section 3.2.4.
             """
    @@ -1740,15 +1785,65 @@ def process(self):
             )
             self.assertEqual(
                 request.requestHeaders.getRawHeaders(b"space"),
    -            [b"space  space"],
    +            [b"space space"],
             )
             self.assertEqual(
                 request.requestHeaders.getRawHeaders(b"spaces"),
    -            [b"spaces   spaces    spaces"],
    +            [b"spaces spaces spaces"],
             )
             self.assertEqual(
                 request.requestHeaders.getRawHeaders(b"tab"),
    -            [b"t \ta \tb"],
    +            [b"t a b"],
    +        )
    +
    +    def test_headerStripWhitespace(self):
    +        """
    +        Leading and trailing space and tab characters are stripped from
    +        headers. Other forms of whitespace are preserved.
    +
    +        See RFC 7230 section 3.2.3 and 3.2.4.
    +        """
    +        processed = []
    +
    +        class MyRequest(http.Request):
    +            def process(self):
    +                processed.append(self)
    +                self.finish()
    +
    +        requestLines = [
    +            b"GET / HTTP/1.0",
    +            b"spaces:   spaces were stripped   ",
    +            b"tabs: \t\ttabs were stripped\t\t",
    +            b"spaces-and-tabs: \t \t spaces and tabs were stripped\t \t",
    +            b"line-tab:   \v vertical tab was preserved\v\t",
    +            b"form-feed: \f form feed was preserved \f  ",
    +            b"",
    +            b"",
    +        ]
    +
    +        self.runRequest(b"\n".join(requestLines), MyRequest, 0)
    +        [request] = processed
    +        # All leading and trailing whitespace is stripped from the
    +        # header-value.
    +        self.assertEqual(
    +            request.requestHeaders.getRawHeaders(b"spaces"),
    +            [b"spaces were stripped"],
    +        )
    +        self.assertEqual(
    +            request.requestHeaders.getRawHeaders(b"tabs"),
    +            [b"tabs were stripped"],
    +        )
    +        self.assertEqual(
    +            request.requestHeaders.getRawHeaders(b"spaces-and-tabs"),
    +            [b"spaces and tabs were stripped"],
    +        )
    +        self.assertEqual(
    +            request.requestHeaders.getRawHeaders(b"line-tab"),
    +            [b"\v vertical tab was preserved\v"],
    +        )
    +        self.assertEqual(
    +            request.requestHeaders.getRawHeaders(b"form-feed"),
    +            [b"\f form feed was preserved \f"],
             )
     
         def test_tooManyHeaders(self):
    @@ -2315,6 +2410,58 @@ def test_duplicateContentLengths(self):
                 ]
             )
     
    +    def test_contentLengthMalformed(self):
    +        """
    +        A request with a non-integer C{Content-Length} header fails with a 400
    +        response without calling L{Request.process}.
    +        """
    +        self.assertRequestRejected(
    +            [
    +                b"GET /a HTTP/1.1",
    +                b"Content-Length: MORE THAN NINE THOUSAND!",
    +                b"Host: host.invalid",
    +                b"",
    +                b"",
    +                b"x" * 9001,
    +            ]
    +        )
    +
    +    def test_contentLengthTooPositive(self):
    +        """
    +        A request with a C{Content-Length} header that begins with a L{+} fails
    +        with a 400 response without calling L{Request.process}.
    +
    +        This is a potential request smuggling vector: see GHSA-c2jg-hw38-jrqq.
    +        """
    +        self.assertRequestRejected(
    +            [
    +                b"GET /a HTTP/1.1",
    +                b"Content-Length: +100",
    +                b"Host: host.invalid",
    +                b"",
    +                b"",
    +                b"x" * 100,
    +            ]
    +        )
    +
    +    def test_contentLengthNegative(self):
    +        """
    +        A request with a C{Content-Length} header that is negative fails with
    +        a 400 response without calling L{Request.process}.
    +
    +        This is a potential request smuggling vector: see GHSA-c2jg-hw38-jrqq.
    +        """
    +        self.assertRequestRejected(
    +            [
    +                b"GET /a HTTP/1.1",
    +                b"Content-Length: -100",
    +                b"Host: host.invalid",
    +                b"",
    +                b"",
    +                b"x" * 200,
    +            ]
    +        )
    +
         def test_duplicateContentLengthsWithPipelinedRequests(self):
             """
             Two pipelined requests, the first of which includes multiple
    @@ -4239,3 +4386,43 @@ def test_sendHeaderSanitizesLinearWhitespace(self):
                     transport.value().splitlines(),
                     [b": ".join([sanitizedBytes, sanitizedBytes])],
                 )
    +
    +
    +class HexHelperTests(unittest.SynchronousTestCase):
    +    """
    +    Test the L{http._hexint} and L{http._ishexdigits} helper functions.
    +    """
    +
    +    badStrings = (b"", b"0x1234", b"feds", b"-123" b"+123")
    +
    +    def test_isHex(self):
    +        """
    +        L{_ishexdigits()} returns L{True} for nonempy bytestrings containing
    +        hexadecimal digits.
    +        """
    +        for s in (b"10", b"abcdef", b"AB1234", b"fed", b"123467890"):
    +            self.assertIs(True, http._ishexdigits(s))
    +
    +    def test_decodes(self):
    +        """
    +        L{_hexint()} returns the integer equivalent of the input.
    +        """
    +        self.assertEqual(10, http._hexint(b"a"))
    +        self.assertEqual(0x10, http._hexint(b"10"))
    +        self.assertEqual(0xABCD123, http._hexint(b"abCD123"))
    +
    +    def test_isNotHex(self):
    +        """
    +        L{_ishexdigits()} returns L{False} for bytestrings that don't contain
    +        hexadecimal digits, including the empty string.
    +        """
    +        for s in self.badStrings:
    +            self.assertIs(False, http._ishexdigits(s))
    +
    +    def test_decodeNotHex(self):
    +        """
    +        L{_hexint()} raises L{ValueError} for bytestrings that can't
    +        be decoded.
    +        """
    +        for s in self.badStrings:
    +            self.assertRaises(ValueError, http._hexint, s)
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

14

News mentions

0

No linked articles in our index yet.