From 78c1ed4c41efc623003d30516eeb46eac43dabb8 Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 21 Aug 2026 06:18:13 +0200 Subject: [PATCH] Strip the fragment from the rendered request-target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §7.1: "The target URI excludes the reference's fragment component, if any, since fragment identifiers are reserved for client-side processing." `Request.target` stripped userinfo but rendered the rest of the URI as it stood, so a request built from a URI carrying a fragment sent it on the wire — and therefore into every request-line log on the way: http://h.example/a/b?q=1#sec2 -> GET http://h.example/a/b?q=1#sec2 HTTP/1.1 http://h.example/a/b#sec2 -> GET http://h.example/a/b#sec2 HTTP/1.1 http://h.example/a/b# -> GET http://h.example/a/b# HTTP/1.1 http://h.example/a/b#frag?notquery -> GET http://h.example/a/b#frag?notquery HTTP/1.1 The last one is the sharp case: a `?` or `/` inside a fragment reaches the server looking like part of the query or the path. The strip rides on the copy the userinfo strip already makes, so the fragment stays on the request's URI for a caller that wants to read it back, and the empty-path special case sees the stripped URI — which gives a fragment-only URI a `/` target instead of the `#sec2` it rendered before. --- http.carp | 5 ++++- test/http.carp | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/http.carp b/http.carp index 14a26ad..2b82b03 100644 --- a/http.carp +++ b/http.carp @@ -313,9 +313,12 @@ 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 `/`. (defn target [u] - (let [v (URI.set-password (URI.set-user @u (Maybe.Nothing)) (Maybe.Nothing)) + (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)) diff --git a/test/http.carp b/test/http.carp index f3dd88e..9620260 100644 --- a/test/http.carp +++ b/test/http.carp @@ -508,6 +508,51 @@ _ false) "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" + &(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" + &(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" + &(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" + &(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" + &(request-line "http://h.example/a/b?q=a%23b") + "request-target keeps an encoded # inside the query") + + (assert-equal test + "GET /?q=a#b HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get (URI.set-query (URI.zero) (Maybe.Just @"q=a#b")) [] {} @"")) + "request-target keeps a literal # inside the query") + + (assert-equal test + "GET / HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get (URI.set-fragment (URI.zero) (Maybe.Just @"sec2")) [] {} @"")) + "request-target of a fragment-only URI is /") + + (assert-true test + (match (URI.parse "http://h.example/a/b#sec2") + (Result.Success u) + (= &(Maybe.Just @"sec2") + (URI.fragment (Request.uri &(Request.get u [] {} @"")))) + _ false) + "the fragment stays on the request’s URI") + (assert-true test (match (Cookie.parse "name=value") (Result.Success c)