Skip to content

Commit 3d25f9f

Browse files
committed
fix(security): bound HTTP/3 response buffering (GHSA-jq4m)
await_response_loop reset its per-chunk timeout on every received message and appended each chunk to the body with no cap, so a peer that dribbled a small chunk just before each deadline kept the loop alive forever while the process heap grew until OOM. The timeout is now an absolute wall-clock deadline for the whole response (mirroring wait_connected/3), and the buffered body is capped at max_body_size (512 MiB default, configurable, infinity to disable), aborting with {error, body_too_large}.
1 parent ce0109e commit 3d25f9f

2 files changed

Lines changed: 85 additions & 17 deletions

File tree

src/hackney_h3.erl

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666

6767
-ifdef(TEST).
6868
-export([maybe_strip_redirect_headers/4]).
69+
-export([body_within_limit/2, remaining/1]).
6970
-endif.
7071

7172
-record(state, {
@@ -77,6 +78,13 @@
7778

7879
-define(CONN_TABLE, hackney_h3_conns).
7980

81+
%% GHSA-jq4m: default ceiling on a buffered HTTP/3 response body. The
82+
%% non-streaming await path holds the whole body in memory, so without a cap
83+
%% a peer that trickles data forever drives the node to OOM. Configurable via
84+
%% the `max_body_size' option (`infinity' disables it); very large downloads
85+
%% should use the streaming API instead.
86+
-define(DEFAULT_MAX_BODY_SIZE, 16#20000000). %% 512 MiB
87+
8088
-type h3_conn() :: reference().
8189
-type stream_id() :: non_neg_integer().
8290
-type method() :: get | post | put | delete | head | options | patch | atom() | binary().
@@ -141,10 +149,11 @@ do_request_with_redirect(Method, Url, Headers, Body, Opts, FollowRedirect, MaxRe
141149
_ -> <<PathBin/binary, "?", Qs/binary>>
142150
end,
143151
Timeout = maps:get(timeout, Opts, 30000),
152+
MaxBodySize = maps:get(max_body_size, Opts, ?DEFAULT_MAX_BODY_SIZE),
144153
case connect(HostBin, Port, Opts) of
145154
{ok, Conn} ->
146155
try
147-
case do_request(Conn, Method, HostBin, FullPath, Headers, Body, Timeout) of
156+
case do_request(Conn, Method, HostBin, FullPath, Headers, Body, Timeout, MaxBodySize) of
148157
{ok, Status, RespHeaders, RespBody} when Status >= 301, Status =< 308 ->
149158
%% Redirect response
150159
handle_redirect(Status, RespHeaders, RespBody, Method, Url, Headers, Body, Opts,
@@ -311,7 +320,7 @@ connect(Host, Port, Opts) when is_binary(Host) ->
311320
-spec await_response(reference(), non_neg_integer()) ->
312321
{ok, integer(), headers(), binary()} | {error, term()}.
313322
await_response(ConnRef, StreamId) ->
314-
await_response_loop(ConnRef, StreamId, 30000, undefined, [], <<>>).
323+
await_response_loop(ConnRef, StreamId, 30000, ?DEFAULT_MAX_BODY_SIZE).
315324

316325
%%====================================================================
317326
%% Request operations (used by hackney_conn)
@@ -444,33 +453,41 @@ wait_connected(ConnRef, Timeout, StartTime) ->
444453
{error, timeout}
445454
end.
446455

447-
do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout) ->
456+
do_request(ConnRef, Method, Host, Path, Headers, Body, Timeout, MaxBodySize) ->
448457
AllHeaders = build_request_headers(Method, Host, Path, Headers),
449458
HasBody = Body =/= <<>> andalso Body =/= [],
450459
Fin = not HasBody,
451460
case send_request(ConnRef, AllHeaders, Fin) of
452461
{ok, StreamId} when HasBody ->
453462
case send_data(ConnRef, StreamId, Body, true) of
454463
ok ->
455-
await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>);
464+
await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize);
456465
{error, _} = Error ->
457466
Error
458467
end;
459468
{ok, StreamId} ->
460-
await_response_loop(ConnRef, StreamId, Timeout, undefined, [], <<>>);
469+
await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize);
461470
{error, _} = Error ->
462471
Error
463472
end.
464473

465474
%% @private Wait for HTTP/3 response.
466-
%% Timeout is per-chunk - resets each time data is received.
467-
%% This allows large responses to complete as long as data keeps flowing.
468-
%% Note: For very large responses, use hackney_conn with streaming mode instead.
469-
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody) ->
475+
%% GHSA-jq4m: `Timeout' is an absolute wall-clock deadline for the whole
476+
%% response, not a per-chunk timer; a peer that dribbles a byte just before
477+
%% each chunk deadline used to reset it forever. The accumulated body is also
478+
%% capped at MaxBodySize so a slow- or fast-drip cannot exhaust memory.
479+
await_response_loop(ConnRef, StreamId, Timeout, MaxBodySize) ->
480+
Deadline = case Timeout of
481+
infinity -> infinity;
482+
_ -> erlang:monotonic_time(millisecond) + Timeout
483+
end,
484+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, undefined, [], <<>>).
485+
486+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody) ->
470487
receive
471488
{select, _Resource, _Ref, ready_input} ->
472489
_ = process(ConnRef),
473-
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody);
490+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody);
474491
{h3, ConnRef, {stream_headers, StreamId, RespHeaders, Fin}} ->
475492
NewStatus = get_status(RespHeaders),
476493
FilteredHeaders = filter_pseudo_headers(RespHeaders),
@@ -479,30 +496,43 @@ await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody) ->
479496
%% Headers with Fin=true means no body (e.g., HEAD response, 204, 304)
480497
{ok, NewStatus, FilteredHeaders, AccBody};
481498
false ->
482-
await_response_loop(ConnRef, StreamId, Timeout, NewStatus, FilteredHeaders, AccBody)
499+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, NewStatus, FilteredHeaders, AccBody)
483500
end;
484501
{h3, ConnRef, {stream_data, StreamId, Data, Fin}} ->
485502
NewBody = <<AccBody/binary, Data/binary>>,
486-
case Fin of
487-
true ->
488-
{ok, Status, Headers, NewBody};
503+
case body_within_limit(byte_size(NewBody), MaxBodySize) of
489504
false ->
490-
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, NewBody)
505+
{error, body_too_large};
506+
true ->
507+
case Fin of
508+
true ->
509+
{ok, Status, Headers, NewBody};
510+
false ->
511+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, NewBody)
512+
end
491513
end;
492514
{h3, ConnRef, {stream_reset, StreamId, _ErrorCode}} ->
493515
{error, stream_reset};
494516
{h3, ConnRef, {closed, Reason}} ->
495517
{error, {connection_closed, Reason}};
496518
{h3, ConnRef, {settings, _Settings}} ->
497519
%% HTTP/3 SETTINGS frame - ignore and continue waiting
498-
await_response_loop(ConnRef, StreamId, Timeout, Status, Headers, AccBody);
520+
await_response_loop(ConnRef, StreamId, Deadline, MaxBodySize, Status, Headers, AccBody);
499521
{h3, ConnRef, {goaway, _StreamId2}} ->
500522
%% GOAWAY frame - connection is shutting down
501523
{error, goaway}
502-
after Timeout ->
524+
after remaining(Deadline) ->
503525
{error, timeout}
504526
end.
505527

528+
%% @private Milliseconds left until the absolute deadline.
529+
remaining(infinity) -> infinity;
530+
remaining(Deadline) -> max(0, Deadline - erlang:monotonic_time(millisecond)).
531+
532+
%% @private GHSA-jq4m: bound the buffered body size.
533+
body_within_limit(_Size, infinity) -> true;
534+
body_within_limit(Size, Max) -> Size =< Max.
535+
506536
%% @private Build HTTP/3 request headers including pseudo-headers.
507537
-spec build_request_headers(method(), binary(), binary(), headers()) -> headers().
508538
build_request_headers(Method, Host, Path, Headers) ->

test/hackney_h3_redirect_tests.erl

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,44 @@ cross_origin_header_strip_test_() ->
238238
end}
239239
].
240240

241+
%%====================================================================
242+
%% GHSA-jq4m: response body cap + wall-clock deadline
243+
%%====================================================================
244+
245+
body_cap_test_() ->
246+
[
247+
{"accepts body at or under the cap",
248+
fun() ->
249+
?assert(hackney_h3:body_within_limit(0, 100)),
250+
?assert(hackney_h3:body_within_limit(100, 100))
251+
end},
252+
{"rejects body over the cap",
253+
fun() ->
254+
?assertNot(hackney_h3:body_within_limit(101, 100))
255+
end},
256+
{"infinity disables the cap",
257+
fun() ->
258+
?assert(hackney_h3:body_within_limit(1 bsl 40, infinity))
259+
end}
260+
].
261+
262+
deadline_test_() ->
263+
[
264+
{"infinity stays infinity",
265+
fun() -> ?assertEqual(infinity, hackney_h3:remaining(infinity)) end},
266+
{"past deadline clamps to zero",
267+
fun() ->
268+
Past = erlang:monotonic_time(millisecond) - 1000,
269+
?assertEqual(0, hackney_h3:remaining(Past))
270+
end},
271+
{"future deadline returns a positive remaining",
272+
fun() ->
273+
Future = erlang:monotonic_time(millisecond) + 5000,
274+
R = hackney_h3:remaining(Future),
275+
?assert(R > 0 andalso R =< 5000)
276+
end}
277+
].
278+
241279
%%====================================================================
242280
%% TLS Option Tests
243281
%%====================================================================

0 commit comments

Comments
 (0)