From 84bb203bfe3a1c8596b2ca168ee21b49e0dc4492 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 28 Aug 2026 20:14:31 +0200 Subject: [PATCH] Send origin-form request-targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9112 §3.2.1 requires a client talking directly to an origin server to send only the absolute path and query as the request-target; absolute-form (§3.2.2) addresses a proxy. `target` re-rendered the whole URI, so a request built from an absolute URL went out as GET http://127.0.0.1:8843/good HTTP/1.1 Host: 127.0.0.1 Conforming servers accept that, which is why nothing broke. What it costs is the ambiguity §3.2.2 spends a paragraph on — a request line carrying a scheme and host that the Host header also carries — and stricter intermediaries treat absolute-form from a non-proxy client as suspicious. Compose the target from URI.path and URI.query rather than re-rendering the URI. Dropping scheme, host, port, userinfo and fragment now falls out of reading only two components instead of needing them cleared first. URI.full-path does the same job, but the pinned uri@0.5.1 prepends `/` unconditionally, so a path set to "/a/b" by hand comes back "//a/b"; the leading-slash guard here is what uri itself grew after 0.5.1. The suite pinned the old shape ("absolute-form request-target is left alone"); those assertions now pin origin-form. New cases cover an already absolute path, a URI carrying path, query, userinfo, port and fragment at once, and that host and port survive on the request's URI so http-client keeps building its Host header. The other three §3.2 forms stay unproduced: nothing here or in http-client speaks to a proxy or opens a tunnel, and a bare "host:port" parses as scheme+opaque rather than host+port, so authority-form would need an API with no caller. Closes #43 --- docs/Request.html | 8 ++++++++ http.carp | 35 ++++++++++++++++++++--------------- test/http.carp | 43 ++++++++++++++++++++++++++++++++----------- 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/docs/Request.html b/docs/Request.html index cd4dc8b..73f33eb 100644 --- a/docs/Request.html +++ b/docs/Request.html @@ -943,6 +943,14 @@

stringifies the request as it looks on the wire.

+

The request-target is rendered in origin-form: the path and query of the +request’s URI, or / when it has neither, as RFC 9112 §3.2.1 requires of a +request sent directly to an origin server. Scheme, host, port, userinfo and +fragment stay on the URI and never reach the request line, so the authority has +to travel in a Host header.

+

The other request-target forms of RFC 9112 §3.2 are not produced: +absolute-form addresses a proxy, authority-form a CONNECT, and asterisk-form +a server-wide OPTIONS.

diff --git a/http.carp b/http.carp index 495a035..734aaf2 100644 --- a/http.carp +++ b/http.carp @@ -312,22 +312,27 @@ attribute, whatever order the two appear in.") (private target) (hidden target) - ; RFC 9110 §4.2.4: a request target never carries userinfo. - ; RFC 9110 §7.1: the target URI excludes the fragment component. - ; RFC 9112 §3.2.1: an origin-form target with an empty path is sent as `/`. + ; RFC 9112 §3.2.1: a request to an origin server carries path and query only. (defn target [u] - (let [v (URI.set-fragment - (URI.set-password (URI.set-user @u (Maybe.Nothing)) (Maybe.Nothing)) - (Maybe.Nothing)) - s (URI.str &v)] - (if (and (Maybe.nothing? (URI.path &v)) - (Maybe.nothing? (URI.scheme &v)) - (Maybe.nothing? (URI.host &v)) - (Maybe.nothing? (URI.port &v))) - (String.append "/" &s) - s))) - - (doc str "stringifies the request as it looks on the wire.") + (let [pth (Maybe.from @(URI.path u) @"") + q (Maybe.from @(URI.query u) @"")] + (String.concat + &[@(if (String.byte-starts-with? &pth "/") "" "/") + pth + @(if (String.empty? &q) "" "?") + q]))) + + (doc str "stringifies the request as it looks on the wire. + +The request-target is rendered in origin-form: the path and query of the +request’s URI, or `/` when it has neither, as RFC 9112 §3.2.1 requires of a +request sent directly to an origin server. Scheme, host, port, userinfo and +fragment stay on the URI and never reach the request line, so the authority has +to travel in a `Host` header. + +The other request-target forms of RFC 9112 §3.2 are not produced: +absolute-form addresses a proxy, authority-form a `CONNECT`, and asterisk-form +a server-wide `OPTIONS`.") (defn str [r] (let-do [sb (StringBuf.create)] (StringBuf.append-str &sb (verb r)) diff --git a/test/http.carp b/test/http.carp index e5a5085..13e1f50 100644 --- a/test/http.carp +++ b/test/http.carp @@ -473,7 +473,7 @@ "request-target of a query-only URI keeps its leading /") (assert-equal test - "GET http://example.com HTTP/1.1\r\n\r\n" + "GET / HTTP/1.1\r\n\r\n" &(Request.str &(Request.get (URI.set-scheme @@ -482,22 +482,43 @@ [] {} @"")) - "absolute-form request-target is left alone") + "request-target of an absolute URL drops the scheme and host") + + (assert-equal test + "GET /a/b?q=1 HTTP/1.1\r\n\r\n" + &(request-line "http://user:pw@h.example:8080/a/b?q=1#sec2") + "request-target keeps only the path and query") + + (assert-equal test + "GET /a/b HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get (URI.set-path (URI.zero) (Maybe.Just @"/a/b")) [] {} @"")) + "an already absolute path is not slashed twice") + + (assert-true test + (match (URI.parse "http://h.example:8080/a/b") + (Result.Success u) + (let [r (Request.get u [] {} @"")] + (and + (= &(Maybe.Just @"h.example") (URI.host (Request.uri &r))) + (= &(Maybe.Just 8080) (URI.port (Request.uri &r))))) + _ false) + "the host and port stay on the request’s URI") (assert-equal test - "GET http://h.example/a/c HTTP/1.1\r\n\r\n" + "GET /a/c HTTP/1.1\r\n\r\n" &(request-line "http://USER:PW@h.example/a/c") "request-target drops user and password") (assert-equal test - "GET http://h.example/a/c HTTP/1.1\r\n\r\n" + "GET /a/c HTTP/1.1\r\n\r\n" &(request-line "http://USER@h.example/a/c") "request-target drops a user without a password") (assert-equal test - "GET http://h.example/a/c HTTP/1.1\r\n\r\n" + "GET /a/c HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/c") - "request-target without userinfo is unchanged") + "request-target without userinfo is the plain path") (assert-equal test "GET / HTTP/1.1\r\n\r\n" @@ -516,27 +537,27 @@ "the credentials stay on the request’s URI") (assert-equal test - "GET http://h.example/a/b?q=1 HTTP/1.1\r\n\r\n" + "GET /a/b?q=1 HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/b?q=1#sec2") "request-target drops a fragment and keeps the query") (assert-equal test - "GET http://h.example/a/b HTTP/1.1\r\n\r\n" + "GET /a/b HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/b#sec2") "request-target drops a fragment without a query") (assert-equal test - "GET http://h.example/a/b HTTP/1.1\r\n\r\n" + "GET /a/b HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/b#") "request-target drops an empty fragment") (assert-equal test - "GET http://h.example/a/b HTTP/1.1\r\n\r\n" + "GET /a/b HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/b#frag?notquery") "request-target drops a fragment that looks like a query") (assert-equal test - "GET http://h.example/a/b?q=a%23b HTTP/1.1\r\n\r\n" + "GET /a/b?q=a%23b HTTP/1.1\r\n\r\n" &(request-line "http://h.example/a/b?q=a%23b") "request-target keeps an encoded # inside the query")