From 83aae9508c66195f5ea41adad0fa9a472ad64e0d Mon Sep 17 00:00:00 2001 From: Fredrik Ekre Date: Fri, 21 Aug 2026 17:14:41 +0200 Subject: [PATCH] Preserve explicit Content-Length on bodyless server responses A server handler that set a Content-Length header and then completed a HEAD request without writing a body had the header stripped before the response head was sent: the write-mode decision conflated "suppress the body" with "suppress the framing headers". The same stripping applied to 304 responses. Keep suppressing the body, but only remove Content-Length when the status actually forbids it (1xx/204, RFC 9110 section 8.6): a HEAD response should carry the header fields a GET would have produced (section 9.3.2), and a 304 may repeat the Content-Length of the representation it revalidates (section 15.4.5). This fixes the HTTP/1 and live HTTP/2 stream paths, and write_response! (which already handled HEAD via the content_length field but stripped an explicit Content-Length from 304 responses). Co-Authored-By: Claude Fable 5 --- src/http1.jl | 13 +++++++- src/http_server_streams.jl | 8 +++-- test/http2_server_tests.jl | 33 +++++++++++++++++++ test/http_server_http1_tests.jl | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/http1.jl b/src/http1.jl index f4347c6b1..201ae3184 100644 --- a/src/http1.jl +++ b/src/http1.jl @@ -339,6 +339,15 @@ function _body_allowed_for_status(status::Integer)::Bool return true end +# RFC 9110 §8.6 forbids Content-Length on 1xx and 204 responses, but other +# bodyless responses may still advertise one: a HEAD response carries the +# header fields a GET would have produced (§9.3.2), and a 304 may repeat the +# Content-Length of the representation it revalidates (§15.4.5). +function _content_length_allowed_for_status(status::Integer)::Bool + status == 304 && return true + return _body_allowed_for_status(status) +end + function _read_exact!(io::IO, dst::Vector{UInt8}, nbytes::Integer)::Int nbytes < 0 && throw(ArgumentError("nbytes must be >= 0")) nbytes == 0 && return 0 @@ -842,7 +851,9 @@ function write_response!(io::IO, response::Response) allows_body = status_allows_body && !response_to_head use_chunked = allows_body && _parse_transfer_encoding!(headers, response.proto_major, response.proto_minor) if !status_allows_body - removeheader(headers, "Content-Length") + # 1xx/204 must not advertise framing; a 304 keeps an explicitly + # provided Content-Length. + _content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length") removeheader(headers, "Transfer-Encoding") elseif response_to_head removeheader(headers, "Transfer-Encoding") diff --git a/src/http_server_streams.jl b/src/http_server_streams.jl index 01fdcefa2..9534889ef 100644 --- a/src/http_server_streams.jl +++ b/src/http_server_streams.jl @@ -66,7 +66,9 @@ function _write_server_stream_head!(stream::Stream)::Nothing stream.write_mode = mode if _server_stream_live_h2(stream) if mode == _ServerStreamWriteMode.NONE - removeheader(headers, "Content-Length") + # Only the body is suppressed: HEAD and 304 responses keep an + # explicitly provided Content-Length describing the representation. + _content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length") elseif response.content_length >= 0 setheader(headers, "Content-Length", string(response.content_length)) end @@ -92,7 +94,9 @@ function _write_server_stream_head!(stream::Stream)::Nothing return nothing end if mode == _ServerStreamWriteMode.NONE - removeheader(headers, "Content-Length") + # Only the body is suppressed: HEAD and 304 responses keep an + # explicitly provided Content-Length describing the representation. + _content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length") removeheader(headers, "Transfer-Encoding") elseif mode == _ServerStreamWriteMode.FIXED if response.content_length >= 0 diff --git a/test/http2_server_tests.jl b/test/http2_server_tests.jl index 2d92fb6ad..1826f783d 100644 --- a/test/http2_server_tests.jl +++ b/test/http2_server_tests.jl @@ -891,6 +891,39 @@ end end end +@testset "HTTP/2 server stream handlers keep an explicit Content-Length for HEAD" begin + server = HT.listen!("127.0.0.1", 0; listenany = true) do stream + request = HT.startread(stream) + HT.setheader(stream, "Content-Length" => "5") + HT.setstatus(stream, 200) + if request.method == "HEAD" + HT.startwrite(stream) + else + write(stream, "hello") + end + return nothing + end + address = HT.server_addr(server) + conn = HT.connect_h2!(address; secure = false) + try + get_req = HT.Request("GET", "/sized"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) + get_res = HT.h2_roundtrip!(conn, get_req) + @test get_res.status == 200 + @test HT.header(get_res.headers, "Content-Length") == "5" + @test String(_read_all_h2_server(get_res.body)) == "hello" + + head_req = HT.Request("HEAD", "/sized"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0) + head_res = HT.h2_roundtrip!(conn, head_req) + @test head_res.status == 200 + @test HT.header(head_res.headers, "Content-Length") == "5" + @test isempty(_read_all_h2_server(head_res.body)) + finally + close(conn) + HT.forceclose(server) + HTTP.@try_ignore wait(server.serve_task::Task) + end +end + @testset "HTTP/2 server stream handlers flush DATA before handler return" begin first_written = Channel{Nothing}(1) release = Channel{Nothing}(1) diff --git a/test/http_server_http1_tests.jl b/test/http_server_http1_tests.jl index 3714792e2..0768ad0fa 100644 --- a/test/http_server_http1_tests.jl +++ b/test/http_server_http1_tests.jl @@ -902,6 +902,45 @@ end end end +@testset "HTTP server stream handlers keep an explicit Content-Length for HEAD and 304" begin + server = HT.listen!("127.0.0.1", 0; listenany = true) do stream + request = HT.startread(stream) + HT.setheader(stream, "Content-Length" => "12345") + if request.target == "/nocontent" + HT.setstatus(stream, 204) + elseif request.target == "/notmodified" + HT.setstatus(stream, 304) + else + HT.setstatus(stream, 200) + end + HT.startwrite(stream) + return nothing + end + address = HT.server_addr(server) + try + head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") + @test occursin("HTTP/1.1 200 OK", head_raw) + @test occursin("Content-Length: 12345\r\n", head_raw) + head_parts = split(head_raw, "\r\n\r\n"; limit = 2) + @test length(head_parts) == 2 + @test head_parts[2] == "" + + not_modified_raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") + @test occursin("HTTP/1.1 304 Not Modified", not_modified_raw) + @test occursin("Content-Length: 12345\r\n", not_modified_raw) + not_modified_parts = split(not_modified_raw, "\r\n\r\n"; limit = 2) + @test length(not_modified_parts) == 2 + @test not_modified_parts[2] == "" + + no_content_raw = _raw_http_request(HT.port(server), "GET /nocontent HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") + @test occursin("HTTP/1.1 204 No Content", no_content_raw) + @test !occursin("content-length", lowercase(no_content_raw)) + finally + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) + end +end + @testset "HTTP server timeout and handler error responses" begin timeout_server = HT.Server( address = "127.0.0.1:0", @@ -1090,6 +1129,25 @@ end end end +@testset "HTTP server ordinary handlers keep an explicit Content-Length for 304" begin + server = HT.serve!("127.0.0.1", 0; listenany = true) do request + return HT.Response(304; headers = ["Content-Length" => "12345"], request = request) + end + address = HT.server_addr(server) + try + raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n") + @test occursin("HTTP/1.1 304 Not Modified", raw) + @test occursin("Content-Length: 12345\r\n", raw) + @test !occursin("transfer-encoding", lowercase(raw)) + parts = split(raw, "\r\n\r\n"; limit = 2) + @test length(parts) == 2 + @test parts[2] == "" + finally + _run_test_operation(() -> HT.forceclose(server)) + _run_test_operation(() -> wait(server)) + end +end + @testset "HTTP server ordinary handlers receive buffered request bodies" begin @test HT.Server(handler = _ -> HT.Response(200), max_body_bytes = 0).max_body_bytes == 0 @test_throws ArgumentError HT.Server(handler = _ -> HT.Response(200), max_body_bytes = -1)