Repository: twisted/twisted

Issue:
twisted.web.server doesn't support HTTP pipelining
**Describe the incorrect behavior you saw**
twisted.web.server responds in wrong order

**Describe how to cause this behavior**

https://gist.github.com/mukeran/01874b38334bb7618415695d666a3f29

When running the above code with `xxx.xxx.xxx.xxx:10780` as the target reverse proxy site and sending the following HTTP pipeline request to twisted, it seems that twisted processes the request asynchronously.

The `/hello` route responds first, then the `/reverse` route responds. The pipeline response is out-of-order.

```http
GET /reverse HTTP/1.1
Host: a.com
Connection: keep-alive

GET /hello HTTP/1.1
Host: a.com
Connection: close

```

**Describe the correct behavior you'd like to see**
Twisted should send the response of `/reverse` first instead of `/hello`'s.

**Testing environment**

 - Operating System and Version:
     ```
     Linux *** 6.2.0-26-generic #26~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Jul 13 16:27:29 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
     DISTRIB_ID=Ubuntu
     DISTRIB_RELEASE=22.04
     DISTRIB_CODENAME=jammy
     DISTRIB_DESCRIPTION="Ubuntu 22.04.2 LTS"
    ```
 - Twisted version: 23.8.0




Discussion:
Thanks for the report.
Sorry. I don't understand how you are testing this.

Can you please provide a complete example, including the client code and the server behind the reverse proxy?

----------


>  it seems that twisted processes the request asynchronously.

This is the core feature of Twisted.
It's an async framework.

So async behaviour is a feature, not a bug.

Regards

For the test:
- Server: use `python3 -m http.server 10780` to start a simple HTTP server with a index.html in current directory. (you may need to set path to `b'/'` in line 34 of the PoC)
- Client: save the HTTP request into a file and use `cat request.http | nc 127.0.0.1 8080` to perform the request.

The HTTP pipeline (https://en.wikipedia.org/wiki/HTTP_pipelining) should be responded in the order the requests arrive.
The requests can be processed asynchronously but the responses should be sent in right order.
I am not sure HTTP pipelining is supported

start this Twisted HTTP server


```python
from twisted.internet import reactor, endpoints
from twisted.web import server
from twisted.web.proxy import ReverseProxyResource
from twisted.web.resource import Resource

class Second(Resource):
    isLeaf = True
    def render_GET(self, request):
        return b'SECOND\n'

class First(Resource):
    isLeaf = True
    def render_GET(self, request):
        def send_response():
            request.write(b'FIRST DELAYED\n')
            request.finish()
        reactor.callLater(0.5, send_response)
        return server.NOT_DONE_YET

root = Resource()

root.putChild(b'second', Second())
root.putChild(b'first', First())

endpoint = endpoints.TCP4ServerEndpoint(reactor, 8080)
endpoint.listen(server.Site(root))
reactor.run()
```

and then trigger the client request using

```shell
echo -en "GET /first HTTP/1.1\r\nHost: a\r\n\r\nGET /second HTTP/1.1\r\nHost: a\r\n\r\n" | nc localhost 8080
```
I don't see any obvious documentation for Twisted HTTP server or client stating that HTTP pipelining is supported.

For the client side, I can see a comment in the code that pipelining is not yet supported.

I know that this is supposed to be standard HTTP 1.1 but I think this is more of feature request than a bug.

I have updated the title if the ticket to make it explicit that this is about pipelining.

Happy to review a PR that will fix this :)

Thanks again for the report
[RFC 9112 section 9.3.2](https://datatracker.ietf.org/doc/html/rfc9112#section-9.3.2-1) said that a server "MUST send the corresponding responses in the same order that the requests were received".

From my perspective, twisted actually support HTTP pipeline. If not, twisted should block the connection after receiving the first request before sending the first response. However, twisted accepts two requests in parallel.

So I suggest twisted should remove the parallel processing ability or respond the requests in order that they arrive.
@mukeran you are definitely correct here, if that is the behavior. We have unit tests which demonstrate queueing and dequeueing of requests for exactly this reason, and I'm not sure how real-life traffic is circumventing that logic.
I agree that HTTP pipeline is not supported by state-of-the-art browsers now. However, HTTP pipeline is still a spec in HTTP/1.1. There may be some implementation that still support it, as the Wikipedia page lists.

This can be **a security vulnerability** if a twisted server has two endpoints, and one of which is controlled by the attacker. When an upstream server requests two endpoints using a HTTP pipeline, the attacker can delay its own response, in order to make the second request's response arrive first. The response of second request will forward to the user who launch the first request on the upstream server.
@glyph You can use the following script and command edited by @adiroiban to replay this issue:

> ```python
> from twisted.internet import reactor, endpoints
> from twisted.web import server
> from twisted.web.proxy import ReverseProxyResource
> from twisted.web.resource import Resource
> 
> class Second(Resource):
>     isLeaf = True
>     def render_GET(self, request):
>         return b'SECOND\n'
> 
> class First(Resource):
>     isLeaf = True
>     def render_GET(self, request):
>         def send_response():
>             request.write(b'FIRST DELAYED\n')
>             request.finish()
>         reactor.callLater(0.5, send_response)
>         return server.NOT_DONE_YET
> 
> root = Resource()
> 
> root.putChild(b'second', Second())
> root.putChild(b'first', First())
> 
> endpoint = endpoints.TCP4ServerEndpoint(reactor, 8080)
> endpoint.listen(server.Site(root))
> reactor.run()
> ```
> 
> and then trigger the client request using
> 
> ```shell
> echo -en "GET /first HTTP/1.1\r\nHost: a\r\n\r\nGET /second HTTP/1.1\r\nHost: a\r\n\r\n" | nc localhost 8080
> ```


Ugh. I see the bug. It's only when multiple requests are sent in the same TCP segment. 🤦 

Resolving patch:
diff --git a/src/twisted/web/http.py b/src/twisted/web/http.py
--- a/src/twisted/web/http.py
+++ b/src/twisted/web/http.py
@@ -2431,14 +2431,38 @@ def allContentReceived(self):
 
         self._handlingRequest = True
 
+        # We go into raw mode here even though we will be receiving lines next
+        # in the protocol; however, this data will be buffered and then passed
+        # back to line mode in the setLineMode call in requestDone.
+        self.setRawMode()
+
         req = self.requests[-1]
         req.requestReceived(command, path, version)
 
-    def dataReceived(self, data):
+    def rawDataReceived(self, data: bytes) -> None:
         """
-        Data was received from the network.  Process it.
+        This is called when this HTTP/1.1 parser is in raw mode rather than
+        line mode.
+
+        It may be in raw mode for one of two reasons:
+
+            1. All the headers of a request have been received and this
+               L{HTTPChannel} is currently receiving its body.
+
+            2. The full content of a request has been received and is currently
+               being processed asynchronously, and this L{HTTPChannel} is
+               buffering the data of all subsequent requests to be parsed
+               later.
+
+        In the second state, the data will be played back later.
+
+        @note: This isn't really a public API, and should be invoked only by
+            L{LineReceiver}'s line parsing logic.  If you wish to drive an
+            L{HTTPChannel} from a custom data source, call C{dataReceived} on
+            it directly.
+
+        @see: L{LineReceive.rawDataReceived}
         """
-        # If we're currently handling a request, buffer this data.
         if self._handlingRequest:
             self._dataBuffer.append(data)
             if (
@@ -2450,9 +2474,7 @@ def dataReceived(self, data):
                 # ready.  See docstring for _optimisticEagerReadSize above.
                 self._networkProducer.pauseProducing()
             return
-        return basic.LineReceiver.dataReceived(self, data)
 
-    def rawDataReceived(self, data):
         self.resetTimeout()
 
         try:


Test patch:
diff --git a/src/twisted/web/test/test_web.py b/src/twisted/web/test/test_web.py
--- a/src/twisted/web/test/test_web.py
+++ b/src/twisted/web/test/test_web.py
@@ -8,6 +8,7 @@
 import os
 import zlib
 from io import BytesIO
+from typing import List
 
 from zope.interface import implementer
 from zope.interface.verify import verifyObject
@@ -15,12 +16,15 @@
 from twisted.internet import interfaces
 from twisted.internet.address import IPv4Address, IPv6Address
 from twisted.internet.task import Clock
-from twisted.internet.testing import EventLoggingObserver
+from twisted.internet.testing import EventLoggingObserver, StringTransport
 from twisted.logger import LogLevel, globalLogPublisher
 from twisted.python import failure, reflect
+from twisted.python.compat import iterbytes
 from twisted.python.filepath import FilePath
 from twisted.trial import unittest
 from twisted.web import error, http, iweb, resource, server
+from twisted.web.resource import Resource
+from twisted.web.server import NOT_DONE_YET, Request, Site
 from twisted.web.static import Data
 from twisted.web.test.requesthelper import DummyChannel, DummyRequest
 from ._util import assertIsFilesystemTemporary
@@ -1849,3 +1853,78 @@ def test_defaultReactor(self):
 
         factory = http.HTTPFactory()
         self.assertIs(factory.reactor, reactor)
+
+
+class QueueResource(Resource):
+    """
+    Add all requests to an internal queue,
+    without responding to the requests.
+    You can access the requests from the queue and handle their response.
+    """
+
+    isLeaf = True
+
+    def __init__(self) -> None:
+        super().__init__()
+        self.dispatchedRequests: List[Request] = []
+
+    def render_GET(self, request: Request) -> int:
+        self.dispatchedRequests.append(request)
+        return NOT_DONE_YET
+
+
+class TestRFC9112Section932(unittest.TestCase):
+    """
+    Verify that HTTP/1.1 request ordering is preserved.
+    """
+
+    def test_multipleRequestsInOneSegment(self) -> None:
+        """
+        Twisted MUST NOT respond to a second HTTP/1.1 request while the first
+        is still pending.
+        """
+        qr = QueueResource()
+        site = Site(qr)
+        proto = site.buildProtocol(None)
+        serverTransport = StringTransport()
+        proto.makeConnection(serverTransport)
+        proto.dataReceived(
+            b"GET /first HTTP/1.1\r\nHost: a\r\n\r\n"
+            b"GET /second HTTP/1.1\r\nHost: a\r\n\r\n"
+        )
+        # The TCP data contains 2 requests,
+        # but only 1 request was dispatched,
+        # as the first request was not yet finalized.
+        self.assertEqual(len(qr.dispatchedRequests), 1)
+        # The first request is finalized and the
+        # second request is dispatched right away.
+        qr.dispatchedRequests[0].finish()
+        self.assertEqual(len(qr.dispatchedRequests), 2)
+
+    def test_multipleRequestsInDifferentSegments(self) -> None:
+        """
+        Twisted MUST NOT respond to a second HTTP/1.1 request while the first
+        is still pending, even if the second request is received in a separate
+        TCP package.
+        """
+        qr = QueueResource()
+        site = Site(qr)
+        proto = site.buildProtocol(None)
+        serverTransport = StringTransport()
+        proto.makeConnection(serverTransport)
+        raw_data = (
+            b"GET /first HTTP/1.1\r\nHost: a\r\n\r\n"
+            b"GET /second HTTP/1.1\r\nHost: a\r\n\r\n"
+        )
+        # Just go byte by byte for the extreme case in which each byte is
+        # received in a separate TCP package.
+        for chunk in iterbytes(raw_data):
+            proto.dataReceived(chunk)
+        # The TCP data contains 2 requests,
+        # but only 1 request was dispatched,
+        # as the first request was not yet finalized.
+        self.assertEqual(len(qr.dispatchedRequests), 1)
+        # The first request is finalized and the
+        # second request is dispatched right away.
+        qr.dispatchedRequests[0].finish()
+        self.assertEqual(len(qr.dispatchedRequests), 2)