Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/http_client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1066,12 +1066,15 @@ function _status_throws(resp::Response)::Bool
return resp.status >= 300 && !_is_redirect_status(resp.status)
end

function _read_all_response_bytes(io::IO)::Vector{UInt8}
function _read_all_response_bytes(io::IO, limit::Int=0)::Vector{UInt8}
out = UInt8[]
buf = Vector{UInt8}(undef, 8192)
total = 0
while true
n = readbytes!(io, buf, length(buf))
n == 0 && return out
total += n
limit > 0 && total > limit && throw(DecompressionLimitError(limit))
append!(out, @view(buf[1:n]))
end
end
Expand All @@ -1095,25 +1098,27 @@ function _read_all_response_bytes(body::AbstractBody, content_length_hint::Int64
end
end

function _copy_response_bytes!(dest::IO, io::IO)::Int64
function _copy_response_bytes!(dest::IO, io::IO, limit::Int=0)::Int64
buf = Vector{UInt8}(undef, 8192)
total = Int64(0)
while true
n = readbytes!(io, buf, length(buf))
n == 0 && return total
total += n
limit > 0 && total > limit && throw(DecompressionLimitError(limit))
write(dest, view(buf, 1:n))
end
end

function _copy_response_bytes!(dest::AbstractVector{UInt8}, io::IO)::Int64
function _copy_response_bytes!(dest::AbstractVector{UInt8}, io::IO, limit::Int=0)::Int64
buf = Vector{UInt8}(undef, 8192)
total = 0
capacity = length(dest)
while true
n = readbytes!(io, buf, length(buf))
n == 0 && break
needed = total + n
limit > 0 && needed > limit && throw(DecompressionLimitError(limit))
needed <= capacity || throw(ArgumentError("Unable to grow response stream IOBuffer $(capacity) large enough for response body size: $(needed)"))
copyto!(dest, total + 1, buf, 1, n)
total = needed
Expand Down Expand Up @@ -1332,6 +1337,22 @@ function Base.close(io::_BodyIO)
return nothing
end

"""
DecompressionLimitError(limit)

Thrown when an automatically decompressed response body exceeds the
`max_decompressed_size` byte limit passed to the request. Guards against
decompression bombs — small compressed payloads that inflate to exhaust memory.
"""
struct DecompressionLimitError <: Exception
limit::Int
end

function Base.showerror(io::IO, err::DecompressionLimitError)
print(io, "DecompressionLimitError: decompressed response body exceeded max_decompressed_size = ", err.limit, " bytes")
return nothing
end

function _response_body_reader(incoming::_IncomingResponse, decompress::Union{Nothing,Bool})::Tuple{IO,Union{Nothing,Task}}
raw_stream = _BodyIO(incoming.rawbody)
encoding = _response_content_encoding(incoming.head.headers, decompress)
Expand Down Expand Up @@ -1365,6 +1386,7 @@ function _consume_incoming_response!(
incoming::_IncomingResponse,
sink,
decompress::Union{Nothing,Bool},
max_decompressed_size::Int=0,
)::Tuple{Any,Int64}
if _incoming_response_has_no_body(incoming) || !_should_decompress_response(incoming.head.headers, decompress)
try
Expand All @@ -1390,14 +1412,14 @@ function _consume_incoming_response!(
end
return _with_response_reader(incoming, decompress) do reader
if sink === nothing
body = _read_all_response_bytes(reader)
body = _read_all_response_bytes(reader, max_decompressed_size)
return body, Int64(length(body))
end
if sink isa IO
n = _copy_response_bytes!(sink::IO, reader)
n = _copy_response_bytes!(sink::IO, reader, max_decompressed_size)
return nothing, n
end
n = _copy_response_bytes!(sink::AbstractVector{UInt8}, reader)
n = _copy_response_bytes!(sink::AbstractVector{UInt8}, reader, max_decompressed_size)
if sink isa Vector{UInt8}
return sink::Vector{UInt8}, n
end
Expand Down Expand Up @@ -1706,6 +1728,7 @@ function request(
query=nothing,
response_stream=nothing,
decompress::Union{Nothing,Bool}=nothing,
max_decompressed_size::Integer=0,
sse_callback=nothing,
client::Union{Nothing,Client}=nothing,
context::Union{Nothing,RequestContext}=nothing,
Expand Down Expand Up @@ -1831,7 +1854,7 @@ function request(
return sse_response
end
end
final_body, final_length = _consume_incoming_response!(incoming, sink, decompress)
final_body, final_length = _consume_incoming_response!(incoming, sink, decompress, Int(max_decompressed_size))
response = _finalize_request_response(incoming, final_body, final_length, resolved_request, parsed.url)
final_response = response
status_exception && _status_throws(response) && throw(StatusError(response))
Expand Down Expand Up @@ -1900,6 +1923,7 @@ Keyword arguments:
- `query`: optional query string or key/value collection appended to the URL
- `response_stream`: optional sink `IO` or byte buffer written with the final response body
- `decompress`: `nothing`/`true` auto-decompress gzip and deflate responses, `false` leaves wire bytes untouched
- `max_decompressed_size`: cap, in bytes, on an auto-decompressed response body; reading past it throws `DecompressionLimitError`, guarding against decompression bombs. `0` (default) disables the limit
- `sse_callback`: callback receiving `(event)` or `(stream, event)` for
successful SSE responses
- `trace`: optional callback receiving request lifecycle events
Expand Down
43 changes: 43 additions & 0 deletions test/http_client_tests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2337,3 +2337,46 @@ end
HT.forceclose(slow_server)
end
end

@testset "max_decompressed_size guards against decompression bombs" begin
# ~4 MB of zeros compresses to a few KB of gzip — a small "bomb".
big = zeros(UInt8, 4_000_000)
gz = transcode(HTTP.CodecZlib.GzipCompressor, big)
@test length(gz) < 100_000 # confirm the payload really is small on the wire
server = HT.serve!("127.0.0.1", 0; listenany = true) do req
return HT.Response(200; headers = ["Content-Encoding" => "gzip"], body = gz)
end
try
base = "http://127.0.0.1:$(HT.port(server))/"

# No limit (default): the full body decompresses.
r = HT.get(base)
@test length(r.body) == length(big)

# Limit below the decompressed size: rejected before the bomb inflates.
err = try
HT.get(base; max_decompressed_size = 1024)
nothing
catch e
e
end
@test err isa HTTP.DecompressionLimitError
err isa HTTP.DecompressionLimitError && @test err.limit == 1024

# Limit at/above the decompressed size: succeeds.
r2 = HT.get(base; max_decompressed_size = length(big))
@test length(r2.body) == length(big)

# The limit also applies to a caller-owned IO sink.
sink = IOBuffer()
err2 = try
HT.get(base; response_stream = sink, max_decompressed_size = 1024)
nothing
catch e
e
end
@test err2 isa HTTP.DecompressionLimitError
finally
HT.forceclose(server)
end
end
Loading