From d9fb6df73a59770b4825b15bc3328a9c2eadc699 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 22 Oct 2026 02:51:28 -0600 Subject: [PATCH 1/2] Require an absolute :path in HTTP/2 requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 9113 §8.3.1: :path is the absolute path (and query) of the target, or `*` for an OPTIONS request. The server accepted any value. An empty :path reached the Request constructor, which threw, so the client got RST_STREAM(INTERNAL_ERROR) and the server logged "h2 server could not send the response" for a malformed request. A :path such as "abc" was served, though HTTP/1 answers the same target with 400. Both are now malformed requests, rejected like the other invalid pseudo-headers. Co-Authored-By: Claude Opus 5.5 --- src/http2_server.jl | 3 +++ test/http2_server_tests.jl | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/src/http2_server.jl b/src/http2_server.jl index ab1d5e56d..6fe806b16 100644 --- a/src/http2_server.jl +++ b/src/http2_server.jl @@ -967,6 +967,8 @@ function _validate_h2_request_headers!(headers::Vector{HeaderField})::Tuple{Stri # written verbatim into an HTTP/1 request line, splitting the target # and enabling request smuggling past path-based ACLs. _string_contains_whitespace_byte(normalized) && throw(ProtocolError("invalid HTTP/2 :path pseudo-header: $(repr(normalized))")) + # It is an absolute path, or `*` (checked against the method below). + (startswith(normalized, '/') || normalized == "*") || throw(ProtocolError("invalid HTTP/2 :path pseudo-header: $(repr(normalized))")) path = normalized elseif name == ":authority" authority === nothing || throw(ProtocolError("duplicate HTTP/2 :authority pseudo-header")) @@ -1020,6 +1022,7 @@ function _validate_h2_request_headers!(headers::Vector{HeaderField})::Tuple{Stri else scheme === nothing && throw(ProtocolError("missing HTTP/2 :scheme pseudo-header")) path === nothing && throw(ProtocolError("missing HTTP/2 :path pseudo-header")) + path == "*" && method != "OPTIONS" && throw(ProtocolError("HTTP/2 :path * is only valid for OPTIONS")) end return method::String, scheme, path, authority, out_headers end diff --git a/test/http2_server_tests.jl b/test/http2_server_tests.jl index 0b0572cf3..89be4be77 100644 --- a/test/http2_server_tests.jl +++ b/test/http2_server_tests.jl @@ -2582,6 +2582,15 @@ end @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="/public /admin")) @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="/a\tb")) + # RFC 9113 §8.3.1: :path is an absolute path, or `*` for OPTIONS only. An + # empty :path reached the Request constructor, and "abc" was served, which + # HTTP/1 answers with 400. + @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="")) + @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="abc")) + @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="http://example.com/")) + @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(path="*")) + @test HT._validate_h2_request_headers!(_h2_fields(method="OPTIONS", path="*"))[3] == "*" + # ANT-2026-565042FN: :authority must pass host validation (no whitespace). @test_throws HT.ProtocolError HT._validate_h2_request_headers!(_h2_fields(authority="allowed.example.com evil")) From bb409144317f778cc2408b6b53fef19ca56a4943 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 22 Oct 2026 02:51:47 -0600 Subject: [PATCH 2/2] Link the CHANGELOG entry to #1484 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 633f9692d..87907bbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 until the handler finishes. ([#1384]) ### Fixed +- The HTTP/2 server rejects a request whose `:path` is not an absolute path, + or `*` for OPTIONS, as RFC 9113 §8.3.1 requires. An empty `:path` reset the + stream with `INTERNAL_ERROR` and logged a server error, and a `:path` such as + `abc` reached the handler, though HTTP/1 answers that target with 400. ([#1484]) - `serve!` answers a handler's response that cannot be sent with 500 and logs it. Such a response has an unsupported `Transfer-Encoding`, a control character in a header value, an invalid header name, reason phrase or @@ -1295,3 +1299,4 @@ See changes for 0.9.15: this release is equivalent to 0.9.15 with [#752] reverte [#1452]: https://github.com/JuliaWeb/HTTP.jl/issues/1452 [#1456]: https://github.com/JuliaWeb/HTTP.jl/issues/1456 [#1460]: https://github.com/JuliaWeb/HTTP.jl/issues/1460 +[#1484]: https://github.com/JuliaWeb/HTTP.jl/issues/1484