Repository: mitmproxy/mitmproxy

Issue:
Bridge Addresses Causing "block.py" Add-on to Fail with `ValueError`
##### Steps to reproduce the problem:

1. create an ipv6 network connection to the proxy host on a bridge
2. set the device to request through the proxy host
3. watch some traffic start flowing


```
Addon error: Traceback (most recent call last):
  File "/usr/local/Cellar/mitmproxy/4.0.1/libexec/lib/python3.6/site-packages/mitmproxy/addons/block.py", line 24, in clientconnect
    address = ipaddress.ip_address(layer.client_conn.address[0])
  File "/usr/local/opt/python/Frameworks/Python.framework/Versions/3.6/lib/python3.6/ipaddress.py", line 54, in ip_address
    address)
ValueError: 'fe80::884:9f3b:3afd:6eae%bridge100' does not appear to be an IPv4 or IPv6 address

```

##### System information

<!-- Paste the output of "mitmproxy --version" here. -->

```
$ mitmproxy --version
Mitmproxy: 4.0.1 
Python:    3.6.5
OpenSSL:   OpenSSL 1.0.2o  27 Mar 2018
Platform:  Darwin-17.6.0-x86_64-i386-64bit

```




Discussion:

Resolving patch:
diff --git a/mitmproxy/addons/block.py b/mitmproxy/addons/block.py
--- a/mitmproxy/addons/block.py
+++ b/mitmproxy/addons/block.py
@@ -21,17 +21,19 @@ def load(self, loader):
         )
 
     def clientconnect(self, layer):
-        address = ipaddress.ip_address(layer.client_conn.address[0])
+        astr = layer.client_conn.address[0]
+
+        parts = astr.rsplit("%", 1)
+        address = ipaddress.ip_address(parts[0])
         if isinstance(address, ipaddress.IPv6Address):
             address = address.ipv4_mapped or address
 
-        ipa = ipaddress.ip_address(address)
-        if ipa.is_loopback:
+        if address.is_loopback:
             return
 
-        if ctx.options.block_private and ipa.is_private:
-            ctx.log.warn("Client connection from %s killed by block_private" % address)
+        if ctx.options.block_private and address.is_private:
+            ctx.log.warn("Client connection from %s killed by block_private" % astr)
             layer.reply.kill()
-        if ctx.options.block_global and ipa.is_global:
-            ctx.log.warn("Client connection from %s killed by block_global" % address)
+        if ctx.options.block_global and address.is_global:
+            ctx.log.warn("Client connection from %s killed by block_global" % astr)
             layer.reply.kill()
\ No newline at end of file
diff --git a/mitmproxy/optmanager.py b/mitmproxy/optmanager.py
--- a/mitmproxy/optmanager.py
+++ b/mitmproxy/optmanager.py
@@ -91,7 +91,7 @@ class OptManager:
         mutation doesn't change the option state inadvertently.
     """
     def __init__(self):
-        self._deferred: typing.Dict[str, str] = {}
+        self.deferred: typing.Dict[str, str] = {}
         self.changed = blinker.Signal()
         self.errored = blinker.Signal()
         # Options must be the last attribute here - after that, we raise an
@@ -217,6 +217,10 @@ def update_known(self, **kwargs):
                 self.changed.send(self, updated=updated)
         return unknown
 
+    def update_defer(self, **kwargs):
+        unknown = self.update_known(**kwargs)
+        self.deferred.update(unknown)
+
     def update(self, **kwargs):
         u = self.update_known(**kwargs)
         if u:
@@ -303,7 +307,7 @@ def set(self, *spec, defer=False):
             else:
                 unknown[optname] = optval
         if defer:
-            self._deferred.update(unknown)
+            self.deferred.update(unknown)
         elif unknown:
             raise exceptions.OptionsError("Unknown options: %s" % ", ".join(unknown.keys()))
         self.update(**vals)
@@ -314,12 +318,12 @@ def process_deferred(self):
             have since been added.
         """
         update = {}
-        for optname, optval in self._deferred.items():
+        for optname, optval in self.deferred.items():
             if optname in self._options:
                 update[optname] = self.parse_setval(self._options[optname], optval)
         self.update(**update)
         for k in update.keys():
-            del self._deferred[k]
+            del self.deferred[k]
 
     def parse_setval(self, o: _Option, optstr: typing.Optional[str]) -> typing.Any:
         """
@@ -494,26 +498,21 @@ def parse(text):
     return data
 
 
-def load(opts, text):
+def load(opts: OptManager, text: str) -> None:
     """
         Load configuration from text, over-writing options already set in
         this object. May raise OptionsError if the config file is invalid.
-
-        Returns a dictionary of all unknown options.
     """
     data = parse(text)
-    return opts.update_known(**data)
+    opts.update_defer(**data)
 
 
-def load_paths(opts, *paths):
+def load_paths(opts: OptManager, *paths: str) -> None:
     """
         Load paths in order. Each path takes precedence over the previous
         path. Paths that don't exist are ignored, errors raise an
         OptionsError.
-
-        Returns a dictionary of unknown options.
     """
-    ret = {}
     for p in paths:
         p = os.path.expanduser(p)
         if os.path.exists(p) and os.path.isfile(p):
@@ -525,15 +524,14 @@ def load_paths(opts, *paths):
                         "Error reading %s: %s" % (p, e)
                     )
             try:
-                ret.update(load(opts, txt))
+                load(opts, txt)
             except exceptions.OptionsError as e:
                 raise exceptions.OptionsError(
                     "Error reading %s: %s" % (p, e)
                 )
-    return ret
 
 
-def serialize(opts, text, defaults=False):
+def serialize(opts: OptManager, text: str, defaults: bool = False) -> str:
     """
         Performs a round-trip serialization. If text is not None, it is
         treated as a previous serialization that should be modified
@@ -554,7 +552,7 @@ def serialize(opts, text, defaults=False):
     return ruamel.yaml.round_trip_dump(data)
 
 
-def save(opts, path, defaults=False):
+def save(opts: OptManager, path: str, defaults: bool =False) -> None:
     """
         Save to path. If the destination file exists, modify it in-place.
 
diff --git a/mitmproxy/tools/main.py b/mitmproxy/tools/main.py
--- a/mitmproxy/tools/main.py
+++ b/mitmproxy/tools/main.py
@@ -93,7 +93,7 @@ def run(
         sys.exit(1)
     try:
         opts.confdir = args.confdir
-        unknown = optmanager.load_paths(
+        optmanager.load_paths(
             opts,
             os.path.join(opts.confdir, OPTIONS_FILE_NAME),
         )
@@ -109,7 +109,6 @@ def run(
             server = proxy.server.DummyServer(pconf)
 
         master.server = server
-        opts.update_known(**unknown)
         if args.options:
             print(optmanager.dump_defaults(opts))
             sys.exit(0)


Test patch:
diff --git a/test/mitmproxy/addons/test_block.py b/test/mitmproxy/addons/test_block.py
--- a/test/mitmproxy/addons/test_block.py
+++ b/test/mitmproxy/addons/test_block.py
@@ -17,6 +17,7 @@
     (True, False, False, ("::ffff:172.20.0.1",)),
     (True, False, False, ("::ffff:192.168.1.1",)),
     (True, False, False, ("fe80::",)),
+    (True, False, False, (r"::ffff:192.168.1.1%scope",)),
     # block_global: global
     (True, False, True, ("1.1.1.1",)),
     (True, False, True, ("8.8.8.8",)),
@@ -25,6 +26,7 @@
     (True, False, True, ("::ffff:8.8.8.8",)),
     (True, False, True, ("::ffff:216.58.207.174",)),
     (True, False, True, ("2001:4860:4860::8888",)),
+    (True, False, True, (r"2001:4860:4860::8888%scope",)),
 
 
     # block_private: loopback
@@ -37,6 +39,7 @@
     (False, True, True, ("::ffff:10.0.0.1",)),
     (False, True, True, ("::ffff:172.20.0.1",)),
     (False, True, True, ("::ffff:192.168.1.1",)),
+    (False, True, True, (r"::ffff:192.168.1.1%scope",)),
     (False, True, True, ("fe80::",)),
     # block_private: global
     (False, True, False, ("1.1.1.1",)),
@@ -45,6 +48,7 @@
     (False, True, False, ("::ffff:1.1.1.1",)),
     (False, True, False, ("::ffff:8.8.8.8",)),
     (False, True, False, ("::ffff:216.58.207.174",)),
+    (False, True, False, (r"::ffff:216.58.207.174%scope",)),
     (False, True, False, ("2001:4860:4860::8888",)),
 ])
 @pytest.mark.asyncio
diff --git a/test/mitmproxy/test_optmanager.py b/test/mitmproxy/test_optmanager.py
--- a/test/mitmproxy/test_optmanager.py
+++ b/test/mitmproxy/test_optmanager.py
@@ -269,11 +269,13 @@ def test_serialize():
 
     t = "# a comment"
     optmanager.load(o2, t)
-    assert optmanager.load(o2, "foobar: '123'") == {"foobar": "123"}
+    optmanager.load(o2, "foobar: '123'")
+    assert o2.deferred == {"foobar": "123"}
 
     t = ""
     optmanager.load(o2, t)
-    assert optmanager.load(o2, "foobar: '123'") == {"foobar": "123"}
+    optmanager.load(o2, "foobar: '123'")
+    assert o2.deferred == {"foobar": "123"}
 
 
 def test_serialize_defaults():
@@ -297,7 +299,8 @@ def test_saving(tmpdir):
 
     with open(dst, 'a') as f:
         f.write("foobar: '123'")
-    assert optmanager.load_paths(o, dst) == {"foobar": "123"}
+    optmanager.load_paths(o, dst)
+    assert o.deferred == {"foobar": "123"}
 
     with open(dst, 'a') as f:
         f.write("'''")
@@ -426,13 +429,13 @@ def test_set():
     assert opts.seqstr == []
 
     with pytest.raises(exceptions.OptionsError):
-        opts.set("deferred=wobble")
+        opts.set("deferredoption=wobble")
 
-    opts.set("deferred=wobble", defer=True)
-    assert "deferred" in opts._deferred
+    opts.set("deferredoption=wobble", defer=True)
+    assert "deferredoption" in opts.deferred
     opts.process_deferred()
-    assert "deferred" in opts._deferred
-    opts.add_option("deferred", str, "default", "help")
+    assert "deferredoption" in opts.deferred
+    opts.add_option("deferredoption", str, "default", "help")
     opts.process_deferred()
-    assert "deferred" not in opts._deferred
-    assert opts.deferred == "wobble"
+    assert "deferredoption" not in opts.deferred
+    assert opts.deferredoption == "wobble"