CVE-2026-39804
Description
Allocation of Resources Without Limits or Throttling vulnerability in mtrudel bandit allows unauthenticated remote denial of service via memory exhaustion when WebSocket permessage-deflate compression is enabled.
'Elixir.Bandit.WebSocket.PerMessageDeflate':inflate/2 in lib/bandit/websocket/permessage_deflate.ex calls :zlib.inflate/2 with no output-size cap, then materializes the entire decompressed payload as a single binary via IO.iodata_to_binary/1. The websocket_options.max_frame_size option only bounds the on-the-wire (compressed) frame size, not the decompressed output. A high-ratio compressed frame (e.g. uniform data at ~1024:1 ratio) can stay well under any wire-size limit while forcing GiB-scale heap allocations in the connection process before any application code runs.
An unauthenticated attacker who can open a WebSocket connection can send a single such frame to exhaust the BEAM node's memory and trigger an OOM kill.
This vulnerability requires both Bandit's server-level websocket_options.compress and the per-upgrade compress: true option passed to WebSockAdapter.upgrade/4 to be enabled. Stock Phoenix and LiveView applications are not affected as they default to compress: false.
This issue affects bandit: from 0.5.9 before 1.11.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
banditHex | >= 0.5.8, < 1.11.0 | 1.11.0 |
Affected products
1Patches
14 files changed · +90 −11
lib/bandit.ex+5 −1 modified@@ -202,13 +202,17 @@ defmodule Bandit do * `deflate_options`: A keyword list of options to set on the deflate library when using the per-message deflate extension. A complete list can be found at `t:deflate_options/0`. `window_bits` is currently ignored and left to negotiation. + * `max_inflate_ratio`: The maximum allowable ratio to allow decompression of received WebSocket + messages. Intended to prevent 'inflate bomb' attacks where a tiny deflated messages inflates to + a massive one. Defaults to `25` representing a 25:1 allowable inflation ratio. """ @type websocket_options :: [ {:enabled, boolean()} | {:max_frame_size, pos_integer()} | {:validate_text_frames, boolean()} | {:compress, boolean()} | {:deflate_options, deflate_options()} + | {:max_inflate_ratio, pos_integer()} ] @typedoc """ @@ -245,7 +249,7 @@ defmodule Bandit do @http_keys ~w(compress response_encodings deflate_options zstd_options log_exceptions_with_status_codes log_protocol_errors log_client_closures)a @http_1_keys ~w(enabled max_request_line_length max_header_length max_header_count max_requests clear_process_dict gc_every_n_keepalive_requests log_unknown_messages)a @http_2_keys ~w(enabled max_header_block_size max_requests max_reset_stream_rate sendfile_chunk_size default_local_settings)a - @websocket_keys ~w(enabled max_frame_size validate_text_frames compress deflate_options primitive_ops_module)a + @websocket_keys ~w(enabled max_frame_size validate_text_frames compress deflate_options max_inflate_ratio primitive_ops_module)a @thousand_island_keys ThousandIsland.ServerConfig.__struct__() |> Map.from_struct() |> Map.keys()
lib/bandit/websocket/connection.ex+3 −0 modified@@ -308,6 +308,9 @@ defmodule Bandit.WebSocket.Connection do {:error, :no_compress} -> do_error(1002, "Received unexpected compressed frame (RFC6455§5.2)", socket, connection) + {:error, :too_much_inflation} -> + do_error(1009, "Received compressed frame inflating too much", socket, connection) + {:error, _reason} -> do_error(1007, "Inflation error", socket, connection) end
lib/bandit/websocket/permessage_deflate.ex+46 −10 modified@@ -9,17 +9,19 @@ defmodule Bandit.WebSocket.PerMessageDeflate do server_max_window_bits: 8..15, client_max_window_bits: 8..15, inflate_context: :zlib.zstream(), - deflate_context: :zlib.zstream() + deflate_context: :zlib.zstream(), + max_inflate_ratio: integer() } defstruct server_no_context_takeover: false, client_no_context_takeover: false, server_max_window_bits: 15, client_max_window_bits: 15, inflate_context: nil, - deflate_context: nil + deflate_context: nil, + max_inflate_ratio: nil - @valid_params ~w[server_no_context_takeover client_no_context_takeover server_max_window_bits client_max_window_bits] + @valid_params ~w[server_no_context_takeover client_no_context_takeover server_max_window_bits client_max_window_bits max_inflate_ratio] def negotiate(requested_extensions, opts) do :proplists.get_all_values("permessage-deflate", requested_extensions) @@ -86,6 +88,7 @@ defmodule Bandit.WebSocket.PerMessageDeflate do instance = struct(__MODULE__, params) inflate_context = :zlib.open() :ok = :zlib.inflateInit(inflate_context, fix_bits(-instance.client_max_window_bits)) + deflate_context = :zlib.open() deflate_opts = Keyword.get(opts, :deflate_options, []) @@ -99,7 +102,14 @@ defmodule Bandit.WebSocket.PerMessageDeflate do Keyword.get(deflate_opts, :strategy, :default) ) - %{instance | inflate_context: inflate_context, deflate_context: deflate_context} + max_inflate_ratio = Keyword.get(opts, :max_inflate_ratio, 25) + + %{ + instance + | inflate_context: inflate_context, + deflate_context: deflate_context, + max_inflate_ratio: max_inflate_ratio + } end # https://www.erlang.org/doc/man/zlib.html#deflateInit-6 @@ -109,19 +119,45 @@ defmodule Bandit.WebSocket.PerMessageDeflate do # Note that we pass back the context to the caller even though it is unmodified locally def inflate(data, %__MODULE__{} = context) do - inflated_data = - context.inflate_context - |> :zlib.inflate(<<data::binary, 0x00, 0x00, 0xFF, 0xFF>>) - |> IO.iodata_to_binary() + safe_inflate( + context.inflate_context, + :zlib.safeInflate(context.inflate_context, <<data::binary, 0x00, 0x00, 0xFF, 0xFF>>), + [], + byte_size(data) * context.max_inflate_ratio + ) + |> case do + {:ok, inflated_iodata, inflate_context} -> + if context.client_no_context_takeover, do: :zlib.inflateReset(context.inflate_context) + {:ok, IO.iodata_to_binary(inflated_iodata), %{context | inflate_context: inflate_context}} - if context.client_no_context_takeover, do: :zlib.inflateReset(context.inflate_context) - {:ok, inflated_data, context} + {:error, reason} -> + {:error, reason} + end rescue e -> {:error, "Error encountered #{inspect(e)}"} end def inflate(_data, nil), do: {:error, :no_compress} + defp safe_inflate(inflate_context, {:continue, deflated}, buffer, bytes_remaining) + when bytes_remaining > 0 do + safe_inflate( + inflate_context, + :zlib.safeInflate(inflate_context, <<>>), + [buffer | deflated], + bytes_remaining - IO.iodata_length(deflated) + ) + end + + defp safe_inflate(_inflate_context, {:continue, _deflated}, _buffer, bytes_remaining) + when bytes_remaining <= 0 do + {:error, :too_much_inflation} + end + + defp safe_inflate(inflate_context, {:finished, deflated}, buffer, _bytes_remaining) do + {:ok, [buffer | deflated], inflate_context} + end + def deflate(data, %__MODULE__{} = context) do deflated_data = context.deflate_context
test/bandit/websocket/protocol_test.exs+36 −0 modified@@ -239,6 +239,42 @@ defmodule WebSocketProtocolTest do assert SimpleWebSocketClient.recv_pong_frame(client) == {:ok, "OK"} assert SimpleWebSocketClient.recv_ping_frame(client) == {:ok, "OK"} end + + test "server sends a 1009 on an overly compressed frame", context do + output = + capture_log(fn -> + zstream = :zlib.open() + :ok = :zlib.deflateInit(zstream, :default, :deflated, -15, 8, :default) + + deflated_chunks = + Enum.map( + 1..1_000_000, + fn _ -> :zlib.deflate(zstream, "aaaaaaaaaa", :none) end + ) + + final_flush = :zlib.deflate(zstream, <<>>, :sync) + :zlib.close(zstream) + deflated = IO.iodata_to_binary([deflated_chunks, final_flush]) + trailer_size = byte_size(deflated) - 4 + <<payload::binary-size(trailer_size), 0x00, 0x00, 0xFF, 0xFF>> = deflated + + client = SimpleWebSocketClient.tcp_client(context) + SimpleWebSocketClient.http1_handshake(client, TerminateWebSock, [], true) + SimpleWebSocketClient.send_text_frame(client, payload, 0xC) + + # Get the error that terminate saw, to ensure we're closing for the expected reason + assert_receive {:error, "Received compressed frame inflating too much"}, 500 + + # Validate that the server has started the shutdown handshake from RFC6455§7.1.2 + assert SimpleWebSocketClient.recv_connection_close_frame(client) == {:ok, <<1009::16>>} + + # Verify that the server didn't send any extraneous frames + assert SimpleWebSocketClient.connection_closed_for_reading?(client) + Process.sleep(500) + end) + + assert output =~ "Received compressed frame inflating too much" + end end describe "ping frames" do
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-frh3-6pv6-rc8jghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-39804ghsaADVISORY
- cna.erlef.org/cves/CVE-2026-39804.htmlnvdWEB
- github.com/mtrudel/bandit/commit/8156921a51e684a951221da7bc30a70a022f722envdWEB
- github.com/mtrudel/bandit/security/advisories/GHSA-frh3-6pv6-rc8jnvdWEB
- osv.dev/vulnerability/EEF-CVE-2026-39804nvdWEB
News mentions
0No linked articles in our index yet.