Skip to content

HTTP/2 header field values containing CR, LF or NUL are passed to the application unvalidated

Moderate
mtrudel published GHSA-x3gh-xhj4-3vq8 Aug 20, 2026

Package

erlang bandit (Erlang)

Affected versions

<=1.12.4

Patched versions

none yet

Description

Summary

On July 26 you hardened HTTP/1 against header field values containing CR, LF or NUL (#620); the comment in validate_field_value! itself calls this a request-smuggling / response-splitting vector. HTTP/2 never got the same check. A HEADERS block whose field values contain \r, \n or \0 decodes fine (HPACK carries arbitrary octets) and the values land in conn.req_headers exactly as sent. I confirmed this on 1.12.4: a value of aaa\r\nx-injected: yes and another with a NUL in the middle both reached my test plug unchanged over h2c, while the same construct over HTTP/1 dies with a 400 before the app ever runs.

Whether this hurts depends on what the application does with request headers. The common bad case is plain-text audit logging: one request with a line break inside a header value produced a second entry in the log file, correctly formatted, with a timestamp I chose. To anything that reads that file afterward, alerting included, the forged entry is indistinguishable from a real one.

Details

Bandit.HTTP2.Stream.read_headers/1 (lib/bandit/http2/stream.ex:118-139) checks pseudo-header placement and uniqueness, lowercase names, connection-specific headers, the te value and content-length. Nothing in that list looks at field values. The HTTP/1 side does (lib/bandit/http1/socket.ex:189-198). Both #619 and #620, same day, touched only the HTTP/1 path and its tests, so the judgement "these octets are dangerous, reject them" is enforced on one transport and not the other.

RFC 9113 §8.2.1 is unambiguous about which behavior is correct:

A field value MUST NOT contain the zero value (ASCII NUL, 0x00), line feed (ASCII LF, 0x0a), or carriage return (ASCII CR, 0x0d) at any position.

with violating messages to be treated as malformed and a 400 recommended for requests.

There's a sibling gap I'd bundle with this (or file separately, your call): exactly_one_instance_of! runs for :method/:scheme/:path but not :authority, so a duplicate :authority is accepted (first one wins as conn.host), and a host header that disagrees with :authority is accepted too, with both visible to the app. That's the #619 half of the same parity gap.

First: Bandit itself is not a sink. I walked every logging path in lib/bandit that can carry request-derived data: request-line errors (socket.ex:118,124), the trailer warnings (socket.ex:288, stream.ex:247), the unknown-frame log (connection.ex:228), and the logger metadata built in bandit/logger.ex. All of it is fixed strings or inspect, and the metadata only ever carries stream_id, domain and crash_reason. So Bandit's own logs can't be forged through this.

Response splitting isn't reachable either. HTTP/2 response headers are HPACK, and Plug 1.20.3 rejects CR/LF/NUL in put_resp_header and friends anyway (I tried; InvalidHeaderError).

What's left is application sinks: appending header values to a text log, building upstream requests by string concatenation, metric labels. A JSON logger escapes \r\n and gets one harmless line. But the transport-level fact stands on its own: the octets are accepted and delivered, and closing it at the transport removes the whole class regardless of how applications behave.

Topology note: behind an nginx-style front that terminates h2 and proxies HTTP/1, the payload never reaches Bandit (nginx 1.31.3 gave me a 400). Direct h2c/h2 and TCP-passthrough load balancers are exposed, and Bandit's own docs recommend direct exposure for HTTP/2.

The two sink shapes I built and ran, both in deliberately vulnerable demo applications I wrote for this, in case they're useful:

  1. Audit log: a plug appends a request header to an append-only file. Value eve\r\n2026-01-01T00:00:00Z level=info user=admin action=approve_deletion approver=cto status=OK yields a forged second line indistinguishable from a real approval record to any reader of that file.
  2. Naive forwarder: a plug concatenates a caller-supplied header into an upstream HTTP/1 request. The CRLF becomes a real header line on the internal request, which lets a client inject headers the internal tier trusts (in my demo, an internal admin flag).

For a fix, I'd suggest lifting the field-value validation into Bandit.Headers and calling it from both transports, plus a parity test that feeds the same inputs to HTTP/1 and HTTP/2 and expects the same answer. That test would have caught both this and the :authority gap.

PoC

Self-contained; run from the bandit repo root as mix run poc_h2_field_value.exs:

defmodule Probe do
  def init(opts), do: opts

  def call(conn, _opts) do
    Plug.Conn.send_resp(
      conn,
      200,
      inspect(Enum.filter(conn.req_headers, fn {k, _} -> String.starts_with?(k, "x-poc") end))
    )
  end
end

{:ok, _} = Application.ensure_all_started(:bandit)
{:ok, srv} = Bandit.start_link(plug: Probe, scheme: :http, port: 0, startup_log: false)
{:ok, {_, port}} = ThousandIsland.listener_info(srv)

{:ok, sock} = :gen_tcp.connect(~c"localhost", port, [:binary, packet: :raw, active: false])
:ok = :gen_tcp.send(sock, "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n")
:ok = :gen_tcp.send(sock, <<0::24, 4::8, 0::8, 0::1, 0::31>>)

{block, _} =
  HPAX.encode(
    Enum.map(
      [{":method", "GET"}, {":scheme", "http"}, {":path", "/"}, {":authority", "x"},
       {"x-poc-crlf", "aaa\r\nx-injected: yes"}, {"x-poc-nul", <<97, 0, 98>>}],
      fn {k, v} -> {:store, k, v} end
    ),
    HPAX.new(4096)
  )

block = IO.iodata_to_binary(block)
:ok = :gen_tcp.send(sock, [<<byte_size(block)::24, 1::8, 0x05::8, 0::1, 1::31>>, block])

# read frames for ~2s; the response DATA body is:
#   [{"x-poc-crlf", "aaa\r\nx-injected: yes"}, {"x-poc-nul", <<97, 0, 98>>}]
# i.e. CR, LF and NUL all survived into the application. No RST/GOAWAY.

And the HTTP/1 control, same value via obs-fold:

GET / HTTP/1.1\r\nhost: x\r\nx-poc-crlf: aaa\r\n\tx-injected: yes\r\n\r\n

comes back 400 Bad Request with (Bandit.HTTPError) Field value contains invalid characters (RFC9110§5.5) in the log. The application is never reached.

Impact

Unauthenticated, integrity-only (I:L in the vector), on deployments where Bandit terminates HTTP/2 itself. In two deliberately vulnerable demo applications I wrote, the primitive produced forged audit entries with attacker-chosen timestamps and injected an internal-only header onto an upstream request. How much of that applies to a given application depends on how it consumes request headers, which is why I rate this Medium rather than High. The transport-level acceptance itself is unconditional, and one fix at the transport closes all of it.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Neutralization of CRLF Sequences ('CRLF Injection')

The product uses CRLF (carriage return line feeds) as a special element, e.g. to separate lines or records, but it does not neutralize or incorrectly neutralizes CRLF sequences from inputs. Learn more on MITRE.

Improper Output Neutralization for Logs

The product constructs a log message from external input, but it does not neutralize or incorrectly neutralizes special elements when the message is written to a log file. Learn more on MITRE.