Repository: encode/httpx

Issue:
The `get_environment_proxies` function in _utils.py does not support IPv4, IPv6 correctly
Hi, I encountered error when my environment `no_proxy` includes IPv6 address like `::1`. It is wrongly transformed into `all://*::1` and causes urlparse error since the _urlparse.py parses the `:1` as port.

![image](https://user-images.githubusercontent.com/26450272/232004919-042f07ff-dd55-4c3c-9e49-91d69c5cf3d6.png)

The `get_environment_proxies` function in **_utils.py** is responsible for parsing and mounting proxy info from system environment.

https://github.com/encode/httpx/blob/4b5a92e88e03443c2619f0905d756b159f9f0222/httpx/_utils.py#L229-L264

For env `no_proxy`, according to [CURLOPT_NOPROXY explained](https://curl.se/libcurl/c/CURLOPT_NOPROXY.html), it should support domains, IPv4, IPv6 and the `localhost`. However, current `get_environment_proxies` function implementation only supports domains correctly as it always adds wildcard `*` in front of the `hostname`. 

To fix this issue, I looked into this repo and suggest handling the `no_proxy` hostnames as domains, IPv4, IPv6 and the `localhost` seperately. I have updated the `get_environment_proxies` function in **_utils.py** and tested it.

Refer to the PR: #2659 

Replies and discussions are welcomed!


Discussion:
tomchristie: Nicely done, yup.

Patch:
diff --git a/httpx/_utils.py b/httpx/_utils.py
index c55d33a..2568fdc 100644
--- a/httpx/_utils.py
+++ b/httpx/_utils.py
@@ -1,5 +1,6 @@
 import codecs
 import email.message
+import ipaddress
 import mimetypes
 import os
 import re
@@ -259,7 +260,16 @@ def get_environment_proxies() -> typing.Dict[str, typing.Optional[str]]:
             # NO_PROXY=google.com is marked as "all://*google.com,
             #   which disables "www.google.com" and "google.com".
             #   (But not "wwwgoogle.com")
-            mounts[f"all://*{hostname}"] = None
+            # NO_PROXY can include domains, IPv6, IPv4 addresses and "localhost"
+            #   NO_PROXY=example.com,::1,localhost,192.168.0.0/16
+            if is_ipv4_hostname(hostname):
+                mounts[f"all://{hostname}"] = None
+            elif is_ipv6_hostname(hostname):
+                mounts[f"all://[{hostname}]"] = None
+            elif hostname.lower() == "localhost":
+                mounts[f"all://{hostname}"] = None
+            else:
+                mounts[f"all://*{hostname}"] = None
 
     return mounts
 
@@ -449,3 +459,19 @@ class URLPattern:
 
     def __eq__(self, other: typing.Any) -> bool:
         return isinstance(other, URLPattern) and self.pattern == other.pattern
+
+
+def is_ipv4_hostname(hostname: str) -> bool:
+    try:
+        ipaddress.IPv4Address(hostname.split("/")[0])
+    except:
+        return False
+    return True
+
+
+def is_ipv6_hostname(hostname: str) -> bool:
+    try:
+        ipaddress.IPv6Address(hostname.split("/")[0])
+    except:
+        return False
+    return True


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 3eaf245..ab0fcbe 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -185,6 +185,12 @@ def test_get_ssl_cert_file():
         ),
         ({"all_proxy": "http://127.0.0.1"}, {"all://": "http://127.0.0.1"}),
         ({"TRAVIS_APT_PROXY": "http://127.0.0.1"}, {}),
+        ({"no_proxy": "127.0.0.1"}, {"all://127.0.0.1": None}),
+        ({"no_proxy": "192.168.0.0/16"}, {"all://192.168.0.0/16": None}),
+        ({"no_proxy": "::1"}, {"all://[::1]": None}),
+        ({"no_proxy": "localhost"}, {"all://localhost": None}),
+        ({"no_proxy": "github.com"}, {"all://*github.com": None}),
+        ({"no_proxy": ".github.com"}, {"all://*.github.com": None}),
     ],
 )
 def test_get_environment_proxies(environment, proxies):