Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/httpcore2/httpcore2/_async/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
enforce_bytes,
enforce_headers,
enforce_url,
format_host,
)
from .._ssl import default_ssl_context
from .._synchronization import AsyncLock
Expand Down Expand Up @@ -261,7 +262,7 @@ async def handle_async_request(self, request: Request) -> Response:

async with self._connect_lock:
if not self._connected:
target = b"%b:%d" % (self._remote_origin.host, self._remote_origin.port)
target = b"%b:%d" % (format_host(self._remote_origin.host), self._remote_origin.port)

connect_url = URL(
scheme=self._proxy_origin.scheme,
Expand Down
26 changes: 21 additions & 5 deletions src/httpcore2/httpcore2/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ def enforce_stream(
}


def format_host(host: bytes) -> bytes:
"""
Wrap an IPv6 address literal in square brackets.

`URL.host` and `Origin.host` hold the host in the form required to establish
a connection, which for an IPv6 address is the bare address. Wherever the
host is instead rendered as part of a URL or of an authority, an IPv6
address literal must be bracketed, as an `IP-literal`.

* https://tools.ietf.org/html/rfc3986#section-3.2.2
"""
# A colon cannot occur in a registered name or in an IPv4 address, so its
# presence is enough to tell an IPv6 address apart.
return b"[%b]" % host if b":" in host else host


def include_request_headers(
headers: list[tuple[bytes, bytes]],
*,
Expand All @@ -118,9 +134,9 @@ def include_request_headers(
if b"host" not in headers_set:
default_port = DEFAULT_PORTS.get(url.scheme)
if url.port is None or url.port == default_port:
header_value = url.host
header_value = format_host(url.host)
else:
header_value = b"%b:%d" % (url.host, url.port)
header_value = b"%b:%d" % (format_host(url.host), url.port)
headers = [(b"Host", header_value)] + headers

if content is not None and b"content-length" not in headers_set and b"transfer-encoding" not in headers_set:
Expand Down Expand Up @@ -171,7 +187,7 @@ def __eq__(self, other: typing.Any) -> bool:

def __str__(self) -> str:
scheme = self.scheme.decode("ascii")
host = self.host.decode("ascii")
host = format_host(self.host).decode("ascii")
port = str(self.port)
return f"{scheme}://{host}:{port}"

Expand Down Expand Up @@ -296,8 +312,8 @@ def __eq__(self, other: typing.Any) -> bool:

def __bytes__(self) -> bytes:
if self.port is None:
return b"%b://%b%b" % (self.scheme, self.host, self.target)
return b"%b://%b:%d%b" % (self.scheme, self.host, self.port, self.target)
return b"%b://%b%b" % (self.scheme, format_host(self.host), self.target)
return b"%b://%b:%d%b" % (self.scheme, format_host(self.host), self.port, self.target)

def __repr__(self) -> str:
return (
Expand Down
3 changes: 2 additions & 1 deletion src/httpcore2/httpcore2/_sync/http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
enforce_bytes,
enforce_headers,
enforce_url,
format_host,
)
from .._ssl import default_ssl_context
from .._synchronization import Lock
Expand Down Expand Up @@ -261,7 +262,7 @@ def handle_request(self, request: Request) -> Response:

with self._connect_lock:
if not self._connected:
target = b"%b:%d" % (self._remote_origin.host, self._remote_origin.port)
target = b"%b:%d" % (format_host(self._remote_origin.host), self._remote_origin.port)

connect_url = URL(
scheme=self._proxy_origin.scheme,
Expand Down
84 changes: 84 additions & 0 deletions tests/httpcore2/_async/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,90 @@ async def test_proxy_tunneling() -> None:
assert not proxy.connections[0].can_handle_request(Origin(b"https", b"other.com", 443))


class RecordingStream(AsyncMockStream):
"""A mock stream that keeps everything written to it."""

def __init__(self, buffer: list[bytes], http2: bool = False) -> None:
super().__init__(buffer, http2)
self.written = b""

async def write(self, buffer: bytes, timeout: float | None = None) -> None:
self.written += buffer


class RecordingBackend(AsyncMockBackend):
def __init__(self, buffer: list[bytes], http2: bool = False) -> None:
super().__init__(buffer, http2)
self.stream = RecordingStream(list(buffer), http2=http2)

async def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> AsyncMockStream:
return self.stream


@pytest.mark.anyio
async def test_proxy_forwarding_to_ipv6_host() -> None:
"""
Send an HTTP request for an IPv6 address literal via a proxy.

The absolute-form request target is a URI, so the address is bracketed.
"""
network_backend = RecordingBackend(
[
b"HTTP/1.1 200 OK\r\n",
b"Content-Length: 0\r\n",
b"\r\n",
]
)

async with AsyncConnectionPool(
proxy=Proxy("http://localhost:8080/"),
network_backend=network_backend,
) as proxy:
response = await proxy.request("GET", "http://[::1]:1234/")
assert response.status == 200

request_line = network_backend.stream.written.split(b"\r\n")[0]
assert request_line == b"GET http://[::1]:1234/ HTTP/1.1"
assert b"\r\nHost: [::1]:1234\r\n" in network_backend.stream.written


@pytest.mark.anyio
async def test_proxy_tunneling_to_ipv6_host() -> None:
"""
Send an HTTPS request for an IPv6 address literal via a proxy.

The CONNECT target is in authority-form, so the address is bracketed.
"""
network_backend = RecordingBackend(
[
# The initial response to the proxy CONNECT
b"HTTP/1.1 200 OK\r\n\r\n",
# The actual response from the remote server
b"HTTP/1.1 200 OK\r\n",
b"Content-Length: 0\r\n",
b"\r\n",
]
)

async with AsyncConnectionPool(
proxy=Proxy("http://localhost:8080/"),
network_backend=network_backend,
) as proxy:
response = await proxy.request("GET", "https://[::1]:8443/")
assert response.status == 200

request_line = network_backend.stream.written.split(b"\r\n")[0]
assert request_line == b"CONNECT [::1]:8443 HTTP/1.1"
assert b"\r\nHost: [::1]:8443\r\n" in network_backend.stream.written


# We need to adapt the mock backend here slightly in order to deal
# with the proxy case. We do not want the initial connection to the proxy
# to indicate an HTTP/2 connection, but we do want it to indicate HTTP/2
Expand Down
84 changes: 84 additions & 0 deletions tests/httpcore2/_sync/test_http_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,90 @@ def test_proxy_tunneling() -> None:
assert not proxy.connections[0].can_handle_request(Origin(b"https", b"other.com", 443))


class RecordingStream(MockStream):
"""A mock stream that keeps everything written to it."""

def __init__(self, buffer: list[bytes], http2: bool = False) -> None:
super().__init__(buffer, http2)
self.written = b""

def write(self, buffer: bytes, timeout: float | None = None) -> None:
self.written += buffer


class RecordingBackend(MockBackend):
def __init__(self, buffer: list[bytes], http2: bool = False) -> None:
super().__init__(buffer, http2)
self.stream = RecordingStream(list(buffer), http2=http2)

def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: typing.Iterable[SOCKET_OPTION] | None = None,
) -> MockStream:
return self.stream



def test_proxy_forwarding_to_ipv6_host() -> None:
"""
Send an HTTP request for an IPv6 address literal via a proxy.

The absolute-form request target is a URI, so the address is bracketed.
"""
network_backend = RecordingBackend(
[
b"HTTP/1.1 200 OK\r\n",
b"Content-Length: 0\r\n",
b"\r\n",
]
)

with ConnectionPool(
proxy=Proxy("http://localhost:8080/"),
network_backend=network_backend,
) as proxy:
response = proxy.request("GET", "http://[::1]:1234/")
assert response.status == 200

request_line = network_backend.stream.written.split(b"\r\n")[0]
assert request_line == b"GET http://[::1]:1234/ HTTP/1.1"
assert b"\r\nHost: [::1]:1234\r\n" in network_backend.stream.written



def test_proxy_tunneling_to_ipv6_host() -> None:
"""
Send an HTTPS request for an IPv6 address literal via a proxy.

The CONNECT target is in authority-form, so the address is bracketed.
"""
network_backend = RecordingBackend(
[
# The initial response to the proxy CONNECT
b"HTTP/1.1 200 OK\r\n\r\n",
# The actual response from the remote server
b"HTTP/1.1 200 OK\r\n",
b"Content-Length: 0\r\n",
b"\r\n",
]
)

with ConnectionPool(
proxy=Proxy("http://localhost:8080/"),
network_backend=network_backend,
) as proxy:
response = proxy.request("GET", "https://[::1]:8443/")
assert response.status == 200

request_line = network_backend.stream.written.split(b"\r\n")[0]
assert request_line == b"CONNECT [::1]:8443 HTTP/1.1"
assert b"\r\nHost: [::1]:8443\r\n" in network_backend.stream.written


# We need to adapt the mock backend here slightly in order to deal
# with the proxy case. We do not want the initial connection to the proxy
# to indicate an HTTP/2 connection, but we do want it to indicate HTTP/2
Expand Down
23 changes: 23 additions & 0 deletions tests/httpcore2/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,29 @@ def test_url_with_port() -> None:
assert bytes(url) == b"https://www.example.com:443/"


def test_url_with_ipv6_host() -> None:
"""
`URL.host` holds the bare address, as needed to establish a connection, but
rendering the URL brackets it.
"""
url = httpcore2.URL("https://[::1]/")
assert url == httpcore2.URL(scheme="https", host="::1", port=None, target="/")
assert url.host == b"::1"
assert bytes(url) == b"https://[::1]/"

url = httpcore2.URL("https://[::1]:8443/")
assert url.host == b"::1"
assert bytes(url) == b"https://[::1]:8443/"


def test_url_origin_ipv6() -> None:
url = httpcore2.URL("https://[::1]:8443/")
origin = url.origin
assert origin == httpcore2.Origin(scheme=b"https", host=b"::1", port=8443)
assert origin.host == b"::1"
assert str(origin) == "https://[::1]:8443"


def test_url_with_invalid_argument() -> None:
with pytest.raises(TypeError) as exc_info:
httpcore2.URL(123) # type: ignore
Expand Down
Loading