Skip to content

Commit 5f55f87

Browse files
youdie006quinnjclaude
authored
Join repeated HTTP/1 Cookie lines with "; " (#1386)
* Join repeated HTTP/1 Cookie lines with "; " _read_headers folded a repeated Cookie line into the previous one with the appendheader comma, so cookies(req) read Cookie: a=1 and Cookie: b=2 as the single cookie a="1,b=2". Join them with "; " as the HTTP/2 server already does. * Join repeated Cookie values with "; " in appendheader appendheader merged a repeated header into the previous entry with a comma and exempted only Set-Cookie. A Cookie header has no comma list syntax (RFC 6265 5.4 joins pairs with "; "), so every path that builds headers through appendheader produced a cookie the server reads as one pair: Headers(["Cookie" => "a=1", "Cookie" => "b=2"]), mkheaders, append!, and HTTP.get(url, ["Cookie" => "a=1", "Cookie" => "b=2"]), which sent Cookie: a="1,b=2". Join Cookie values with "; " there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Join received header lines in one pass Every receive path joined a repeated field line onto the previous entry with appendheader, which rebuilds the growing value on each line. A peer that repeats a name made that quadratic: on HTTP/1 a request made of 1 MiB of repeated header lines took about 2.8 s and 16 GiB of allocation to parse, and the HTTP/2 client, which accepts 10 MiB header lists, scaled the same way. The HTTP/1 parser, the HTTP/2 request validator and the HTTP/2 response header and trailer decoders now store each line as it arrives and join the section once with _fold_received_fields!, which keeps appendheader's result and joins every Cookie line into the first with "; " as the HTTP/2 server did. That also fixes an empty Cookie line followed by another one: HTTP/1 stored ",b=2", which cookies(req) could not parse, where HTTP/2 stored "b=2". The same 1 MiB request now parses in about 0.04 s and 40 MiB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cover consecutive repeats in the fold tests and update a Cookie comment The HTTP/1 allocation test alternated X-A and Cookie lines, which master never joined, so it guarded only the global Cookie join. Add a run of consecutive X-A lines, the shape master parsed in quadratic time, and check the HTTP/2 trailer decoder the same way as the response decoder. Move the Cookie appendheader checks into their own testset, and drop the addcookie! comment that still said appendheader joins with a comma. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Jacob Quinn <quinn.jacobd@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bf57bb1 commit 5f55f87

10 files changed

Lines changed: 149 additions & 24 deletions

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
164164
comma, so two adjacent calls produced `Cookie: a=1,b=2`. `cookies(::Request)`
165165
splits a `Cookie` header on `;` only, so that header parsed back as the single
166166
cookie `a="1,b=2"` and the second cookie was lost.
167+
- The HTTP/1 parser now joins every `Cookie` line of a request into one
168+
`Cookie` header with `"; "`, as the HTTP/2 server does. Adjacent lines were
169+
joined with a comma, so `Cookie: a=1` followed by `Cookie: b=2` was read by
170+
`cookies(::Request)` as the single cookie `a="1,b=2"`. ([#1386])
171+
- `appendheader` now joins repeated `Cookie` values with `"; "` instead of a
172+
comma, so `HTTP.get(url, ["Cookie" => "a=1", "Cookie" => "b=2"])` sends
173+
`Cookie: a=1; b=2` rather than the single cookie `a="1,b=2"`. ([#1386])
174+
- Received header sections are joined in linear time. Joining each repeated
175+
line onto the previous one rebuilt the growing value, so an HTTP/1 request
176+
made of 1 MiB of repeated header lines took about 3 s and 16 GiB of
177+
allocation to parse; HTTP/2 request and response headers scaled the same
178+
way. ([#1386])
167179

168180
## [v2.0.0] - 2026-04-27
169181
HTTP.jl 2.0 is a major rewrite of the package internals and public API. The
@@ -985,3 +997,4 @@ See changes for 0.9.15: this release is equivalent to 0.9.15 with [#752] reverte
985997
[#1362]: https://github.com/JuliaWeb/HTTP.jl/issues/1362
986998
[#1371]: https://github.com/JuliaWeb/HTTP.jl/issues/1371
987999
[#1377]: https://github.com/JuliaWeb/HTTP.jl/issues/1377
1000+
[#1386]: https://github.com/JuliaWeb/HTTP.jl/issues/1386

‎src/http1.jl‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ function _upcoming_header_keys(io::IO)::Int
169169
return 0
170170
end
171171

172+
# Framing and Host lines stay separate so their checks see every line.
173+
_h1_separate_field(key::String)::Bool = key == "Content-Length" || key == "Transfer-Encoding" || key == "Host"
174+
172175
function _read_headers(io::IO, max_line_bytes::Integer, max_header_bytes::Integer)::Headers
173176
max_header_bytes <= 0 && throw(ArgumentError("max_header_bytes must be > 0"))
174177
headers = Headers(_upcoming_header_keys(io))
@@ -177,7 +180,7 @@ function _read_headers(io::IO, max_line_bytes::Integer, max_header_bytes::Intege
177180
line = _readline_crlf(io, max_line_bytes)
178181
consumed += ncodeunits(line) + 2
179182
consumed > max_header_bytes && throw(ProtocolError("HTTP/1 headers exceed configured max_header_bytes", _PROTOCOL_ERROR_HEADERS_TOO_LARGE))
180-
isempty(line) && return headers
183+
isempty(line) && return _fold_received_fields!(headers, _h1_separate_field)
181184
sep = findfirst(':', line)
182185
sep === nothing && throw(ParseError("malformed HTTP/1 header line (missing ':'): $(repr(line))"))
183186
key = String(SubString(line, firstindex(line), prevind(line, sep)))
@@ -186,12 +189,7 @@ function _read_headers(io::IO, max_line_bytes::Integer, max_header_bytes::Intege
186189
value = _trim_http_ows(SubString(line, nextind(line, sep), lastindex(line)))
187190
normalized = _normalize_header_field_value(value)
188191
normalized === nothing && throw(ParseError("invalid HTTP/1 header field value for $(repr(key))"))
189-
canon_key = canonical_header_key(key)
190-
if canon_key == "Content-Length" || canon_key == "Transfer-Encoding" || canon_key == "Host"
191-
push!(headers, canon_key => normalized)
192-
else
193-
appendheader(headers, canon_key, normalized)
194-
end
192+
push!(headers, canonical_header_key(key) => normalized)
195193
end
196194
end
197195

‎src/http2_client.jl‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,10 +1651,10 @@ function _decode_response_headers(headers::Vector{HeaderField})::Tuple{Int,Heade
16511651
if _is_h2_connection_specific_header(name) || name == "transfer-encoding" || name == "te"
16521652
throw(ProtocolError("forbidden HTTP/2 response header $(repr(name))"))
16531653
end
1654-
appendheader(out, canonical_header_key(name), normalized)
1654+
push!(out, canonical_header_key(name) => normalized)
16551655
end
16561656
status === nothing && throw(ProtocolError("missing HTTP/2 :status pseudo-header"))
1657-
return status::Int, out
1657+
return status::Int, _fold_received_fields!(out)
16581658
end
16591659

16601660
function _decode_h2_trailer_headers(headers::Vector{HeaderField})::Headers
@@ -1667,9 +1667,9 @@ function _decode_h2_trailer_headers(headers::Vector{HeaderField})::Headers
16671667
_valid_trailer_header_name(name) || throw(ProtocolError("invalid HTTP/2 trailer header $(repr(name))"))
16681668
normalized = _normalize_strict_header_field_value(value)
16691669
normalized === nothing && throw(ProtocolError("invalid HTTP/2 trailer field value for $(repr(name))"))
1670-
appendheader(out, canonical_header_key(name), normalized)
1670+
push!(out, canonical_header_key(name) => normalized)
16711671
end
1672-
return out
1672+
return _fold_received_fields!(out)
16731673
end
16741674

16751675
function _publish_h2_response_trailers!(state::H2StreamState)

‎src/http2_server.jl‎

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -974,16 +974,9 @@ function _validate_h2_request_headers!(headers::Vector{HeaderField})::Tuple{Stri
974974
if name == "te"
975975
lowercase(_trim_http_ows(normalized)) == "trailers" || throw(ProtocolError("HTTP/2 TE header may only contain trailers"))
976976
end
977-
if name == "cookie"
978-
if hasheader(out_headers, "Cookie")
979-
setheader(out_headers, "Cookie", string(header(out_headers, "Cookie"), "; ", normalized))
980-
else
981-
setheader(out_headers, "Cookie", normalized)
982-
end
983-
continue
984-
end
985-
appendheader(out_headers, canonical_header_key(name), normalized)
977+
push!(out_headers, canonical_header_key(name) => normalized)
986978
end
979+
_fold_received_fields!(out_headers)
987980
method === nothing && throw(ProtocolError("missing HTTP/2 :method pseudo-header"))
988981
# RFC 9113 8.3.1: "If the :authority pseudo-header field is present, the
989982
# endpoint MUST NOT generate a request with a Host header field that differs

‎src/http_cookies.jl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ slot.
191191
function addcookie! end
192192

193193
function addcookie!(r::Request, c::Cookie)
194-
# one Cookie header, pairs joined with "; " (RFC 6265 5.4); appendheader would join with ","
194+
# one Cookie header holding every stored pair, joined with "; " (RFC 6265 5.4)
195195
setheader(r.headers, "Cookie" => stringify(join(headers(r.headers, "Cookie"), "; "), [c]))
196196
return r
197197
end

‎src/http_core.jl‎

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -951,8 +951,9 @@ Append a header value to `headers`.
951951
952952
If the previous stored header has the same name (in any case) and the key is
953953
not `Set-Cookie`, the value is merged into the previous entry, which keeps its
954-
spelling, with a comma (no whitespace), as permitted by RFC 9110 §5.3 and
955-
required by common request-signing canonicalizations.
954+
spelling. `Cookie` values are joined with `"; "` (RFC 6265 §5.4); other values
955+
with a comma and no whitespace, as permitted by RFC 9110 §5.3 and required by
956+
common request-signing canonicalizations.
956957
Otherwise a new pair is appended. [`appendheader!`](@ref) is the same
957958
function under the conventional mutating-name spelling.
958959
"""
@@ -961,7 +962,8 @@ function appendheader(headers::Headers, header::Pair)
961962
if !isempty(headers.entries)
962963
last_header = headers.entries[end]
963964
if !_ascii_equal_fold(first(item), "Set-Cookie") && _ascii_equal_fold(first(last_header), first(item))
964-
headers.entries[end] = first(last_header) => string(last(last_header), ",", last(item))
965+
sep = _ascii_equal_fold(first(item), "Cookie") ? "; " : ","
966+
headers.entries[end] = first(last_header) => string(last(last_header), sep, last(item))
965967
return headers
966968
end
967969
end
@@ -973,6 +975,61 @@ function appendheader(headers::Headers, key::AbstractString, value::AbstractStri
973975
return appendheader(headers, key => value)
974976
end
975977

978+
"""
979+
_fold_received_fields!(headers, separate=Returns(false)) -> headers
980+
981+
Join a received header section stored one entry per line, in one pass: a line
982+
with the same name as the entry kept before it is joined onto that entry with a
983+
comma, as `appendheader` does, and every `Cookie` line onto the first `Cookie`
984+
entry with `"; "` (RFC 6265 §5.4). `Set-Cookie` lines and names for which
985+
`separate` returns true stay separate. Each joined value is built once, so
986+
repeated names cost linear rather than quadratic time.
987+
"""
988+
function _fold_received_fields!(headers::Headers, separate::F=Returns(false)) where {F}
989+
entries = headers.entries
990+
out = 0
991+
run = nothing # the value of entries[out] and the values joined onto it
992+
cookie_at = 0
993+
cookie = nothing # the joined Cookie value, from the second Cookie line on
994+
for i in eachindex(entries)
995+
key, value = entries[i]
996+
if _ascii_equal_fold(key, "Cookie") && cookie_at > 0
997+
if cookie === nothing
998+
cookie = IOBuffer()
999+
write(cookie, last(entries[cookie_at]))
1000+
end
1001+
# no separator while the joined value is still empty
1002+
position(cookie) > 0 && write(cookie, "; ")
1003+
write(cookie, value)
1004+
continue
1005+
end
1006+
if out > 0 && _ascii_equal_fold(first(entries[out]), key) &&
1007+
!_ascii_equal_fold(key, "Set-Cookie") && !separate(key)
1008+
if run === nothing
1009+
run = IOBuffer()
1010+
write(run, last(entries[out]))
1011+
end
1012+
write(run, ',', value)
1013+
continue
1014+
end
1015+
_store_run!(entries, out, run)
1016+
run = nothing
1017+
out += 1
1018+
entries[out] = entries[i]
1019+
_ascii_equal_fold(key, "Cookie") && (cookie_at = out)
1020+
end
1021+
_store_run!(entries, out, run)
1022+
cookie === nothing || (entries[cookie_at] = first(entries[cookie_at]) => String(take!(cookie)))
1023+
resize!(entries, out)
1024+
return headers
1025+
end
1026+
1027+
@inline function _store_run!(entries::Vector{Pair{String,String}}, out::Int, run::Union{Nothing,IOBuffer})::Nothing
1028+
run === nothing && return nothing
1029+
entries[out] = first(entries[out]) => String(take!(run))
1030+
return nothing
1031+
end
1032+
9761033
"""
9771034
removeheader(headers, key) -> Headers
9781035

‎test/http1_wire_tests.jl‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ end
8888
@test take!(partial_io) == collect(codeunits("cdef"))
8989
end
9090

91+
@testset "HTTP/1 request parse combines cookie lines with semicolons" begin
92+
raw = "GET / HTTP/1.1\r\nHost: example.com\r\nCookie: a=1\r\nX-Test: one\r\nCookie: b=2\r\nCookie: c=3\r\n\r\n"
93+
req = HT.read_request(IOBuffer(codeunits(raw)))
94+
@test HT.headers(req.headers, "Cookie") == ["a=1; b=2; c=3"]
95+
@test [(c.name, c.value) for c in HT.Cookies.cookies(req)] == [("a", "1"), ("b", "2"), ("c", "3")]
96+
raw = "GET / HTTP/1.1\r\nHost: example.com\r\nCookie: \r\nCookie: b=2\r\n\r\n"
97+
req = HT.read_request(IOBuffer(codeunits(raw)))
98+
@test HT.headers(req.headers, "Cookie") == ["b=2"]
99+
end
100+
101+
@testset "HTTP/1 header parse joins repeated lines in one pass" begin
102+
raw = "GET / HTTP/1.1\r\nHost: x\r\nCookie: a=1\r\nX-A: 1\r\nCookie: b=2\r\nX-A: 2\r\nx-a: 3\r\n" *
103+
"Set-Cookie: s=1\r\nSet-Cookie: s=2\r\n\r\n"
104+
req = HT.read_request(IOBuffer(codeunits(raw)))
105+
@test collect(req.headers) == ["Host" => "x", "Cookie" => "a=1; b=2", "X-A" => "1,2,3",
106+
"Set-Cookie" => "s=1", "Set-Cookie" => "s=2"]
107+
repeated_lines(n) = codeunits("GET / HTTP/1.1\r\nHost: x\r\n" * "X-A: a\r\n"^n * "X-B: b\r\nCookie: c\r\n"^n * "\r\n")
108+
readreq(bytes) = HT.read_request(IOBuffer(bytes))
109+
small, large = repeated_lines(4_000), repeated_lines(8_000)
110+
readreq(small); readreq(large)
111+
# twice the repeated lines must cost about twice as much, not four times
112+
@test @allocated(readreq(large)) < 3 * @allocated(readreq(small))
113+
end
114+
91115
@testset "HTTP/1 header serialization preserves stored entries" begin
92116
headers = HT.Headers()
93117
push!(headers, "X-Test" => "one")

‎test/http2_client_tests.jl‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,22 @@ end
215215
@test collect(trailers) == ["Grpc-Status" => "0"]
216216
end
217217

218+
@testset "HTTP/2 client joins repeated response fields in one pass" begin
219+
response_fields(n) = vcat(HT.HeaderField[HT.HeaderField(":status", "200", false)],
220+
[HT.HeaderField("x-a", "a", false) for _ in 1:n])
221+
_, headers = HT._decode_response_headers(response_fields(3))
222+
@test collect(headers) == ["X-A" => "a,a,a"]
223+
trailer_fields = [HT.HeaderField("x-a", "a", false) for _ in 1:3]
224+
@test collect(HT._decode_h2_trailer_headers(trailer_fields)) == ["X-A" => "a,a,a"]
225+
small, large = response_fields(10_000), response_fields(20_000)
226+
HT._decode_response_headers(small); HT._decode_response_headers(large)
227+
# twice the repeated fields must cost about twice as much, not four times
228+
@test @allocated(HT._decode_response_headers(large)) < 3 * @allocated(HT._decode_response_headers(small))
229+
small, large = small[2:end], large[2:end]
230+
HT._decode_h2_trailer_headers(small); HT._decode_h2_trailer_headers(large)
231+
@test @allocated(HT._decode_h2_trailer_headers(large)) < 3 * @allocated(HT._decode_h2_trailer_headers(small))
232+
end
233+
218234
@testset "HTTP/2 client validates response pseudo-headers" begin
219235
@test_throws HT.ProtocolError HT._decode_response_headers(HT.HeaderField[])
220236
@test_throws HT.ProtocolError HT._decode_response_headers(HT.HeaderField[

‎test/http2_server_tests.jl‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,21 @@ end
14181418
@test HT.header(headers, "Cookie") == "a=1; b=2"
14191419
end
14201420

1421+
@testset "HTTP/2 server joins repeated request fields in one pass" begin
1422+
request_fields(n) = vcat(HT.HeaderField[
1423+
HT.HeaderField(":method", "GET", false),
1424+
HT.HeaderField(":scheme", "http", false),
1425+
HT.HeaderField(":authority", "example.test", false),
1426+
HT.HeaderField(":path", "/", false),
1427+
], [HT.HeaderField(isodd(i) ? "cookie" : "x-a", "a", false) for i in 1:n])
1428+
_, _, _, _, headers = HT._validate_h2_request_headers!(request_fields(4))
1429+
@test collect(headers) == ["Cookie" => "a; a", "X-A" => "a,a"]
1430+
small, large = request_fields(10_000), request_fields(20_000)
1431+
HT._validate_h2_request_headers!(small); HT._validate_h2_request_headers!(large)
1432+
# twice the repeated fields must cost about twice as much, not four times
1433+
@test @allocated(HT._validate_h2_request_headers!(large)) < 3 * @allocated(HT._validate_h2_request_headers!(small))
1434+
end
1435+
14211436
@testset "HTTP/2 server stores request header names in canonical form" begin
14221437
fields = HT.HeaderField[
14231438
HT.HeaderField(":method", "GET", false),

‎test/http_core_tests.jl‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@ end
188188
@test collect(cookies) == ["Set-Cookie" => "a=1", "Set-Cookie" => "b=2"]
189189
end
190190

191+
@testset "appendheader joins Cookie values with a semicolon (RFC 6265 §5.4)" begin
192+
cookie = HT.Headers()
193+
HT.appendheader(cookie, "Cookie", "a=1")
194+
HT.appendheader(cookie, "cookie", "b=2")
195+
@test collect(cookie) == ["Cookie" => "a=1; b=2"]
196+
request = HT.Request("GET", "/"; headers = ["Cookie" => "a=1", "Cookie" => "b=2"])
197+
@test [(c.name, c.value) for c in HT.Cookies.cookies(request)] == [("a", "1"), ("b", "2")]
198+
end
199+
191200
@testset "Tuple header collections" begin
192201
for items in ((), ("x-a" => "one",), ("x-a" => "one", "x-b" => "two"),
193202
("x-a" => "one", "x-b" => "two", "x-c" => "three"),

0 commit comments

Comments
 (0)