Skip to content

Apply safe-by-default H2Options on HTTP/2 handshake#903

Open
pigri wants to merge 1 commit into
cloudflare:mainfrom
pigri:fix/h2-safe-default-options
Open

Apply safe-by-default H2Options on HTTP/2 handshake#903
pigri wants to merge 1 commit into
cloudflare:mainfrom
pigri:fix/h2-safe-default-options

Conversation

@pigri
Copy link
Copy Markdown

@pigri pigri commented Jun 3, 2026

Problem

pingora_core::protocols::http::v2::server::handshake falls back to h2's permissive defaults when the application does not provide H2Options:

let options = options.unwrap_or_default();   // 16 MiB decoded header list, unbounded inbound streams

h2's default MAX_HEADER_LIST_SIZE is 16 MiB and the inbound concurrent-stream count is effectively unbounded (config.remote_max_initiated.unwrap_or(usize::MAX)). A remote HTTP/2 client can exploit this with an HPACK dynamic-table bomb plus a flow-control window stall:

  • insert one dynamic entry a: "", then emit many one-byte 0xbe indexed references — each wire byte decodes to one header that h2 accounts as name + value + 32 = 33 bytes;
  • open many streams on one connection;
  • advertise SETTINGS_INITIAL_WINDOW_SIZE=0 and drip WINDOW_UPDATE frames to keep the inflated request state resident.

The result is a memory-exhaustion DoS against any Pingora HTTP/2 listener that does not explicitly set app.h2_options.

Public write-up and PoC exploit: https://github.com/califio/publications/tree/main/MADBugs/http2-bomb/pingora

Fix

Route the None case through a new safe_h2_options() that bounds the decoded header-list size, concurrent inbound streams, and pending accept reset streams. Applications that pass their own H2Options are unaffected and override every value.

pub fn safe_h2_options() -> H2Options {
    let mut options = H2Options::default();
    options.max_header_list_size(64 * 1024);          // vs 16 MiB
    options.max_concurrent_streams(100);              // vs unbounded
    options.max_pending_accept_reset_streams(32);
    options
}
let options = options.unwrap_or_else(safe_h2_options);

max_header_list_size is the lever that defeats the bomb: h2 refuses a decoded header list above the cap before request state is allocated.

Evidence

Minimal Pingora h2c server, no H2Options set, attacked with the published hpack_bomb.py (64 streams × 32,000 headers + window stall), peak process RSS:

pingora-core Peak RSS Requests reaching the app
current (unwrap_or_default) 148.1 MiB (OOMs at scale) all streams accepted (headers=31996)
this change (safe_h2_options) 7.0 MiB 0h2 refuses the inflated header list

Setting H2Options explicitly at the application layer already mitigates this; this change just makes the default safe so an unconfigured listener is not trivially exhausted.

Notes

  • The per-session response write_timeout default is intentionally left unchanged: with the header-list and stream caps in place the parked footprint is bounded regardless of how long a client stalls the window, so a finite write timeout is defense-in-depth rather than required here.
  • cargo build -p pingora-core passes with the change.
  • DCO: commit is Signed-off-by.

h2's defaults allow a 16 MiB decoded header list and an effectively
unbounded number of concurrent inbound streams. An HTTP/2 listener that
does not set `H2Options` can therefore be exhausted by an HPACK
dynamic-table bomb combined with a flow-control window stall, a
memory-exhaustion denial of service.

Route the `None` case in `handshake` through a new `safe_h2_options()`
that bounds the decoded header-list size (64 KiB), concurrent inbound
streams (100), and pending accept reset streams (32). Callers that pass
their own `H2Options` keep full control and override every value.

Signed-off-by: David Papp <pigri@users.noreply.github.com>
@andrewhavck
Copy link
Copy Markdown
Collaborator

Thanks for your submission, we are working on this issue internally.

@pigri
Copy link
Copy Markdown
Author

pigri commented Jun 3, 2026

I understand I will close this pull request once your fix is implemented, if that's acceptable to you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants