VYPR
Critical severityNVD Advisory· Published Mar 12, 2020· Updated Aug 4, 2024

CVE-2020-10109

CVE-2020-10109

Description

In Twisted Web through 19.10.0, there was an HTTP request splitting vulnerability. When presented with a content-length and a chunked encoding header, the content-length took precedence and the remainder of the request body was interpreted as a pipelined request.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Twisted Web <=19.10.0 misparses conflicting Content-Length and Transfer-Encoding: chunked headers, allowing HTTP request splitting/smuggling.

Vulnerability

Overview

In Twisted Web versions through 19.10.0, incorrect HTTP header precedence handling leads to an HTTP request splitting vulnerability. When both a Content-Length header and a Transfer-Encoding: chunked header are present, the server incorrectly prioritizes the Content-Length value. This causes the remainder of the request body beyond the declared content length to be interpreted as a new, pipelined HTTP request [1][3].

Exploitation

An attacker can exploit this by sending a crafted HTTP request that includes both headers. The server will process the request body up to the specified Content-Length, then treat any subsequent bytes as the start of a second request. No authentication is required; the attacker only needs network access to send raw HTTP requests to a vulnerable Twisted endpoint [3][4].

Impact

Successful exploitation enables request smuggling scenarios. An attacker can inject arbitrary HTTP requests that get processed by the server, potentially leading to cache poisoning, session hijacking, or security filter bypass. The impact is highly contextual and depends on the application's role and deployment [4].

Mitigation

The vulnerability is fixed in Twisted version 20.3.0rc1 and later [4]. Ubuntu users received a security update via USN-4308-1 [2]. Users are strongly advised to upgrade to a patched version.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
TwistedPyPI
< 20.3.020.3.0

Affected products

21

Patches

1
4a7d22e490bb

Fix several request smuggling attacks.

https://github.com/twisted/twistedMark WilliamsFeb 17, 2020via ghsa
3 files changed · +187 15
  • src/twisted/web/http.py+49 15 modified
    @@ -2171,6 +2171,51 @@ def _finishRequestBody(self, data):
             self.allContentReceived()
             self._dataBuffer.append(data)
     
    +    def _maybeChooseTransferDecoder(self, header, data):
    +        """
    +        If the provided header is C{content-length} or
    +        C{transfer-encoding}, choose the appropriate decoder if any.
    +
    +        Returns L{True} if the request can proceed and L{False} if not.
    +        """
    +
    +        def fail():
    +            self._respondToBadRequestAndDisconnect()
    +            self.length = None
    +
    +        # Can this header determine the length?
    +        if header == b'content-length':
    +            try:
    +                length = int(data)
    +            except ValueError:
    +                fail()
    +                return False
    +            newTransferDecoder = _IdentityTransferDecoder(
    +                length, self.requests[-1].handleContentChunk, self._finishRequestBody)
    +        elif header == b'transfer-encoding':
    +            # XXX Rather poorly tested code block, apparently only exercised by
    +            # test_chunkedEncoding
    +            if data.lower() == b'chunked':
    +                length = None
    +                newTransferDecoder = _ChunkedTransferDecoder(
    +                    self.requests[-1].handleContentChunk, self._finishRequestBody)
    +            elif data.lower() == b'identity':
    +                return True
    +            else:
    +                fail()
    +                return False
    +        else:
    +            # It's not a length related header, so exit
    +            return True
    +
    +        if self._transferDecoder is not None:
    +            fail()
    +            return False
    +        else:
    +            self.length = length
    +            self._transferDecoder = newTransferDecoder
    +            return True
    +
     
         def headerReceived(self, line):
             """
    @@ -2196,21 +2241,10 @@ def headerReceived(self, line):
     
             header = header.lower()
             data = data.strip()
    -        if header == b'content-length':
    -            try:
    -                self.length = int(data)
    -            except ValueError:
    -                self._respondToBadRequestAndDisconnect()
    -                self.length = None
    -                return False
    -            self._transferDecoder = _IdentityTransferDecoder(
    -                self.length, self.requests[-1].handleContentChunk, self._finishRequestBody)
    -        elif header == b'transfer-encoding' and data.lower() == b'chunked':
    -            # XXX Rather poorly tested code block, apparently only exercised by
    -            # test_chunkedEncoding
    -            self.length = None
    -            self._transferDecoder = _ChunkedTransferDecoder(
    -                self.requests[-1].handleContentChunk, self._finishRequestBody)
    +
    +        if not self._maybeChooseTransferDecoder(header, data):
    +            return False
    +
             reqHeaders = self.requests[-1].requestHeaders
             values = reqHeaders.getRawHeaders(header)
             if values is not None:
    
  • src/twisted/web/newsfragments/9770.bugfix+1 0 added
    @@ -0,0 +1 @@
    +Fix several request smuggling attacks: requests with multiple Content-Length headers were allowed (thanks to Jake Miller from Bishop Fox and ZeddYu Lu) and now fail with a 400; requests with a Content-Length header and a Transfer-Encoding header honored the first header (thanks to Jake Miller from Bishop Fox) and now fail with a 400; requests whose Transfer-Encoding header had a value other than "chunked" and "identity" (thanks to ZeddYu Lu) were allowed and now fail a 400.
    \ No newline at end of file
    
  • src/twisted/web/test/test_http.py+137 0 modified
    @@ -2252,6 +2252,143 @@ def process(self):
             self.flushLoggedErrors(AttributeError)
     
     
    +    def assertDisconnectingBadRequest(self, request):
    +        """
    +        Assert that the given request bytes fail with a 400 bad
    +        request without calling L{Request.process}.
    +
    +        @param request: A raw HTTP request
    +        @type request: L{bytes}
    +        """
    +        class FailedRequest(http.Request):
    +            processed = False
    +            def process(self):
    +                FailedRequest.processed = True
    +
    +        channel = self.runRequest(request, FailedRequest, success=False)
    +        self.assertFalse(FailedRequest.processed, "Request.process called")
    +        self.assertEqual(
    +            channel.transport.value(),
    +            b"HTTP/1.1 400 Bad Request\r\n\r\n")
    +        self.assertTrue(channel.transport.disconnecting)
    +
    +
    +    def test_duplicateContentLengths(self):
    +        """
    +        A request which includes multiple C{content-length} headers
    +        fails with a 400 response without calling L{Request.process}.
    +        """
    +        self.assertRequestRejected([
    +            b'GET /a HTTP/1.1',
    +            b'Content-Length: 56',
    +            b'Content-Length: 0',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +        ])
    +
    +
    +    def test_duplicateContentLengthsWithPipelinedRequests(self):
    +        """
    +        Two pipelined requests, the first of which includes multiple
    +        C{content-length} headers, trigger a 400 response without
    +        calling L{Request.process}.
    +        """
    +        self.assertRequestRejected([
    +            b'GET /a HTTP/1.1',
    +            b'Content-Length: 56',
    +            b'Content-Length: 0',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +            b'GET /a HTTP/1.1',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +        ])
    +
    +
    +    def test_contentLengthAndTransferEncoding(self):
    +        """
    +        A request that includes both C{content-length} and
    +        C{transfer-encoding} headers fails with a 400 response without
    +        calling L{Request.process}.
    +        """
    +        self.assertRequestRejected([
    +            b'GET /a HTTP/1.1',
    +            b'Transfer-Encoding: chunked',
    +            b'Content-Length: 0',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +        ])
    +
    +
    +    def test_contentLengthAndTransferEncodingWithPipelinedRequests(self):
    +        """
    +        Two pipelined requests, the first of which includes both
    +        C{content-length} and C{transfer-encoding} headers, triggers a
    +        400 response without calling L{Request.process}.
    +        """
    +        self.assertRequestRejected([
    +            b'GET /a HTTP/1.1',
    +            b'Transfer-Encoding: chunked',
    +            b'Content-Length: 0',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +            b'GET /a HTTP/1.1',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +        ])
    +
    +
    +    def test_unknownTransferEncoding(self):
    +        """
    +        A request whose C{transfer-encoding} header includes a value
    +        other than C{chunked} or C{identity} fails with a 400 response
    +        without calling L{Request.process}.
    +        """
    +        self.assertRequestRejected([
    +            b'GET /a HTTP/1.1',
    +            b'Transfer-Encoding: unknown',
    +            b'Host: host.invalid',
    +            b'',
    +            b'',
    +        ])
    +
    +
    +    def test_transferEncodingIdentity(self):
    +        """
    +        A request with a valid C{content-length} and a
    +        C{transfer-encoding} whose value is C{identity} succeeds.
    +        """
    +        body = []
    +
    +        class SuccessfulRequest(http.Request):
    +            processed = False
    +            def process(self):
    +                body.append(self.content.read())
    +                self.setHeader(b'content-length', b'0')
    +                self.finish()
    +
    +        request = b'''\
    +GET / HTTP/1.1
    +Host: host.invalid
    +Content-Length: 2
    +Transfer-Encoding: identity
    +
    +ok
    +'''
    +        channel = self.runRequest(request, SuccessfulRequest, False)
    +        self.assertEqual(body, [b'ok'])
    +        self.assertEqual(
    +            channel.transport.value(),
    +            b'HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n',
    +        )
    +
    +
     
     class QueryArgumentsTests(unittest.TestCase):
         def testParseqs(self):
    

Vulnerability mechanics

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

References

19

News mentions

0

No linked articles in our index yet.