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
34 changes: 33 additions & 1 deletion httpie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@

ELAPSED_TIME_LABEL = 'Elapsed time'

# Default ports stripped from the displayed `Host` header to match what
# `http.client` actually sends on the wire (it omits the port when it
# matches the scheme's default).
DEFAULT_PORTS_BY_SCHEME = {
'http': 80,
'https': 443,
}


class HTTPMessage:
"""Abstract class for HTTP messages."""
Expand Down Expand Up @@ -148,7 +156,7 @@ def headers(self):

headers = self._orig.headers.copy()
if 'Host' not in self._orig.headers:
headers['Host'] = url.netloc.split('@')[-1]
headers['Host'] = _build_host_header(url)

headers = [
f'{name}: {value if isinstance(value, str) else value.decode()}'
Expand All @@ -169,6 +177,30 @@ def body(self):
return body or b''


def _build_host_header(url) -> str:
"""Return the displayed `Host` header for a URL.

Mirrors `http.client`'s behavior of omitting the port when it equals the
scheme's default, so `--print hH` matches the bytes actually sent on
the wire.
"""
netloc = url.netloc.split('@')[-1]
default_port = DEFAULT_PORTS_BY_SCHEME.get(url.scheme)
if default_port is not None:
try:
port = url.port
except ValueError:
# Malformed port — leave the netloc as-is.
port = None
if port == default_port:
# Strip the trailing `:<default_port>` while preserving any
# IPv6 brackets and host casing in `netloc`.
suffix = f':{default_port}'
if netloc.endswith(suffix):
netloc = netloc[: -len(suffix)]
return netloc


RequestsMessage = Union[requests.PreparedRequest, requests.Response]


Expand Down
34 changes: 34 additions & 0 deletions tests/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,40 @@ def test_Host_header_overwrite(httpbin):
assert f'host: {host}' in r


@pytest.mark.parametrize(
'url, expected_host',
[
# Default ports must be stripped from the displayed Host
# header to match what `http.client` actually sends on the
# wire (it omits the port when it equals the scheme default).
('http://localhost:80/', 'localhost'),
('https://example.com:443/', 'example.com'),
# Userinfo should not appear in Host either way.
('http://user:pass@localhost:80/', 'localhost'),
# Non-default ports must be preserved.
('http://localhost:8080/', 'localhost:8080'),
('https://example.com:80/', 'example.com:80'),
('https://example.com:8443/', 'example.com:8443'),
# No explicit port -> no port in Host.
('https://example.com/', 'example.com'),
# IPv6 hosts keep their brackets.
('http://[::1]:80/', '[::1]'),
('http://[::1]:8080/', '[::1]:8080'),
],
)
def test_Host_header_default_port_stripped(url, expected_host):
"""
https://github.com/httpie/cli/issues/1034
"""
r = http('--offline', '--print=H', url)
host_lines = [
line for line in r.splitlines()
if line.lower().startswith('host:')
]
assert len(host_lines) == 1, r
assert host_lines[0] == f'Host: {expected_host}', r


@pytest.mark.skipif(is_windows, reason='Unix-only')
def test_output_devnull(httpbin):
"""
Expand Down
Loading