Repository: mitmproxy/mitmproxy

Issue:
Wrong SNI hostname used for upstream HTTPS proxy
#### Problem Description
When using mitmproxy with an upstream HTTPS proxy, it will attempt to use the client connection's target host for SNI in the outer TLS connection, and will subsequently fail to validate that connection and return "502 Bad Gateway" -- "Certificate verify failed: Hostname mismatch"

#### Steps to reproduce the behavior:
1. Start mitmproxy with an HTTPS upstream proxy, e.g. `mitmproxy --mode upstream:https://example.com:1234 --upstream-auth user:pass`
2. Issue an HTTP request, e.g. `curl -ski -x http://127.0.0.1:8080 https://api.ipify.org`
3. mitmproxy will log `warn: 127.0.0.1:55584: Server TLS handshake failed. Certificate verify failed: Hostname mismatch` and return a `502 Bad Gateway` error with reason `Certificate verify failed: Hostname mismatch` to the client.
4. A packet capture of the upstream traffic shows that mitmproxy tried to use `api.ipify.org` for SNI in the TLS connection to the upstream proxy.

#### System Information
```
Mitmproxy: 7.0.4
Python:    3.8.10
OpenSSL:   OpenSSL 1.1.1l  24 Aug 2021
Platform:  Linux-5.4.0-26-generic-x86_64-with-glibc2.29
```


Discussion:
I haven't verified the issue yet, but thank you already for the clear report! 

Resolving patch:
diff --git a/mitmproxy/addons/tlsconfig.py b/mitmproxy/addons/tlsconfig.py
--- a/mitmproxy/addons/tlsconfig.py
+++ b/mitmproxy/addons/tlsconfig.py
@@ -118,7 +118,9 @@ def tls_start_client(self, tls_start: tls.TlsData) -> None:
         if tls_start.ssl_conn is not None:
             return  # a user addon has already provided the pyOpenSSL context.
 
-        client: connection.Client = tls_start.context.client
+        assert isinstance(tls_start.conn, connection.Client)
+
+        client: connection.Client = tls_start.conn
         server: connection.Server = tls_start.context.server
 
         entry = self.get_cert(tls_start.context)
@@ -168,8 +170,11 @@ def tls_start_server(self, tls_start: tls.TlsData) -> None:
         if tls_start.ssl_conn is not None:
             return  # a user addon has already provided the pyOpenSSL context.
 
+        assert isinstance(tls_start.conn, connection.Server)
+
         client: connection.Client = tls_start.context.client
-        server: connection.Server = tls_start.context.server
+        # tls_start.conn may be different from tls_start.context.server, e.g. an upstream HTTPS proxy.
+        server: connection.Server = tls_start.conn
         assert server.address
 
         if ctx.options.ssl_insecure:
diff --git a/mitmproxy/proxy/layers/tls.py b/mitmproxy/proxy/layers/tls.py
--- a/mitmproxy/proxy/layers/tls.py
+++ b/mitmproxy/proxy/layers/tls.py
@@ -351,7 +351,8 @@ def start_handshake(self) -> layer.CommandGenerator[None]:
             self.tunnel_state = tunnel.TunnelState.CLOSED
         else:
             yield from self.start_tls()
-            yield from self.receive_handshake_data(b"")
+            if self.tls:
+                yield from self.receive_handshake_data(b"")
 
     def event_to_child(self, event: events.Event) -> layer.CommandGenerator[None]:
         if self.wait_for_clienthello:


Test patch:
diff --git a/test/mitmproxy/proxy/layers/test_modes.py b/test/mitmproxy/proxy/layers/test_modes.py
--- a/test/mitmproxy/proxy/layers/test_modes.py
+++ b/test/mitmproxy/proxy/layers/test_modes.py
@@ -70,6 +70,7 @@ def test_upstream_https(tctx):
         << SendData(upstream, clienthello)
     )
     assert upstream().address == ("example.mitmproxy.org", 8081)
+    assert upstream().sni == "example.mitmproxy.org"
     assert (
         proxy2
         >> DataReceived(tctx2.client, clienthello())
diff --git a/test/mitmproxy/proxy/tutils.py b/test/mitmproxy/proxy/tutils.py
--- a/test/mitmproxy/proxy/tutils.py
+++ b/test/mitmproxy/proxy/tutils.py
@@ -201,9 +201,13 @@ def __bool__(self):
                     x.connection.timestamp_end = 1624544787
 
                 self.actual.append(x)
+                cmds: typing.List[commands.Command] = []
                 try:
-                    cmds: typing.List[commands.Command] = list(self.layer.handle_event(x))
+                    # consume them one by one so that we can extend the log with all commands until traceback.
+                    for cmd in self.layer.handle_event(x):
+                        cmds.append(cmd)
                 except Exception:
+                    self.actual.extend(cmds)
                     self.actual.append(_TracebackInPlaybook(traceback.format_exc()))
                     break