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 , {
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 ()}.
313322await_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 ().
508538build_request_headers (Method , Host , Path , Headers ) ->
0 commit comments