VYPR
High severityNVD Advisory· Published Jun 8, 2026

CVE-2026-43973

CVE-2026-43973

Description

Uncontrolled Resource Consumption vulnerability in ninenines gun (gun_http module) allows a malicious server to exhaust client memory via unbounded HTTP/1.1 response buffering.

In gun_http:handle/5, three clauses accumulate incoming TCP data into the connection's buffer field using binary concatenation with no upper-bound check: the head clause appends data until the \r\n\r\n header terminator is found; the body_chunked clause appends data whenever cow_http_te:stream_chunked/2 returns a more result indicating an incomplete chunk boundary; and the body_trailer clause appends data until the trailing \r\n\r\n is found. In each case, when the expected terminator never arrives, the enlarged binary is stored back into state and the process waits for more data, with no configurable or hard-coded ceiling on buffer size.

A malicious or compromised server can exploit this by sending a partial response that never completes. For example, a response may begin with HTTP/1.1 200 OK\r\nX-Pad: followed by an unbounded stream of arbitrary bytes, never sending the header terminator. The gun connection process will continuously append the incoming data to its buffer, causing unbounded heap growth. Because BEAM imposes no per-process heap limit by default, a single malicious connection can exhaust all available memory on the node, causing a node-wide out-of-memory crash.

This issue affects gun: from 1.0.0 before 2.4.0.

Affected products

3

Patches

1
f3e7e0568b3c

Add max_{header|trailer}_block_size HTTP/1.1 options

https://github.com/ninenines/gunLoïc HoguinJun 4, 2026via body-scan
4 files changed · +115 4
  • doc/src/manual/gun.asciidoc+20 0 modified
    @@ -154,6 +154,8 @@ http_opts() :: #{
         cookie_ignore_informational => boolean(),
         flow                        => pos_integer(),
         keepalive                   => timeout(),
    +    max_header_block_size       => non_neg_integer(),
    +    max_trailer_block_size      => non_neg_integer(),
         transform_header_name       => fun((binary()) -> binary()),
         version                     => 'HTTP/1.1' | 'HTTP/1.0'
     }
    @@ -190,6 +192,22 @@ effort here as servers usually have configurable limits to drop
     idle connections. Disabled by default due to potential
     incompatibilities.
     
    +max_header_block_size (100000)::
    +
    +Maximum size in bytes of the response header block.
    +This is a soft limit that ensures we do not accumulate
    +too much data in memory when receiving HTTP response
    +headers. When the buffer exceeds this limit Gun closes
    +the connection with a `limit_reached` connection error.
    +
    +max_trailer_block_size (10000)::
    +
    +Maximum size in bytes of the response trailer block.
    +This is a soft limit that ensures we do not accumulate
    +too much data in memory when receiving HTTP response
    +headers. When the buffer exceeds this limit Gun closes
    +the connection with a `limit_reached` connection error.
    +
     transform_header_name - see below::
     
     A function that will be applied to all header names before they
    @@ -627,6 +645,8 @@ By default no user option is defined.
     
     == Changelog
     
    +* *2.4*: The `max_header_block_size` and `max_trailer_block_size`
    +         HTTP/1.1 options were added.
     * *2.4*: The `invalid_request_headers` request option was added
              (for both normal requests and Websocket upgrades).
     * *2.2*: The `notify_settings_changed` option for HTTP/2 was
    
  • src/gun.erl+2 0 modified
    @@ -223,6 +223,8 @@
     	cookie_ignore_informational => boolean(),
     	flow => pos_integer(),
     	keepalive => timeout(),
    +	max_header_block_size => non_neg_integer(),
    +	max_trailer_block_size => non_neg_integer(),
     	transform_header_name => fun((binary()) -> binary()),
     	version => 'HTTP/1.1' | 'HTTP/1.0',
     
    
  • src/gun_http.erl+27 4 modified
    @@ -110,6 +110,10 @@ do_check_options([{keepalive, infinity}|Opts]) ->
     	do_check_options(Opts);
     do_check_options([{keepalive, K}|Opts]) when is_integer(K), K > 0 ->
     	do_check_options(Opts);
    +do_check_options([{max_header_block_size, M}|Opts]) when is_integer(M), M > 0 ->
    +	do_check_options(Opts);
    +do_check_options([{max_trailer_block_size, M}|Opts]) when is_integer(M), M > 0 ->
    +	do_check_options(Opts);
     do_check_options([{transform_header_name, F}|Opts]) when is_function(F) ->
     	do_check_options(Opts);
     do_check_options([{version, V}|Opts]) when V =:= 'HTTP/1.1'; V =:= 'HTTP/1.0' ->
    @@ -138,7 +142,7 @@ handle(<<>>, State, CookieStore, _, EvHandlerState) ->
     handle(_, #http_state{streams=[]}, CookieStore, _, EvHandlerState) ->
     	{close, CookieStore, EvHandlerState};
     %% Wait for the full response headers before trying to parse them.
    -handle(Data, State=#http_state{in=head, buffer=Buffer,
    +handle(Data, State=#http_state{opts=Opts, in=head, buffer=Buffer,
     		streams=[#stream{ref=StreamRef, reply_to=ReplyTo}|_]},
     		CookieStore, EvHandler, EvHandlerState0) ->
     	%% Send the event only if there was no data in the buffer.
    @@ -155,7 +159,16 @@ handle(Data, State=#http_state{in=head, buffer=Buffer,
     	Data2 = << Buffer/binary, Data/binary >>,
     	case binary:match(Data2, <<"\r\n\r\n">>) of
     		nomatch ->
    -			{{state, State#http_state{buffer=Data2}}, CookieStore, EvHandlerState};
    +			MaxSize = maps:get(max_header_block_size, Opts, 100000),
    +			case byte_size(Data2) > MaxSize of
    +				true ->
    +					Reason = {connection_error, limit_reached,
    +						"The response header block is too large."},
    +					gun:reply(ReplyTo, {gun_error, self(), Reason}),
    +					{{error, Reason}, CookieStore, EvHandlerState};
    +				false ->
    +					{{state, State#http_state{buffer=Data2}}, CookieStore, EvHandlerState}
    +			end;
     		{_, _} ->
     			handle_head(Data2, State#http_state{buffer= <<>>},
     				CookieStore, EvHandler, EvHandlerState)
    @@ -232,13 +245,23 @@ handle(Data, State=#http_state{in=body_chunked, in_state=InState, buffer=Buffer,
     					{[{state, end_stream(State1)}, close], CookieStore, EvHandlerState}
     			end
     	end;
    -handle(Data, State=#http_state{in=body_trailer, buffer=Buffer, connection=Conn,
    +handle(Data, State=#http_state{opts=Opts, in=body_trailer,
    +		buffer=Buffer, connection=Conn,
     		streams=[#stream{ref=StreamRef, reply_to=ReplyTo}|_]},
     		CookieStore, EvHandler, EvHandlerState0) ->
     	Data2 = << Buffer/binary, Data/binary >>,
     	case binary:match(Data2, <<"\r\n\r\n">>) of
     		nomatch ->
    -			{{state, State#http_state{buffer=Data2}}, CookieStore, EvHandlerState0};
    +			MaxSize = maps:get(max_trailer_block_size, Opts, 10000),
    +			case byte_size(Data2) > MaxSize of
    +				true ->
    +					Reason = {connection_error, limit_reached,
    +						"The response trailer block is too large."},
    +					gun:reply(ReplyTo, {gun_error, self(), Reason}),
    +					{{error, Reason}, CookieStore, EvHandlerState0};
    +				false ->
    +					{{state, State#http_state{buffer=Data2}}, CookieStore, EvHandlerState0}
    +			end;
     		{_, _} ->
     			{Trailers, Rest} = cow_http:parse_headers(Data2),
     			%% @todo We probably want to pass this to gun_content_handler?
    
  • test/gun_SUITE.erl+66 0 modified
    @@ -327,6 +327,72 @@ map_headers(_) ->
     	[<<"user-agent: Gun/map-headers">>] = [L || <<"user-agent: ", _/bits>> = L <- Lines],
     	gun:close(Pid).
     
    +max_header_block_size(_) ->
    +	doc("The max_header_block_size HTTP/1.1 option limits the size of data "
    +	    "accumulated while waiting for the response header block terminator."),
    +	{ok, _, OriginPort} = init_origin(tcp, http,
    +		fun(_, _, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
    +			%% First send a partial response (no \r\n\r\n yet).
    +			ok = ClientTransport:send(ClientSocket,
    +				"HTTP/1.1 200 OK\r\n"
    +				"content-length: 0\r\n"
    +			),
    +			%% Now send a huge chunk that does not contain the terminator.
    +			ok = ClientTransport:send(ClientSocket, [
    +				"x-gun: ",
    +				lists:duplicate(20000, <<"0123456789ABCDEF">>),
    +				"\r\n"
    +			])
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	StreamRef = gun:get(ConnPid, "/"),
    +	%% Receive a limit_reached connection error.
    +	{error, {connection_error, {connection_error, limit_reached, _}}}
    +		= gun:await(ConnPid, StreamRef),
    +	receive
    +		{gun_down, ConnPid, http,
    +				{error, {connection_error, limit_reached, _}}, _} ->
    +			gun:close(ConnPid)
    +	end.
    +
    +max_trailer_block_size(_) ->
    +	doc("The max_trailer_block_size HTTP/1.1 option limits the size of data "
    +	    "accumulated while waiting for the response trailer block terminator."),
    +	{ok, _, OriginPort} = init_origin(tcp, http,
    +		fun(_, _, ClientSocket, ClientTransport) ->
    +			{ok, _} = ClientTransport:recv(ClientSocket, 0, 1000),
    +			ClientTransport:send(ClientSocket, [
    +				"HTTP/1.1 200 OK\r\n"
    +				"transfer-encoding: chunked\r\n"
    +				"trailer: x-gun\r\n"
    +				"\r\n"
    +				"6\r\n"
    +				"hello \r\n"
    +				"6\r\n"
    +				"world!\r\n"
    +				"0\r\n"
    +				"x-gun: ",
    +				lists:duplicate(2000, <<"0123456789ABCDEF">>),
    +				"\r\n\r\n"
    +			])
    +		end),
    +	{ok, ConnPid} = gun:open("localhost", OriginPort),
    +	{ok, http} = gun:await_up(ConnPid),
    +	StreamRef = gun:get(ConnPid, "/"),
    +	%% First receive the start of the response.
    +	{response, nofin, 200, _} = gun:await(ConnPid, StreamRef),
    +	{data, nofin, <<"hello world!">>} = gun:await(ConnPid, StreamRef),
    +	%% Then receive a limit_reached connection error.
    +	{error, {connection_error, {connection_error, limit_reached, _}}}
    +		= gun:await(ConnPid, StreamRef),
    +	receive
    +		{gun_down, ConnPid, http,
    +				{error, {connection_error, limit_reached, _}}, _} ->
    +			gun:close(ConnPid)
    +	end.
    +
     postpone_request_while_not_connected(_) ->
     	doc("Ensure Gun doesn't raise error when requesting in retries"),
     	%% Try connecting to a server that isn't up yet.
    

Vulnerability mechanics

Root cause

"The gun_http module fails to enforce a maximum size for buffered response data, allowing unbounded memory consumption."

Attack vector

A malicious or compromised server can exploit this vulnerability by sending an incomplete HTTP/1.1 response that never provides the expected header or trailer terminators. The gun_http module will continuously buffer incoming data without any upper-bound check. This unbounded buffering leads to heap growth, potentially exhausting all available memory on the node and causing a node-wide out-of-memory crash [ref_id=1].

Affected code

The vulnerability resides in the `handle/5` function within the `src/gun_http.erl` file. Specifically, the clauses handling the `head`, `body_chunked`, and `body_trailer` states accumulate data into the connection's buffer without checking its size against any limit [ref_id=1].

What the fix does

The patch introduces two new options, `max_header_block_size` and `max_trailer_block_size`, to the gun_http module [patch_id=5216885]. These options set a limit on the amount of data that can be accumulated while waiting for response headers or trailers. If the buffer size exceeds these limits, the connection is closed with a `limit_reached` error, preventing unbounded memory growth [ref_id=1].

Preconditions

  • networkThe client must be connected to a server that can be controlled or is malicious.

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.