From 7ab9d00fb6d1b1a33aeccea59285de2932f90310 Mon Sep 17 00:00:00 2001 From: Ryan McWhorter Date: Wed, 21 Oct 2026 19:46:16 -0700 Subject: [PATCH] Mitigate QPACK header amplification ("Header Accordions") attacks - Default max_field_section_size is now 64 KiB (65536 bytes), enforced incrementally while decoding; oversized requests are answered with HTTP 431 and fail with StreamError::HeaderTooBig. The RFC 9114 section 7.2.4.1 "unlimited" default is retained for parsing the peer's advertised setting. - Header conversion uses HeaderMap::try_with_capacity/try_append and returns the new HeaderError::ExcessiveHeaders instead of panicking when HeaderMap's internal capacity is exceeded, reported as an H3_MESSAGE_ERROR stream error. - Bump http dependency requirement to 1.1 for try_append/try_with_capacity. --- changelog-h3.md | 19 +++++++ h3/Cargo.toml | 2 +- h3/src/config.rs | 33 +++++++++--- h3/src/proto/headers.rs | 38 +++++++++++++- h3/src/qpack/decoder.rs | 33 ++++++++++++ h3/src/tests/request.rs | 111 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 9 deletions(-) diff --git a/changelog-h3.md b/changelog-h3.md index a91c3c9e..3a74efb2 100644 --- a/changelog-h3.md +++ b/changelog-h3.md @@ -1,3 +1,22 @@ +### Unreleased + +* **Security**: mitigate QPACK header amplification attacks on HTTP/3 servers, + where a small encoded header block expands into an oversized decoded header + section (see ["Header Accordions"](https://foxio.io/blog/header-accordions-qpack-amplification-attacks-against-http-3-servers)): + * `max_field_section_size` now defaults to 64 KiB (`65536` bytes) instead of + unlimited. Decoded header sections are rejected incrementally while + decoding, requests exceeding the limit are answered with HTTP 431 and fail + with `StreamError::HeaderTooBig`. Use `client::Builder::max_field_section_size()` + or `server::Builder::max_field_section_size()` to configure it. Per RFC 9114 + section 7.2.4.1, the default remains unlimited only when *parsing the peer's* + advertised setting. + * Converting a decoded header section into a `Header` no longer panics when it + exhausts `http::HeaderMap`'s internal capacity (32768 entries); it returns the + new `HeaderError::ExcessiveHeaders` variant instead, which is reported as a + stream error of type `H3_MESSAGE_ERROR` rather than crashing the connection. + This uses `HeaderMap::try_append`, which requires `http >= 1.1`; the minimum + version of the `http` dependency has been raised accordingly. + ### v0.0.8 (2025-05-06) * fix integer overflow when parsing qpack prefixed integers * introduce new user facing error types diff --git a/h3/Cargo.toml b/h3/Cargo.toml index c6767a41..a6c6a424 100644 --- a/h3/Cargo.toml +++ b/h3/Cargo.toml @@ -26,7 +26,7 @@ tracing = ["dep:tracing"] [dependencies] bytes = "1" futures-util = { version = "0.3", default-features = false, features = ["io"] } -http = "1" +http = "1.1" tokio = { version = "1", features = ["sync"] } pin-project-lite = { version = "0.2", default-features = false } tracing = {version = "0.1.40", optional = true} diff --git a/h3/src/config.rs b/h3/src/config.rs index c2a6fe64..97515982 100644 --- a/h3/src/config.rs +++ b/h3/src/config.rs @@ -2,6 +2,16 @@ use std::convert::TryFrom; use crate::proto::{frame, varint::VarInt}; +/// The default limit, in bytes, on the decoded size of a header section +/// imposed by [`Config::default()`], as defined by the "Header Size +/// Constraints" section of the specification: each field contributes its +/// name and value lengths plus an overhead of 32 bytes. A finite limit +/// protects against amplification attacks where a small QPACK-encoded +/// block expands into a very large decoded section. +/// +/// See: +pub(crate) const DEFAULT_MAX_FIELD_SECTION_SIZE: u64 = 65_536; + /// Configures the HTTP/3 connection #[derive(Debug, Clone, Copy)] #[non_exhaustive] @@ -22,11 +32,14 @@ pub struct Config { /// HTTP/3 Settings #[derive(Debug, Clone, Copy)] pub struct Settings { - /// The MAX_FIELD_SECTION_SIZE in HTTP/3 refers to the maximum size of the dynamic table used in HPACK compression. - /// HPACK is the compression algorithm used in HTTP/3 to reduce the size of the header fields in HTTP requests and responses. - - /// In HTTP/3, the MAX_FIELD_SECTION_SIZE is set to 12. - /// This means that the dynamic table used for HPACK compression can have a maximum size of 2^12 bytes, which is 4KB. + /// The maximum size of the header section (calculated as the uncompressed + /// size of the fields, including the length of the name and value in bytes + /// plus an overhead of 32 bytes for each field) that this endpoint is + /// willing to accept on an individual HTTP message. + /// + /// See [header size constraints] section of the specification for details. + /// + /// [header size constraints]: https://www.rfc-editor.org/rfc/rfc9114.html#name-header-size-constraints pub(crate) max_field_section_size: u64, /// https://datatracker.ietf.org/doc/html/draft-ietf-webtrans-http3/#section-3.1 @@ -47,6 +60,8 @@ impl From<&frame::Settings> for Settings { fn from(settings: &frame::Settings) -> Self { let defaults: Self = Default::default(); Self { + // SETTINGS_MAX_FIELD_SECTION_SIZE's default value is unlimited, + // as defined in RFC 9114 section 7.2.4.1 max_field_section_size: settings .get(frame::SettingId::MAX_HEADER_LIST_SIZE) .unwrap_or(defaults.max_field_section_size), @@ -173,7 +188,13 @@ impl Default for Config { send_grease: true, #[cfg(test)] send_settings: true, - settings: Default::default(), + settings: Settings { + //= https://www.rfc-editor.org/rfc/rfc9114#section-4.2.2 + //# An HTTP/3 implementation MAY impose a limit on the maximum size of + //# the message header it will accept on an individual HTTP message. + max_field_section_size: DEFAULT_MAX_FIELD_SECTION_SIZE, + ..Settings::default() + }, } } } diff --git a/h3/src/proto/headers.rs b/h3/src/proto/headers.rs index d84ed0ed..3558c3c8 100644 --- a/h3/src/proto/headers.rs +++ b/h3/src/proto/headers.rs @@ -204,7 +204,10 @@ impl Iterator for HeaderIter { impl TryFrom> for Header { type Error = HeaderError; fn try_from(headers: Vec) -> Result { - let mut fields = HeaderMap::with_capacity(headers.len()); + // `HeaderMap::with_capacity` panics when the requested capacity exceeds + // the map's internal limit; fall back to an empty map and let + // `try_append` reject oversized header sections with an error instead. + let mut fields = HeaderMap::try_with_capacity(headers.len()).unwrap_or_default(); let mut pseudo = Pseudo::default(); let mut regular_field_seen = false; @@ -247,7 +250,12 @@ impl TryFrom> for Header { } Field::Header((n, v)) => { regular_field_seen = true; - fields.append(n, v); + // `HeaderMap::append` panics when the map has reached its + // internal limit; surface it as an error so an oversized + // header section cannot take down the connection. + fields + .try_append(n, v) + .map_err(|_| HeaderError::ExcessiveHeaders)?; } Field::Protocol(p) => { pseudo.protocol = Some(p); @@ -466,6 +474,10 @@ pub enum HeaderError { MissingAuthority, ContradictedAuthority, PseudoAfterRegularField, + /// The decoded header section contains more fields than the underlying + /// `HeaderMap` is able to represent. Only reachable when the configured + /// `max_field_section_size` allows more fields than that limit. + ExcessiveHeaders, } impl HeaderError { @@ -509,6 +521,12 @@ impl fmt::Display for HeaderError { "pseudo-header field appears after a regular header field" ) } + HeaderError::ExcessiveHeaders => { + write!( + f, + "the number of header fields exceeds the maximum supported" + ) + } } } } @@ -518,6 +536,22 @@ mod tests { use super::*; use assert_matches::assert_matches; + #[test] + fn oversized_qpack_field_section_returns_error() { + // `http::HeaderMap` can hold at most `1 << 15` fields (see + // `try_with_capacity` in the http crate): one field per slot + // must fail with `ExcessiveHeaders`, not panic. + let fields = (0..(1 << 15)).map(|i| HeaderField::from((format!("x-{i}"), ""))); + let mut encoded = bytes::BytesMut::new(); + crate::qpack::encode_stateless(&mut encoded, fields).unwrap(); + let decoded = crate::qpack::decode_stateless(&mut encoded, u64::MAX).unwrap(); + + assert_matches!( + Header::try_from(decoded.fields), + Err(HeaderError::ExcessiveHeaders) + ); + } + #[test] fn request_has_no_authority_nor_host() { //= https://www.rfc-editor.org/rfc/rfc9114#section-4.3.1 diff --git a/h3/src/qpack/decoder.rs b/h3/src/qpack/decoder.rs index 15cf68d3..4846af75 100644 --- a/h3/src/qpack/decoder.rs +++ b/h3/src/qpack/decoder.rs @@ -343,8 +343,41 @@ impl From for DecoderError { #[cfg(test)] mod tests { use super::*; + use crate::config::DEFAULT_MAX_FIELD_SECTION_SIZE; use crate::qpack::tests::helpers::{build_table_with_size, TABLE_SIZE}; + #[test] + fn default_limit_rejects_header_amplification_incrementally() { + // One wire byte referencing static entry 58 + // (`strict-transport-security: ...; preload`) charges its + // full `mem_size()` against the decoding budget. + let amplified = Indexed::Static(58); + let mem_size = StaticTable::get(58).unwrap().mem_size() as u64; + // Twice what's needed to exceed the default limit, so + // decoding must stop before the end of the block. + let refs = 2 * (DEFAULT_MAX_FIELD_SECTION_SIZE / mem_size + 1); + + // A stateless block: empty prefix, then only indexed references. + let mut encoded = bytes::BytesMut::new(); + HeaderPrefix::new(0, 0, 0, 0).encode(&mut encoded); + for _ in 0..refs { + amplified.encode(&mut encoded); + } + + let max_size = crate::config::Config::default() + .settings + .max_field_section_size; + + assert!(matches!( + decode_stateless(&mut encoded, max_size), + Err(DecoderError::HeaderTooLong(_)) + )); + assert!( + encoded.has_remaining(), + "must stop before decoding all fields" + ); + } + #[test] fn test_header_too_long() { let mut trailers = http::HeaderMap::new(); diff --git a/h3/src/tests/request.rs b/h3/src/tests/request.rs index 158db7b5..a7e0b88d 100644 --- a/h3/src/tests/request.rs +++ b/h3/src/tests/request.rs @@ -26,6 +26,117 @@ use crate::{ use super::h3_quinn; use super::{init_tracing, Pair}; +#[tokio::test] +async fn default_limit_rejects_oversized_request_without_closing_connection() { + //= https://www.rfc-editor.org/rfc/rfc9114#section-4.2.2 + //= type=test + //# An HTTP/3 implementation MAY impose a limit on the maximum size of + //# the message header it will accept on an individual HTTP message. + reject_oversized_request(false).await; +} + +#[tokio::test] +async fn large_limit_rejects_header_map_overflow_without_closing_connection() { + //= https://www.rfc-editor.org/rfc/rfc9114#section-4.2.2 + //= type=test + //# An HTTP/3 implementation MAY impose a limit on the maximum size of + //# the message header it will accept on an individual HTTP message. + reject_oversized_request(true).await; +} + +async fn reject_oversized_request(large_limit: bool) { + let mut pair = Pair::default(); + let mut server = pair.server(); + let (rejected_tx, rejected_rx) = tokio::sync::oneshot::channel(); + + let client_fut = async { + let connection = pair.client_inner().await; + let (mut driver, mut client) = client::new(h3_quinn::Connection::new(connection.clone())) + .await + .unwrap(); + let drive_fut = async { future::poll_fn(|cx| driver.poll_close(cx)).await }; + let request_fut = async move { + // Bypass the client's outgoing header limit and HeaderMap to send + // an oversized, otherwise valid request over a real QUIC stream. + let mut fields: Vec = Header::request( + http::Method::GET, + "https://localhost/".parse().unwrap(), + HeaderMap::new(), + http::Extensions::new(), + ) + .unwrap() + .into_iter() + .collect(); + fields.extend((0..32_768).map(|i| qpack::HeaderField::from((format!("x-{i}"), "")))); + let mut encoded = BytesMut::new(); + qpack::encode_stateless(&mut encoded, fields).unwrap(); + let mut frame = BytesMut::new(); + Frame::::Headers(encoded.freeze()).encode_with_payload(&mut frame); + let (mut send, _recv) = connection.open_bi().await.unwrap(); + send.write_all(&frame).await.unwrap(); + send.finish().unwrap(); + rejected_rx.await.unwrap(); + + let mut stream = client + .send_request(Request::get("https://localhost/").body(()).unwrap()) + .await + .unwrap(); + stream.finish().await.unwrap(); + assert_eq!( + stream.recv_response().await.unwrap().status(), + StatusCode::OK + ); + }; + tokio::join!(request_fut, drive_fut); + }; + + let server_fut = async { + let mut builder = server::builder(); + if large_limit { + builder.max_field_section_size(4 * 1024 * 1024); + } + let mut incoming = builder.build(server.next().await).await.unwrap(); + let error = incoming + .accept() + .await + .unwrap() + .unwrap() + .resolve_request() + .await + .err() + .expect("oversized request must be rejected"); + if large_limit { + assert_matches!( + error, + StreamError::StreamError { + code: Code::H3_MESSAGE_ERROR, + .. + } + ); + } else { + assert_matches!( + error, + StreamError::HeaderTooBig { + max_size: 65_536, + .. + } + ); + } + rejected_tx.send(()).unwrap(); + + let (_, mut stream) = get_stream_blocking(&mut incoming).await.unwrap(); + stream.send_response(Response::new(())).await.unwrap(); + stream.finish().await.unwrap(); + let _ = incoming.accept().await; + }; + + tokio::time::timeout(Duration::from_secs(10), async { + tokio::join!(server_fut, client_fut); + }) + .await + .expect("oversized request test timed out"); +} + #[tokio::test] async fn get() { init_tracing();