diff --git a/http-client.carp b/http-client.carp index ea07eaa..3e13158 100644 --- a/http-client.carp +++ b/http-client.carp @@ -335,6 +335,13 @@ that cares about a truncated body must check `error` afterwards.") (defn default-port [scheme] (cond (= scheme "https") 443 (= scheme "http") 80 80)) + ; RFC 9110 ยง7.2: the field value is the whole authority, minus a port that + ; is the scheme's default. `URI.host` keeps the brackets on IPv6 literals. + (hidden host-header) + (private host-header) + (defn host-header [scheme host port] + (if (= port (default-port scheme)) @host (fmt "%s:%d" host port))) + (hidden connect) (private connect) (defn connect [scheme host port config] @@ -373,7 +380,7 @@ that cares about a truncated body must check `error` afterwards.") (match (connect &scheme &host port config) (Result.Error e) (Result.Error e) (Result.Success conn) - (let [host-vals [@&host] + (let [host-vals [(host-header &scheme &host port)] conn-vals [@"close"] with-host (Map.put headers &@"Host" &host-vals) full-headers (Map.put with-host &@"Connection" &conn-vals) diff --git a/test/http-client.carp b/test/http-client.carp index da7cbb6..9160c76 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -101,6 +101,23 @@ "{\"a\": 1}" &jar)))) +; Every Host line the origin echoed back, joined, so a duplicate or missing +; header cannot read as the expected one. +(defn seen-hosts [res] + (match res + (Result.Error e) e + (Result.Success r) + (let-do [seen []] + (foreach + [line &(String.split-by (Response.body &r) &[\newline])] + (when (byte-starts-with? line "Host: ") + (set! seen (Array.push-back seen (drop-bytes line 6))))) + (String.join "|" &seen)))) + +; Lets the assertions below reach the private `host-header`. +(defmodule Client + (defn probe-host-header [scheme host port] (host-header scheme host port))) + (defn count-occurrences [s needle] (let-do [n 0 rest @s @@ -746,4 +763,55 @@ &jar))) "the same holds on the cookie-jar path") + (assert-equal test + "127.0.0.1:8791" + &(seen-hosts (Client.get "http://127.0.0.1:8791/headers")) + "a non-default port is part of the Host header") + + (assert-equal test + "127.0.0.1:8791" + &(seen-hosts + (Client.request "GET" + "http://127.0.0.1:8791/headers" + {@"Host" [@"caller.example"]} + "")) + "the Host header comes from the URL, not from the caller's headers") + + (assert-equal test + "127.0.0.1:8792" + &(seen-hosts + (Client.get + "http://127.0.0.1:8791/redirect-to?url=http%3A%2F%2F127.0.0.1%3A8792%2Fheaders&status_code=302")) + "a cross-origin redirect carries the new origin's Host header") + + (assert-equal test + "example.com" + &(Client.probe-host-header "http" "example.com" 80) + "port 80 is left out of an http Host header") + + (assert-equal test + "example.com:8080" + &(Client.probe-host-header "http" "example.com" 8080) + "any other port is part of an http Host header") + + (assert-equal test + "example.com" + &(Client.probe-host-header "https" "example.com" 443) + "port 443 is left out of an https Host header") + + (assert-equal test + "example.com:443" + &(Client.probe-host-header "http" "example.com" 443) + "443 is not a default port for http") + + (assert-equal test + "[::1]:8080" + &(Client.probe-host-header "http" "[::1]" 8080) + "an IPv6 literal keeps its brackets next to a port") + + (assert-equal test + "[::1]" + &(Client.probe-host-header "http" "[::1]" 80) + "an IPv6 literal keeps its brackets without a port") + (cookie-jar-tests test))