From 9dbdb869ce40dcecb5e8e261788074ca4ca3535d Mon Sep 17 00:00:00 2001 From: "carpentry-heartbeat[bot]" Date: Fri, 21 Aug 2026 00:21:34 +0200 Subject: [PATCH] Strip userinfo from the rendered request-target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9110 §4.2.4 and §4.2.5: "A sender MUST NOT generate a userinfo subcomponent (and its "@" delimiter) when an 'http'/'https' URI reference is generated within a message as a request target or header field value." `Request.target` rendered the whole URI, so a request built from a URI carrying credentials put them on the wire — and therefore into server and proxy logs — as `GET http://USER:PW@h.example/a/c HTTP/1.1`. The strip happens on a copy, so the credentials stay on the request's URI for whoever wants to turn them into an `Authorization` header. The empty-path special case (RFC 9112 §3.2.1) now tests the stripped URI, which also gives a userinfo-only URI a valid `/` target instead of the `//user@` it rendered before. --- http.carp | 13 +++++++------ test/http.carp | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/http.carp b/http.carp index 91234c3..78bb73d 100644 --- a/http.carp +++ b/http.carp @@ -312,14 +312,15 @@ attribute, whatever order the two appear in.") (private target) (hidden target) + ; RFC 9110 §4.2.4: a request target never carries userinfo. ; RFC 9112 §3.2.1: an origin-form target with an empty path is sent as `/`. (defn target [u] - (let [s (URI.str u)] - (if (and (Maybe.nothing? (URI.path u)) - (Maybe.nothing? (URI.scheme u)) - (Maybe.nothing? (URI.host u)) - (Maybe.nothing? (URI.user u)) - (Maybe.nothing? (URI.port u))) + (let [v (URI.set-password (URI.set-user @u (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))) diff --git a/test/http.carp b/test/http.carp index 9743229..e611de1 100644 --- a/test/http.carp +++ b/test/http.carp @@ -335,6 +335,12 @@ (fmt "%s=%s" (Cookie.name c) (Cookie.value c))))) (Result.Error e) e)) +; ---- request-target test helpers ---- +(defn request-line [s] + (match (URI.parse s) + (Result.Success u) (Request.str &(Request.get u [] {} @"")) + (Result.Error e) e)) + (deftest test (assert-true test (match (Request.parse &simple-get) (Result.Success _) true _ false) @@ -436,6 +442,37 @@ @"")) "absolute-form request-target is left alone") + (assert-equal test + "GET http://h.example/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" + &(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" + &(request-line "http://h.example/a/c") + "request-target without userinfo is unchanged") + + (assert-equal test + "GET / HTTP/1.1\r\n\r\n" + &(Request.str + &(Request.get (URI.set-user (URI.zero) (Maybe.Just @"solo")) [] {} @"")) + "request-target of a userinfo-only URI is /") + + (assert-true test + (match (URI.parse "http://USER:PW@h.example/a/c") + (Result.Success u) + (let [r (Request.get u [] {} @"")] + (and + (= &(Maybe.Just @"USER") (URI.user (Request.uri &r))) + (= &(Maybe.Just @"PW") (URI.password (Request.uri &r))))) + _ false) + "the credentials stay on the request’s URI") + (assert-true test (match (Cookie.parse "name=value") (Result.Success c)