Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions changelog-h3.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion h3/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
33 changes: 27 additions & 6 deletions h3/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://www.rfc-editor.org/rfc/rfc9114.html#name-header-size-constraints>
pub(crate) const DEFAULT_MAX_FIELD_SECTION_SIZE: u64 = 65_536;

/// Configures the HTTP/3 connection
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
Expand All @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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()
},
}
}
}
38 changes: 36 additions & 2 deletions h3/src/proto/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ impl Iterator for HeaderIter {
impl TryFrom<Vec<HeaderField>> for Header {
type Error = HeaderError;
fn try_from(headers: Vec<HeaderField>) -> Result<Self, Self::Error> {
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;

Expand Down Expand Up @@ -247,7 +250,12 @@ impl TryFrom<Vec<HeaderField>> 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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"
)
}
}
}
}
Expand All @@ -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
Expand Down
33 changes: 33 additions & 0 deletions h3/src/qpack/decoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,41 @@ impl From<TryFromIntError> 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();
Expand Down
111 changes: 111 additions & 0 deletions h3/src/tests/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<qpack::HeaderField> = 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::<Bytes>::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();
Expand Down