From 883a43600b2329f67ede177c23d0c545e2d8579a Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 29 Aug 2026 02:38:36 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Send=20the=20port=20in=20the=20Host=20heade?= =?UTF-8?q?r=20(RFC=209110=20=C2=A77.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build-and-send` put `URI.host` straight into the `Host` header and used the port only for `connect`, so a request to a non-default port advertised the bare host. RFC 9110 §7.2 wants the whole authority. `host-header` appends the port unless it is the scheme's default, keeping the conventional bare form for :80 and :443. --- http-client.carp | 8 +++++- test/http-client.carp | 64 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/http-client.carp b/http-client.carp index ea07eaa..189a6a5 100644 --- a/http-client.carp +++ b/http-client.carp @@ -335,6 +335,12 @@ 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) + (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 +379,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..f5a373a 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -101,6 +101,19 @@ "{\"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)))) + (defn count-occurrences [s needle] (let-do [n 0 rest @s @@ -746,4 +759,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.host-header "http" "example.com" 80) + "port 80 is left out of an http Host header") + + (assert-equal test + "example.com:8080" + &(Client.host-header "http" "example.com" 8080) + "any other port is part of an http Host header") + + (assert-equal test + "example.com" + &(Client.host-header "https" "example.com" 443) + "port 443 is left out of an https Host header") + + (assert-equal test + "example.com:443" + &(Client.host-header "http" "example.com" 443) + "443 is not a default port for http") + + (assert-equal test + "[::1]:8080" + &(Client.host-header "http" "[::1]" 8080) + "an IPv6 literal keeps its brackets next to a port") + + (assert-equal test + "[::1]" + &(Client.host-header "http" "[::1]" 80) + "an IPv6 literal keeps its brackets without a port") + (cookie-jar-tests test)) From 691b2ec1a58ccab48de05a0bcfaa9212f38c9aa2 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Sat, 29 Aug 2026 08:12:57 +0200 Subject: [PATCH 2/2] Make host-header private and reach it from a reopened module Every other module-internal helper in http-client.carp carries both (hidden) and (private); host-header carried only (hidden), so it shipped as a publicly callable but undocumented Client.host-header. It was left public because the six unit assertions call it directly from test/http-client.carp. Deleting those assertions to make private compile would take real coverage with it: they are the only tests in the suite that exercise default-port omission, since both probe origins listen on non-default ports. So the test file reopens defmodule Client and forwards through probe-host-header instead, which a private binding permits. Re-measured: mutant H2 (append the port unconditionally) still dies on exactly the same three assertions, so the forwarder reaches the private binding rather than shadowing it; a direct Client.host-header call from the test file now fails to build; the suite is 160 passed / 0 failed, unchanged. --- http-client.carp | 1 + test/http-client.carp | 16 ++++++++++------ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/http-client.carp b/http-client.carp index 189a6a5..3e13158 100644 --- a/http-client.carp +++ b/http-client.carp @@ -338,6 +338,7 @@ that cares about a truncated body must check `error` afterwards.") ; 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))) diff --git a/test/http-client.carp b/test/http-client.carp index f5a373a..9160c76 100644 --- a/test/http-client.carp +++ b/test/http-client.carp @@ -114,6 +114,10 @@ (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 @@ -782,32 +786,32 @@ (assert-equal test "example.com" - &(Client.host-header "http" "example.com" 80) + &(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.host-header "http" "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.host-header "https" "example.com" 443) + &(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.host-header "http" "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.host-header "http" "[::1]" 8080) + &(Client.probe-host-header "http" "[::1]" 8080) "an IPv6 literal keeps its brackets next to a port") (assert-equal test "[::1]" - &(Client.host-header "http" "[::1]" 80) + &(Client.probe-host-header "http" "[::1]" 80) "an IPv6 literal keeps its brackets without a port") (cookie-jar-tests test))