Repository: mitmproxy/mitmproxy

Issue:
Map remote should include port in Host header (mitmweb)
#### Problem Description

When using `--map-remote` in `mitmweb`, the request URL gets replaced, and the `Host` gets updated. However, the port is not added to the Host header, leading to problems when using non-standard ports.

#### Steps to reproduce the behavior:

1. `mitmweb --map-remote '|http://[^/]+|http://portquiz.net:4444' --listen-port 9090'`
2. `curl --proxy http://127.0.0.1:9090 http://github.com`
3. Host header does not contain port
4. It should: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Host

#### System Information

```
Mitmproxy: 9.0.1
Python:    3.11.1
OpenSSL:   OpenSSL 1.1.1s  1 Nov 2022
Platform:  macOS-13.2-arm64-arm-64bit
``


Discussion:
The map remote addon just sets a new URL [here](https://github.com/mitmproxy/mitmproxy/blob/dfeddcf4add66a6ebab1d508c47256185ef6ddb7/mitmproxy/addons/mapremote.py#L68), which seems like the right thing to do. Seems like the bug is in [`mitmproxy.http.Request.host` and `mitmproxy.http.Request.port`](https://github.com/mitmproxy/mitmproxy/blob/main/mitmproxy/http.py) then, contributions welcome!

Resolving patch:
diff --git a/mitmproxy/http.py b/mitmproxy/http.py
--- a/mitmproxy/http.py
+++ b/mitmproxy/http.py
@@ -749,13 +749,7 @@ def host(self) -> str:
     @host.setter
     def host(self, val: Union[str, bytes]) -> None:
         self.data.host = always_str(val, "idna", "strict")
-
-        # Update host header
-        if "Host" in self.data.headers:
-            self.data.headers["Host"] = val
-        # Update authority
-        if self.data.authority:
-            self.authority = url.hostport(self.scheme, self.host, self.port)
+        self._update_host_and_authority()
 
     @property
     def host_header(self) -> Optional[str]:
@@ -794,7 +788,21 @@ def port(self) -> int:
 
     @port.setter
     def port(self, port: int) -> None:
+        if not isinstance(port, int):
+            raise ValueError(f"Port must be an integer, not {port!r}.")
+
         self.data.port = port
+        self._update_host_and_authority()
+
+    def _update_host_and_authority(self) -> None:
+        val = url.hostport(self.scheme, self.host, self.port)
+
+        # Update host header
+        if "Host" in self.data.headers:
+            self.data.headers["Host"] = val
+        # Update authority
+        if self.data.authority:
+            self.authority = val
 
     @property
     def path(self) -> str:


Test patch:
diff --git a/test/mitmproxy/addons/test_mapremote.py b/test/mitmproxy/addons/test_mapremote.py
--- a/test/mitmproxy/addons/test_mapremote.py
+++ b/test/mitmproxy/addons/test_mapremote.py
@@ -27,6 +27,16 @@ def test_simple(self):
             mr.request(f)
             assert f.request.url == "https://mitmproxy.org/img/test.jpg"
 
+    def test_host_header(self):
+        mr = mapremote.MapRemote()
+        with taddons.context(mr) as tctx:
+            tctx.configure(mr, map_remote=["|http://[^/]+|http://example.com:4444"])
+            f = tflow.tflow()
+            f.request.url = b"http://example.org/example"
+            f.request.headers["Host"] = "example.org"
+            mr.request(f)
+            assert f.request.headers.get("Host", "") == "example.com:4444"
+
     def test_is_killed(self):
         mr = mapremote.MapRemote()
         with taddons.context(mr) as tctx:
diff --git a/test/mitmproxy/test_http.py b/test/mitmproxy/test_http.py
--- a/test/mitmproxy/test_http.py
+++ b/test/mitmproxy/test_http.py
@@ -2,6 +2,7 @@
 import email
 import json
 import time
+from typing import Any
 from unittest import mock
 
 import pytest
@@ -10,6 +11,7 @@
 from mitmproxy import flowfilter
 from mitmproxy.http import Headers
 from mitmproxy.http import HTTPFlow
+from mitmproxy.http import Message
 from mitmproxy.http import Request
 from mitmproxy.http import Response
 from mitmproxy.net.http.cookies import CookieAttrs
@@ -158,7 +160,9 @@ def test_scheme(self):
         _test_decoded_attr(treq(), "scheme")
 
     def test_port(self):
-        _test_passthrough_attr(treq(), "port")
+        _test_passthrough_attr(treq(), "port", 1234)
+        with pytest.raises(ValueError):
+            treq().port = "foo"
 
     def test_path(self):
         _test_decoded_attr(treq(), "path")
@@ -199,8 +203,7 @@ def test_host_update_also_updates_header(self):
         request.headers["Host"] = "foo"
         request.authority = "foo"
         request.host = "example.org"
-        assert request.headers["Host"] == "example.org"
-        assert request.authority == "example.org:22"
+        assert request.headers["Host"] == request.authority == "example.org:22"
 
     def test_get_host_header(self):
         no_hdr = treq()
@@ -864,10 +867,10 @@ def test_items(self):
         ]
 
 
-def _test_passthrough_attr(message, attr):
+def _test_passthrough_attr(message: Message, attr: str, value: Any = b"foo") -> None:
     assert getattr(message, attr) == getattr(message.data, attr)
-    setattr(message, attr, b"foo")
-    assert getattr(message.data, attr) == b"foo"
+    setattr(message, attr, value)
+    assert getattr(message.data, attr) == value
 
 
 def _test_decoded_attr(message, attr):