Skip to content

Commit d3ac72e

Browse files
fredrikekreclaude
andauthored
Preserve explicit Content-Length on bodyless server responses (#1352)
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 <noreply@anthropic.com>
1 parent 0c3b758 commit d3ac72e

4 files changed

Lines changed: 109 additions & 3 deletions

File tree

src/http1.jl

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,15 @@ function _body_allowed_for_status(status::Integer)::Bool
339339
return true
340340
end
341341

342+
# RFC 9110 §8.6 forbids Content-Length on 1xx and 204 responses, but other
343+
# bodyless responses may still advertise one: a HEAD response carries the
344+
# header fields a GET would have produced (§9.3.2), and a 304 may repeat the
345+
# Content-Length of the representation it revalidates (§15.4.5).
346+
function _content_length_allowed_for_status(status::Integer)::Bool
347+
status == 304 && return true
348+
return _body_allowed_for_status(status)
349+
end
350+
342351
function _read_exact!(io::IO, dst::Vector{UInt8}, nbytes::Integer)::Int
343352
nbytes < 0 && throw(ArgumentError("nbytes must be >= 0"))
344353
nbytes == 0 && return 0
@@ -842,7 +851,9 @@ function write_response!(io::IO, response::Response)
842851
allows_body = status_allows_body && !response_to_head
843852
use_chunked = allows_body && _parse_transfer_encoding!(headers, response.proto_major, response.proto_minor)
844853
if !status_allows_body
845-
removeheader(headers, "Content-Length")
854+
# 1xx/204 must not advertise framing; a 304 keeps an explicitly
855+
# provided Content-Length.
856+
_content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length")
846857
removeheader(headers, "Transfer-Encoding")
847858
elseif response_to_head
848859
removeheader(headers, "Transfer-Encoding")

src/http_server_streams.jl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ function _write_server_stream_head!(stream::Stream)::Nothing
6666
stream.write_mode = mode
6767
if _server_stream_live_h2(stream)
6868
if mode == _ServerStreamWriteMode.NONE
69-
removeheader(headers, "Content-Length")
69+
# Only the body is suppressed: HEAD and 304 responses keep an
70+
# explicitly provided Content-Length describing the representation.
71+
_content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length")
7072
elseif response.content_length >= 0
7173
setheader(headers, "Content-Length", string(response.content_length))
7274
end
@@ -92,7 +94,9 @@ function _write_server_stream_head!(stream::Stream)::Nothing
9294
return nothing
9395
end
9496
if mode == _ServerStreamWriteMode.NONE
95-
removeheader(headers, "Content-Length")
97+
# Only the body is suppressed: HEAD and 304 responses keep an
98+
# explicitly provided Content-Length describing the representation.
99+
_content_length_allowed_for_status(response.status) || removeheader(headers, "Content-Length")
96100
removeheader(headers, "Transfer-Encoding")
97101
elseif mode == _ServerStreamWriteMode.FIXED
98102
if response.content_length >= 0

test/http2_server_tests.jl

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,39 @@ end
891891
end
892892
end
893893

894+
@testset "HTTP/2 server stream handlers keep an explicit Content-Length for HEAD" begin
895+
server = HT.listen!("127.0.0.1", 0; listenany = true) do stream
896+
request = HT.startread(stream)
897+
HT.setheader(stream, "Content-Length" => "5")
898+
HT.setstatus(stream, 200)
899+
if request.method == "HEAD"
900+
HT.startwrite(stream)
901+
else
902+
write(stream, "hello")
903+
end
904+
return nothing
905+
end
906+
address = HT.server_addr(server)
907+
conn = HT.connect_h2!(address; secure = false)
908+
try
909+
get_req = HT.Request("GET", "/sized"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0)
910+
get_res = HT.h2_roundtrip!(conn, get_req)
911+
@test get_res.status == 200
912+
@test HT.header(get_res.headers, "Content-Length") == "5"
913+
@test String(_read_all_h2_server(get_res.body)) == "hello"
914+
915+
head_req = HT.Request("HEAD", "/sized"; host = address, body = HT.EmptyBody(), content_length = 0, proto_major = 2, proto_minor = 0)
916+
head_res = HT.h2_roundtrip!(conn, head_req)
917+
@test head_res.status == 200
918+
@test HT.header(head_res.headers, "Content-Length") == "5"
919+
@test isempty(_read_all_h2_server(head_res.body))
920+
finally
921+
close(conn)
922+
HT.forceclose(server)
923+
HTTP.@try_ignore wait(server.serve_task::Task)
924+
end
925+
end
926+
894927
@testset "HTTP/2 server stream handlers flush DATA before handler return" begin
895928
first_written = Channel{Nothing}(1)
896929
release = Channel{Nothing}(1)

test/http_server_http1_tests.jl

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,45 @@ end
902902
end
903903
end
904904

905+
@testset "HTTP server stream handlers keep an explicit Content-Length for HEAD and 304" begin
906+
server = HT.listen!("127.0.0.1", 0; listenany = true) do stream
907+
request = HT.startread(stream)
908+
HT.setheader(stream, "Content-Length" => "12345")
909+
if request.target == "/nocontent"
910+
HT.setstatus(stream, 204)
911+
elseif request.target == "/notmodified"
912+
HT.setstatus(stream, 304)
913+
else
914+
HT.setstatus(stream, 200)
915+
end
916+
HT.startwrite(stream)
917+
return nothing
918+
end
919+
address = HT.server_addr(server)
920+
try
921+
head_raw = _raw_http_request(HT.port(server), "HEAD /head HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n")
922+
@test occursin("HTTP/1.1 200 OK", head_raw)
923+
@test occursin("Content-Length: 12345\r\n", head_raw)
924+
head_parts = split(head_raw, "\r\n\r\n"; limit = 2)
925+
@test length(head_parts) == 2
926+
@test head_parts[2] == ""
927+
928+
not_modified_raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n")
929+
@test occursin("HTTP/1.1 304 Not Modified", not_modified_raw)
930+
@test occursin("Content-Length: 12345\r\n", not_modified_raw)
931+
not_modified_parts = split(not_modified_raw, "\r\n\r\n"; limit = 2)
932+
@test length(not_modified_parts) == 2
933+
@test not_modified_parts[2] == ""
934+
935+
no_content_raw = _raw_http_request(HT.port(server), "GET /nocontent HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n")
936+
@test occursin("HTTP/1.1 204 No Content", no_content_raw)
937+
@test !occursin("content-length", lowercase(no_content_raw))
938+
finally
939+
_run_test_operation(() -> HT.forceclose(server))
940+
_run_test_operation(() -> wait(server))
941+
end
942+
end
943+
905944
@testset "HTTP server timeout and handler error responses" begin
906945
timeout_server = HT.Server(
907946
address = "127.0.0.1:0",
@@ -1090,6 +1129,25 @@ end
10901129
end
10911130
end
10921131

1132+
@testset "HTTP server ordinary handlers keep an explicit Content-Length for 304" begin
1133+
server = HT.serve!("127.0.0.1", 0; listenany = true) do request
1134+
return HT.Response(304; headers = ["Content-Length" => "12345"], request = request)
1135+
end
1136+
address = HT.server_addr(server)
1137+
try
1138+
raw = _raw_http_request(HT.port(server), "GET /notmodified HTTP/1.1\r\nHost: $(address)\r\nConnection: close\r\n\r\n")
1139+
@test occursin("HTTP/1.1 304 Not Modified", raw)
1140+
@test occursin("Content-Length: 12345\r\n", raw)
1141+
@test !occursin("transfer-encoding", lowercase(raw))
1142+
parts = split(raw, "\r\n\r\n"; limit = 2)
1143+
@test length(parts) == 2
1144+
@test parts[2] == ""
1145+
finally
1146+
_run_test_operation(() -> HT.forceclose(server))
1147+
_run_test_operation(() -> wait(server))
1148+
end
1149+
end
1150+
10931151
@testset "HTTP server ordinary handlers receive buffered request bodies" begin
10941152
@test HT.Server(handler = _ -> HT.Response(200), max_body_bytes = 0).max_body_bytes == 0
10951153
@test_throws ArgumentError HT.Server(handler = _ -> HT.Response(200), max_body_bytes = -1)

0 commit comments

Comments
 (0)