Unbounded memory consumption in WebSocket client in hackney
Description
Allocation of Resources Without Limits or Throttling vulnerability in benoitc hackney allows Flooding. The WebSocket client in src/hackney_ws.erl imposes no upper bound on memory consumption in three code paths. First, read_handshake_response/3 accumulates received bytes into a growing buffer with no size cap; the per-receive timeout resets on every chunk, so a server that streams bytes without ever sending \r\n\r\n causes the buffer to grow until memory is exhausted. Second, parse_payload/9 and parse_active_payload/8 do not validate the declared frame payload length against any limit; because RFC 6455 allows payload lengths up to 2^63-1 bytes, a server that announces a very large frame and dribbles bytes causes the accumulation buffer to grow until OOM. Third, the frag_buffer field in #ws_data{} accumulates continuation frames indefinitely; a server that sends an endless stream of non-final (nofin) fragmented frames without ever sending a final (fin) frame grows frag_buffer without bound.
In all three cases the attacker only needs to control the WebSocket server the hackney client connects to, with no authentication or special client configuration required.
This issue affects hackney: from 2.0.0 before 4.0.1.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Hackney WebSocket client (2.0.0–4.0.0) has three unbounded buffer paths allowing a malicious server to cause OOM via slow handshake, large frames, or endless fragments.
Vulnerability
The hackney WebSocket client in src/hackney_ws.erl lacks memory limits in three code paths: read_handshake_response/3 accumulates handshake bytes without cap; parse_payload/9 and parse_active_payload/8 do not validate declared frame payload length (up to 2^63-1 per RFC 6455); and frag_buffer in #ws_data{} accumulates continuation frames indefinitely. Affects hackney from 2.0.0 before 4.0.1 [1][2][3][4].
Exploitation
An attacker only needs to control the WebSocket server the client connects to; no authentication or special client configuration is required. The attacker can: (a) stream bytes without sending \r\n\r\n to grow the handshake buffer; (b) announce a large frame payload and dribble bytes slowly; or (c) send an endless stream of non-final fragmented frames without a final frame. The per-receive timeout resets on each chunk, so a slow trickle never triggers a timeout [2][4].
Impact
Successful exploitation causes unbounded memory consumption in the client process, leading to out-of-memory (OOM) termination of the BEAM process or node crash. This is a denial-of-service (flooding) vulnerability with high availability impact (CVSS 8.7) [3][4].
Mitigation
Fixed in hackney 4.0.1, released 2026-05-25, which introduces configurable max_frame_size (default 16 MiB), max_message_size (default 64 MiB), and MAX_HANDSHAKE_RESPONSE_SIZE (64 KiB) [1][4]. Users should upgrade to 4.0.1 or later. No workaround is available for earlier versions.
AI Insight generated on May 25, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1ce0109e2970afix(security): bound WebSocket client buffers (GHSA-q8jg)
2 files changed · +96 −17
src/hackney_ws.erl+80 −17 modified@@ -57,6 +57,13 @@ -define(RECV_TIMEOUT, infinity). -define(CLOSE_TIMEOUT, 5000). +%% GHSA-q8jg: bound attacker-controlled buffers. A single frame's declared +%% length, the cumulative size of a fragmented message, and the handshake +%% response are all capped so a hostile server cannot drive the client to OOM. +-define(DEFAULT_MAX_FRAME_SIZE, 16#1000000). %% 16 MiB per frame +-define(DEFAULT_MAX_MESSAGE_SIZE, 16#4000000). %% 64 MiB per fragmented message +-define(MAX_HANDSHAKE_RESPONSE_SIZE, 65536). %% 64 KiB of upgrade headers + %% WebSocket frame types (for documentation) -type ws_frame() :: {text, binary()} | {binary, binary()} @@ -105,9 +112,14 @@ %% Frame parsing state (for hackney_ws_proto) frag_state = undefined :: term(), frag_buffer = [] :: list(), %% Accumulated fragment payloads + frag_size = 0 :: non_neg_integer(), %% Bytes buffered in frag_buffer utf8_state = 0 :: integer(), extensions = #{} :: map(), + %% GHSA-q8jg: buffer caps (bytes), infinity disables the cap + max_frame_size = ?DEFAULT_MAX_FRAME_SIZE :: non_neg_integer() | infinity, + max_message_size = ?DEFAULT_MAX_MESSAGE_SIZE :: non_neg_integer() | infinity, + %% Pending requests connect_from :: {pid(), reference()} | undefined, recv_from :: {pid(), reference()} | undefined @@ -220,7 +232,9 @@ init([Owner, Opts]) -> proxy = maps:get(proxy, Opts, false), active = maps:get(active, Opts, false), headers = maps:get(headers, Opts, []), - protocols = maps:get(protocols, Opts, []) + protocols = maps:get(protocols, Opts, []), + max_frame_size = maps:get(max_frame_size, Opts, ?DEFAULT_MAX_FRAME_SIZE), + max_message_size = maps:get(max_message_size, Opts, ?DEFAULT_MAX_MESSAGE_SIZE) }, {ok, idle, Data}. @@ -680,6 +694,11 @@ read_handshake_response(Socket, Transport, Buffer) -> HeaderPart = binary:part(Buffer1, 0, Pos), Rest = binary:part(Buffer1, Pos + 4, byte_size(Buffer1) - Pos - 4), parse_handshake_response(HeaderPart, Rest); + nomatch when byte_size(Buffer1) > ?MAX_HANDSHAKE_RESPONSE_SIZE -> + %% GHSA-q8jg: a server that streams bytes without ever + %% sending the CRLFCRLF terminator would otherwise grow + %% this buffer without bound. + {error, handshake_response_too_large}; nomatch -> read_handshake_response(Socket, Transport, Buffer1) end; @@ -787,6 +806,12 @@ do_recv_frame(Buffer, #ws_data{socket = Socket, transport = Transport, end; error -> {error, invalid_frame}; + {_Type, _FragState1, _Rsv, Len, _MaskKey, _Rest} when is_integer(Len), + is_integer(Data#ws_data.max_frame_size), + Len > Data#ws_data.max_frame_size -> + %% GHSA-q8jg: refuse an oversized declared frame length before + %% buffering its payload (a peer may announce up to 2^63-1). + {error, {frame_too_big, Len}}; {Type, FragState1, Rsv, Len, MaskKey, Rest} -> %% Parse payload parse_payload(Type, FragState1, Rsv, Len, MaskKey, Rest, Data, Timeout, Utf8State) @@ -803,37 +828,48 @@ do_recv_frame(Buffer, #ws_data{socket = Socket, transport = Transport, %% {error, Reason} parse_payload(Type, FragState1, Rsv, Len, MaskKey, Buffer, #ws_data{socket = Socket, transport = Transport, - extensions = Exts, frag_buffer = FragBuffer} = Data, + extensions = Exts, frag_buffer = FragBuffer, + frag_size = FragSize} = Data, Timeout, Utf8State) -> %% ParsedLen = 0 for new frames case hackney_ws_proto:parse_payload(Buffer, MaskKey, Utf8State, 0, Type, Len, FragState1, Exts, Rsv) of {ok, CloseCode, Payload, Utf8State1, Rest} when Type =:= close -> %% Close frame with code Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, {close, CloseCode, Payload, Data1}; {ok, Payload, Utf8State1, Rest} -> %% Non-close frame completed case FragState1 of {nofin, _FragType, _Rsv} -> %% This is a fragment, accumulate - Data1 = Data#ws_data{buffer = Rest, frag_state = FragState1, - frag_buffer = [Payload | FragBuffer], - utf8_state = Utf8State1}, - do_recv_frame(Rest, Data1, Timeout); + NewFragSize = FragSize + byte_size(Payload), + case message_within_limit(NewFragSize, Data) of + ok -> + Data1 = Data#ws_data{buffer = Rest, frag_state = FragState1, + frag_buffer = [Payload | FragBuffer], + frag_size = NewFragSize, + utf8_state = Utf8State1}, + do_recv_frame(Rest, Data1, Timeout); + Err -> + Err + end; {fin, FragType, _Rsv} -> %% Final fragment - assemble full message AllPayloads = lists:reverse([Payload | FragBuffer]), FullPayload = iolist_to_binary(AllPayloads), Frame = hackney_ws_proto:make_frame(FragType, FullPayload, undefined, undefined), Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, handle_received_frame(Frame, Data1, Timeout); undefined -> %% Complete non-fragmented frame Frame = hackney_ws_proto:make_frame(Type, Payload, undefined, undefined), Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, handle_received_frame(Frame, Data1, Timeout) end; {more, _PartialPayload, Utf8State1} -> @@ -916,6 +952,11 @@ parse_active_frames(#ws_data{buffer = Buffer, frag_state = FragState, {ok, lists:reverse(Acc), Data}; error -> {error, invalid_frame}; + {_Type, _FragState1, _Rsv, Len, _MaskKey, _Rest} when is_integer(Len), + is_integer(Data#ws_data.max_frame_size), + Len > Data#ws_data.max_frame_size -> + %% GHSA-q8jg: refuse an oversized declared frame length. + {error, {frame_too_big, Len}}; {Type, FragState1, Rsv, Len, MaskKey, Rest} -> case parse_active_payload(Type, FragState1, Rsv, Len, MaskKey, Rest, Data, Utf8State) of {ok, Frame, Data1} -> @@ -937,43 +978,65 @@ parse_active_frames(#ws_data{buffer = Buffer, frag_state = FragState, parse_active_frames(Data1, Acc); more -> {ok, lists:reverse(Acc), Data}; + {error, Reason} -> + {error, Reason}; error -> {error, invalid_payload} end end. +%% @private GHSA-q8jg: bound the cumulative size of a fragmented message so a +%% peer cannot stream unbounded non-final continuation frames. +message_within_limit(_Size, #ws_data{max_message_size = infinity}) -> + ok; +message_within_limit(Size, #ws_data{max_message_size = Max}) when Size > Max -> + {error, {message_too_big, Size}}; +message_within_limit(_Size, _Data) -> + ok. + %% @private Parse payload in active mode (non-blocking) parse_active_payload(Type, FragState1, Rsv, Len, MaskKey, Buffer, - #ws_data{extensions = Exts, frag_buffer = FragBuffer} = Data, + #ws_data{extensions = Exts, frag_buffer = FragBuffer, + frag_size = FragSize} = Data, Utf8State) -> case hackney_ws_proto:parse_payload(Buffer, MaskKey, Utf8State, 0, Type, Len, FragState1, Exts, Rsv) of {ok, CloseCode, Payload, Utf8State1, Rest} when Type =:= close -> %% Close frame with code Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, {ok, {close, CloseCode, Payload}, Data1}; {ok, Payload, Utf8State1, Rest} -> %% Non-close frame completed case FragState1 of {nofin, _FragType, _Rsv} -> %% This is a fragment, accumulate - Data1 = Data#ws_data{buffer = Rest, frag_state = FragState1, - frag_buffer = [Payload | FragBuffer], - utf8_state = Utf8State1}, - {fragment, Data1}; + NewFragSize = FragSize + byte_size(Payload), + case message_within_limit(NewFragSize, Data) of + ok -> + Data1 = Data#ws_data{buffer = Rest, frag_state = FragState1, + frag_buffer = [Payload | FragBuffer], + frag_size = NewFragSize, + utf8_state = Utf8State1}, + {fragment, Data1}; + Err -> + Err + end; {fin, FragType, _Rsv} -> %% Final fragment AllPayloads = lists:reverse([Payload | FragBuffer]), FullPayload = iolist_to_binary(AllPayloads), Frame = hackney_ws_proto:make_frame(FragType, FullPayload, undefined, undefined), Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, {ok, Frame, Data1}; undefined -> %% Complete non-fragmented frame Frame = hackney_ws_proto:make_frame(Type, Payload, undefined, undefined), Data1 = Data#ws_data{buffer = Rest, frag_state = undefined, - frag_buffer = [], utf8_state = Utf8State1}, + frag_buffer = [], frag_size = 0, + utf8_state = Utf8State1}, {ok, Frame, Data1} end; {more, _PartialPayload, _Utf8State1} ->
test/hackney_ws_tests.erl+16 −0 modified@@ -98,6 +98,8 @@ ws_integration_test_() -> fun() -> test_connect_disconnect(WsUrl) end}, {"GHSA-f9vr: CRLF in upgrade header rejected", fun() -> test_handshake_header_injection(Port) end}, + {"GHSA-q8jg: oversized frame rejected", + fun() -> test_max_frame_size(Port) end}, {"Send and receive text message", fun() -> test_text_message(WsUrl) end}, {"Send and receive binary message", @@ -130,6 +132,20 @@ test_handshake_header_injection(Port) -> ?assertEqual({error, invalid_handshake_header}, hackney_ws:connect(Ws, 5000)). +%% GHSA-q8jg: a frame whose payload exceeds the configured max_frame_size +%% must be refused rather than buffered without bound. +test_max_frame_size(Port) -> + {ok, Ws} = hackney_ws:start_link(#{ + host => "localhost", + port => Port, + transport => hackney_tcp, + path => <<"/ws">>, + max_frame_size => 100 + }), + ok = hackney_ws:connect(Ws, 5000), + ok = hackney_ws:send(Ws, {text, <<"large:5000">>}), + ?assertMatch({error, {frame_too_big, _}}, hackney_ws:recv(Ws, 5000)). + test_connect_disconnect(WsUrl) -> {ok, Ws} = hackney:ws_connect(WsUrl), ?assert(is_pid(Ws)),
Vulnerability mechanics
Root cause
"Missing upper bounds on buffer accumulation in three code paths in hackney_ws.erl allows a hostile WebSocket server to exhaust client memory."
Attack vector
An attacker who controls a WebSocket server that the hackney client connects to can trigger memory exhaustion with no authentication or special client configuration [ref_id=2]. The attacker streams bytes without sending the `\r\n\r\n` handshake terminator, announces a very large frame payload (up to 2^63-1 bytes per RFC 6455) and dribbles payload bytes slowly, or sends an endless stream of non-final (nofin) fragmented frames without ever sending a final (fin) frame [ref_id=2]. In all three cases the per-receive timeout resets on each chunk, so a slow trickle never triggers a timeout [ref_id=2].
Affected code
The vulnerability resides in `src/hackney_ws.erl`. Three code paths lack buffer-size limits: `read_handshake_response/3` accumulates handshake bytes with no cap; `parse_payload/9` and `parse_active_payload/8` do not validate the declared frame payload length before buffering; and the `frag_buffer` field of `#ws_data{}` accumulates continuation frames indefinitely [ref_id=2].
What the fix does
The patch introduces three configurable caps: `max_frame_size` (default 16 MiB), `max_message_size` (default 64 MiB, cumulative across fragments), and a fixed 64 KiB handshake response limit [patch_id=2473693]. A new `message_within_limit/2` helper checks cumulative fragment size against `max_message_size` [patch_id=2473693]. Exceeding any cap returns `{error, {frame_too_big, _}}`, `{error, {message_too_big, _}}`, or `{error, handshake_response_too_large}`, aborting the receive before memory exhaustion [patch_id=2473693]. The caps are configurable via `start_link` options, and `infinity` disables them [patch_id=2473693].
Preconditions
- networkThe attacker must control the WebSocket server the hackney client connects to.
- authNo authentication or special client configuration is required.
Generated on May 25, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/benoitc/hackney/commit/ce0109e2970ace6e20ff29bae9d05c3ac22ec6dcmitrepatch
- github.com/benoitc/hackney/security/advisories/GHSA-q8jg-fgj4-fphfmitrevendor-advisoryrelated
- cna.erlef.org/cves/CVE-2026-47073.htmlmitrerelated
- osv.dev/vulnerability/EEF-CVE-2026-47073mitrerelated
News mentions
0No linked articles in our index yet.