Repository: mitmproxy/mitmproxy

Issue:
get HTTP ERROR 413 return
mitmproxy version: master newest
python version: 3.8.5
OS: windows 10
browser: Chrome 85
use mitmproxy browser `https://ww1.sinaimg.cn/mw600/5f789f8fgy1gjnnwi718sj20s50funk3.jpg` ,get return 413.
i find,
commit `828ba0c2e79d8c54806a1c9eefb6007abc8aabc0` is normal,
commit `c0f62cc559ae4f0778d6079a2b31d2ce0309dda6` is return ERROR 413.





Discussion:
We just had this in #4221 and it looked like it's fixed in master (@Kriechi). But I can confirm that HEAD is broken. Interestingly it does work for the URL in #4221.

So it looks like some of the HTTP/2 code changed and made it compatible with one server (https://g.alicdn.com/kg/kmd-adapter/0.0.6/index.js) while breaking compatibility with the other (https://ww1.sinaimg.cn/mw600/5f789f8fgy1gjnnwi718sj20s50funk3.jpg).

I can confirm that 828ba0c2e79d8c54806a1c9eefb6007abc8aabc0 works for _both_ URLs.

Then c0f62cc559ae4f0778d6079a2b31d2ce0309dda6 broke both.

And then HEAD (currently 0f963248872b3ae7c6d9b5065cd9ea79ae73f153) only works for one of them. 
> We just had this in #4221 and it looked like it's fixed in master (@Kriechi). But I can confirm that HEAD is broken. Interestingly it does work for the URL in #4221.
> 
> So it looks like some of the HTTP/2 code changed and made it compatible with one server (https://g.alicdn.com/kg/kmd-adapter/0.0.6/index.js) while breaking compatibility with the other (https://ww1.sinaimg.cn/mw600/5f789f8fgy1gjnnwi718sj20s50funk3.jpg).
> 
> I can confirm that [828ba0c](https://github.com/mitmproxy/mitmproxy/commit/828ba0c2e79d8c54806a1c9eefb6007abc8aabc0) works for _both_ URLs.
> 
> Then [c0f62cc](https://github.com/mitmproxy/mitmproxy/commit/c0f62cc559ae4f0778d6079a2b31d2ce0309dda6) broke both.
> 
> And then HEAD (currently [0f96324](https://github.com/mitmproxy/mitmproxy/commit/0f963248872b3ae7c6d9b5065cd9ea79ae73f153)) only works for one of them.

I have tested this many times and found that the 413 error does not occur 100% of the time.

It's possible that it will be normal after a while, but these cases have one thing in common, all the servers that return exceptions are using Tengine (https://tengine.taobao.org/), which is a Nginx-based HTTP server.

www.taobao.com,www.bilibili.com uses Tengine, and you can see a lot of 413 code errors when you visit both sites.
Hopefully, this finding will help.

(I've also found a lot of 501 code errors that appear and disappear for no apparent reason, not sure if it's due to a change in the h2 module version.)

@Prinzhorn thanks for bisecting! 🎉 

I can't spot an obvious bug - but based on past experience I'm inclined to blame a faulty or non-RFC-compliant implementation in tengine or an outdated library on the server... We've seen lots of incompatibilities in the early days of HTTP/2. Some vendors even claimed "due to performance benefit we chose to break RFC compliance"...

I'll try to find some time and do another deep-dive, but unless anyone reports a similar problem with other webserver (most importantly the "real" nginx), I think we need to find a workaround for tengine specifically...
> @Prinzhorn thanks for bisecting! tada

@Kriechi that was mostly @bigfoxtail's work

> I can't spot an obvious bug - but based on past experience I'm inclined to blame a faulty or non-RFC-compliant implementation in tengine or an outdated library on the server... We've seen lots of incompatibilities in the early days of HTTP/2. Some vendors even claimed "due to performance benefit we chose to break RFC compliance"...
> 
> I'll try to find some time and do another deep-dive, but unless anyone reports a similar problem with other webserver (most importantly the "real" nginx), I think we need to find a workaround for tengine specifically...

Even if the server is not RFC compliant, the URLs work in Firefox, Chromium, curl (with `--http2`) and Burp Suite (with HTTP/2 enabled). Obviously they have a lot more resources to make their stack compatible to almost anything. I just wanted to point that out. But like you said, if this doesn't become a more widespread issue we might just wait and see what happens.

On the other hand it _did_ work at some point.
I found another module httpx that uses http2 that works fine.
In the httpcore module used by httpx I found this part of the code.
https://github.com/encode/httpcore/blob/master/httpcore/_sync/http2.py
Line 286:
```
        # Send the request.
        seen_headers = set(key for key, value in headers)
        has_body = (
            b"content-length" in seen_headers or b"transfer-encoding" in seen_headers
        )

        self.send_headers(method, url, headers, has_body, timeout)
        if has_body:
            self.send_body(stream, timeout)
```
In c0f62cc559ae4f0778d6079a2b31d2ce0309dda6 you have modified some codes
```
-                end_stream=self.no_request_body,
```
and 
```
-        if not self.no_request_body:
-            self.connections[self.server_conn].safe_send_body(
-                self.raise_zombie,
-                self.server_stream_id,
-                chunks
-            )
```
Data was sent without determining the existence of the request body.
That seems to be the problem.


Resolving patch:
diff --git a/mitmproxy/proxy/protocol/http.py b/mitmproxy/proxy/protocol/http.py
--- a/mitmproxy/proxy/protocol/http.py
+++ b/mitmproxy/proxy/protocol/http.py
@@ -357,6 +357,7 @@ def _process_flow(self, f):
 
                 def get_response():
                     self.send_request_headers(f.request)
+
                     if f.request.stream:
                         chunks = self.read_request_body(f.request)
                         if callable(f.request.stream):
diff --git a/mitmproxy/proxy/protocol/http2.py b/mitmproxy/proxy/protocol/http2.py
--- a/mitmproxy/proxy/protocol/http2.py
+++ b/mitmproxy/proxy/protocol/http2.py
@@ -1,6 +1,7 @@
 import threading
 import time
 import functools
+import types
 from typing import Dict, Callable, Any, List, Optional  # noqa
 
 import h2.exceptions
@@ -602,6 +603,7 @@ def send_request_headers(self, request):
                 self.raise_zombie,
                 self.server_stream_id,
                 headers,
+                end_stream=(False if request.content or request.trailers or request.stream else True),
                 priority_exclusive=priority_exclusive,
                 priority_depends_on=priority_depends_on,
                 priority_weight=priority_weight,
@@ -618,12 +620,13 @@ def send_request_body(self, request, chunks):
             # nothing to do here
             return
 
-        self.connections[self.server_conn].safe_send_body(
-            self.raise_zombie,
-            self.server_stream_id,
-            chunks,
-            end_stream=(request.trailers is None),
-        )
+        if isinstance(chunks, types.GeneratorType) or (chunks and chunks[0]):
+            self.connections[self.server_conn].safe_send_body(
+                self.raise_zombie,
+                self.server_stream_id,
+                chunks,
+                end_stream=(request.trailers is None),
+            )
 
     @detect_zombie_stream
     def send_request_trailers(self, request):


Test patch:
diff --git a/test/mitmproxy/proxy/protocol/test_http2.py b/test/mitmproxy/proxy/protocol/test_http2.py
--- a/test/mitmproxy/proxy/protocol/test_http2.py
+++ b/test/mitmproxy/proxy/protocol/test_http2.py
@@ -192,7 +192,7 @@ def teardown_class(cls):
         _Http2ServerBase.teardown_class()
 
 
-class TestSimple(_Http2Test):
+class TestSimpleRequestWithBody(_Http2Test):
     request_body_buffer = b''
 
     @classmethod
@@ -226,7 +226,7 @@ def handle_server_event(cls, event, h2_conn, rfile, wfile):
             cls.request_body_buffer += event.data
         return True
 
-    def test_simple(self):
+    def test_simple_request_with_body(self):
         response_body_buffer = b''
         h2_conn = self.setup_connection()
 
@@ -274,6 +274,77 @@ def test_simple(self):
         assert response_body_buffer == b'response body'
 
 
+class TestSimpleRequestWithoutBody(_Http2Test):
+    @classmethod
+    def handle_server_event(cls, event, h2_conn, rfile, wfile):
+        if isinstance(event, h2.events.ConnectionTerminated):
+            return False
+        elif isinstance(event, h2.events.RequestReceived):
+            assert (b'self.client-foo', b'self.client-bar-1') in event.headers
+        elif isinstance(event, h2.events.StreamEnded):
+            import warnings
+            with warnings.catch_warnings():
+                # Ignore UnicodeWarning:
+                # h2/utilities.py:64: UnicodeWarning: Unicode equal comparison
+                # failed to convert both arguments to Unicode - interpreting
+                # them as being unequal.
+                #     elif header[0] in (b'cookie', u'cookie') and len(header[1]) < 20:
+
+                warnings.simplefilter("ignore")
+                h2_conn.send_headers(event.stream_id, [
+                    (':status', '200'),
+                ])
+            h2_conn.send_data(event.stream_id, b'response body')
+            h2_conn.end_stream(event.stream_id)
+            wfile.write(h2_conn.data_to_send())
+            wfile.flush()
+        elif isinstance(event, h2.events.DataReceived):
+            return False
+        return True
+
+    def test_simple(self):
+        response_body_buffer = b''
+        h2_conn = self.setup_connection()
+
+        self._send_request(
+            self.client.wfile,
+            h2_conn,
+            headers=[
+                (':authority', "127.0.0.1:{}".format(self.server.server.address[1])),
+                (':method', 'GET'),
+                (':scheme', 'https'),
+                (':path', '/'),
+                ('self.client-FoO', 'self.client-bar-1'),
+            ])
+
+        done = False
+        while not done:
+            try:
+                raw = b''.join(http2.read_raw_frame(self.client.rfile))
+                events = h2_conn.receive_data(raw)
+            except exceptions.HttpException:
+                print(traceback.format_exc())
+                assert False
+
+            self.client.wfile.write(h2_conn.data_to_send())
+            self.client.wfile.flush()
+
+            for event in events:
+                if isinstance(event, h2.events.DataReceived):
+                    response_body_buffer += event.data
+                elif isinstance(event, h2.events.StreamEnded):
+                    done = True
+
+        h2_conn.close_connection()
+        self.client.wfile.write(h2_conn.data_to_send())
+        self.client.wfile.flush()
+
+        assert len(self.master.state.flows) == 1
+        assert self.master.state.flows[0].response.status_code == 200
+        assert self.master.state.flows[0].response.content == b'response body'
+        assert response_body_buffer == b'response body'
+
+
 class TestRequestWithPriority(_Http2Test):
 
     @classmethod
@@ -920,6 +991,7 @@ def handle_server_event(cls, event, h2_conn, rfile, wfile):
         elif isinstance(event, h2.events.DataReceived):
             data = event.data
             assert data
+            print(event)
             h2_conn.close_connection(error_code=5, last_stream_id=42, additional_data=data)
             wfile.write(h2_conn.data_to_send())
             wfile.flush()
@@ -961,6 +1033,7 @@ def requestheaders(self, f):
                         connection_terminated_event = event
                         done = True
             except:
+                print(traceback.format_exc())
                 break
 
         if streaming: