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
2 changes: 1 addition & 1 deletion src/http_client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1944,7 +1944,7 @@ function request(
parsed.target;
headers=req_headers,
body=normalized_body.body,
host=parsed.address,
host=parsed.host_header,
content_length=normalized_body.content_length,
context=req_context,
)
Expand Down
28 changes: 27 additions & 1 deletion src/http_client_url.jl
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ function _urlparts_url!(parts::_URLParts)::String
return url
end

# Authority for the `Host` header. This mirrors the URL as written: an explicit
# port is preserved, but the scheme's default port is never synthesized. The
# connection address (`_urlparts_address!`) always carries a port for dialing,
# so the two intentionally differ for a default-port URL.
#
# Synthesizing the default port into `Host` (e.g. `s3.amazonaws.com:443`) is
# legal per RFC 9110 but breaks any server that treats the Host verbatim. The
# motivating case is AWS SigV4: it signs the canonical host (`s3.amazonaws.com`)
# and rejects a request whose `Host` carries `:443`. Go's net/http, curl and
# HTTP.jl 1.x all keep the Host authority as written; this restores that.
#
# For an explicit port we use the authority directly. For a default-port URL we
# strip the synthesized `:<default_port>` off `address` rather than reusing
# `server_name`, because `server_name` unwraps IPv6 brackets (`2001:db8::1`)
# whereas a `Host` header requires them (`[2001:db8::1]`); `address` keeps the
# brackets (`[2001:db8::1]:443`), so trimming the port yields the correct form.
function _urlparts_host_header!(parts::_URLParts)::String
getfield(parts, :has_explicit_port) && return _urlparts_address!(parts)
address = _urlparts_address!(parts)
suffix = string(':', Int(getfield(parts, :default_port)))
return endswith(address, suffix) ? address[1:(end - length(suffix))] : address
end

function _urlparts_authorization!(parts::_URLParts)::Union{Nothing,String}
getfield(parts, :has_userinfo) || return nothing
cached = getfield(parts, :authorization_cache)
Expand All @@ -166,6 +189,8 @@ function Base.getproperty(parts::_URLParts, sym::Symbol)
return _urlparts_target!(parts)
elseif sym === :server_name
return _urlparts_server_name!(parts)
elseif sym === :host_header
return _urlparts_host_header!(parts)
elseif sym === :url
return _urlparts_url!(parts)
elseif sym === :authorization
Expand All @@ -175,7 +200,8 @@ function Base.getproperty(parts::_URLParts, sym::Symbol)
end

function Base.propertynames(::_URLParts, private::Bool=false)
return private ? fieldnames(_URLParts) : (:secure, :address, :target, :server_name, :url, :authorization)
return private ? fieldnames(_URLParts) :
(:secure, :address, :target, :server_name, :host_header, :url, :authorization)
end

@inline function _request_url(secure::Bool, address::String, target::String)::String
Expand Down
21 changes: 21 additions & 0 deletions test/http_client_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,24 @@ end
@test parsed_query.server_name == "example.com"
@test parsed_query.url == "https://example.com:443/?x=1&y=2"
@test parsed_query.authorization === nothing
# `host_header` mirrors the URL authority: the synthesized default port
# is never added (so the `Host` header stays `example.com`, matching the
# bare host servers like AWS SigV4 sign over), while `address` keeps the
# port for dialing.
@test parsed_query.host_header == "example.com"

parsed_default_port = HT._parse_http_url("https://example.com:443/explicit")
@test parsed_default_port.address == "example.com:443"
# An explicitly-written default port is preserved verbatim (as in Go).
@test parsed_default_port.host_header == "example.com:443"

parsed_http_default = HT._parse_http_url("http://example.com/plain")
@test parsed_http_default.address == "example.com:80"
@test parsed_http_default.host_header == "example.com"

parsed_custom_port = HT._parse_http_url("http://minio:9000/bucket/key")
@test parsed_custom_port.address == "minio:9000"
@test parsed_custom_port.host_header == "minio:9000"

parsed_query_uri = HT._parse_http_url(HT.URI("https://example.com?x=1"), Dict("y" => 2))
@test parsed_query_uri.secure
Expand All @@ -1302,11 +1320,14 @@ end
@test parsed_ipv6.server_name == "2001:db8::1"
@test parsed_ipv6.url == "https://[2001:db8::1]:443/ipv6"
@test parsed_ipv6.authorization === nothing
# IPv6 Host headers keep their brackets (unlike `server_name`).
@test parsed_ipv6.host_header == "[2001:db8::1]"

parsed_ipv6_port = HT._parse_http_url("https://[2001:db8::1]:8443/ipv6")
@test parsed_ipv6_port.address == "[2001:db8::1]:8443"
@test parsed_ipv6_port.server_name == "2001:db8::1"
@test parsed_ipv6_port.url == "https://[2001:db8::1]:8443/ipv6"
@test parsed_ipv6_port.host_header == "[2001:db8::1]:8443"

parsed_ipv6_uri = HT._parse_http_url(HT.URI("https://[2001:db8::1]/ipv6"))
@test parsed_ipv6_uri.address == "[2001:db8::1]:443"
Expand Down
Loading