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
143 changes: 143 additions & 0 deletions pingora-core/src/apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ pub struct HttpServerOptions {
///
/// Default: `None`
pub h2_idle_timeout: Option<Duration>,

/// Maximum size of HTTP/1 request headers in bytes (including the request line).
///
/// When unset, Pingora's default limit of 1,048,575 bytes (~1 MiB) applies.
pub max_header_size: Option<usize>,

/// Maximum number of HTTP/1 request headers allowed.
///
/// When unset, Pingora's default limit of 256 headers applies.
pub max_headers: Option<usize>,
}

impl HttpServerOptions {
/// Validate that the options are valid.
pub fn validate(&self) -> pingora_error::Result<()> {
crate::protocols::http::v1::common::validate_max_header_size(self.max_header_size)?;
crate::protocols::http::v1::common::validate_max_headers(self.max_headers)?;
Ok(())
}
}

/// Settings persisted across HTTP/1.x keepalive requests on the same downstream connection.
Expand Down Expand Up @@ -267,6 +286,13 @@ where
mut stream: Stream,
shutdown: &ShutdownWatch,
) -> Option<Stream> {
if let Some(opts) = self.server_options() {
if let Err(e) = opts.validate() {
error!("Invalid HttpServerOptions: {e}");
return None;
}
}

let mut h2c = self.server_options().as_ref().map_or(false, |o| o.h2c);
let custom = self
.server_options()
Expand Down Expand Up @@ -356,10 +382,30 @@ where
self.server_options()
.and_then(|opts| opts.keepalive_request_limit),
);
if let Some(opts) = self.server_options() {
if let Err(e) = session.set_max_header_size(opts.max_header_size) {
error!("Failed to set max_header_size: {e}");
return None;
}
if let Err(e) = session.set_max_headers(opts.max_headers) {
error!("Failed to set max_headers: {e}");
return None;
}
}

let mut result = self.process_new_http(session, shutdown).await;
while let Some((stream, persistent_settings)) = result.map(|r| r.consume()) {
let mut session = ServerSession::new_http1(stream);
if let Some(opts) = self.server_options() {
if let Err(e) = session.set_max_header_size(opts.max_header_size) {
error!("Failed to set max_header_size on reused session: {e}");
return None;
}
if let Err(e) = session.set_max_headers(opts.max_headers) {
error!("Failed to set max_headers on reused session: {e}");
return None;
}
}
if let Some(persistent_settings) = persistent_settings {
persistent_settings.apply_to_session(&mut session);
}
Expand Down Expand Up @@ -426,4 +472,101 @@ mod tests {
// Keepalive should still work
assert_eq!(session2.get_keepalive(), Some(30));
}

#[test]
fn test_http_server_options_validation() {
let default_opts = HttpServerOptions::default();
assert!(default_opts.validate().is_ok());

let valid_opts = HttpServerOptions {
max_header_size: Some(8192),
max_headers: Some(64),
..Default::default()
};
assert!(valid_opts.validate().is_ok());

let zero_size_opts = HttpServerOptions {
max_header_size: Some(0),
..Default::default()
};
assert!(zero_size_opts.validate().is_err());

let zero_headers_opts = HttpServerOptions {
max_headers: Some(0),
..Default::default()
};
assert!(zero_headers_opts.validate().is_err());

// Test upper bounds
let max_size_opts = HttpServerOptions {
max_header_size: Some(crate::protocols::http::v1::common::MAX_HEADER_SIZE),
max_headers: Some(crate::protocols::http::v1::common::MAX_HEADERS),
..Default::default()
};
assert!(max_size_opts.validate().is_ok());

let over_max_size_opts = HttpServerOptions {
max_header_size: Some(crate::protocols::http::v1::common::MAX_HEADER_SIZE + 1),
..Default::default()
};
assert!(over_max_size_opts.validate().is_err());

let over_max_headers_opts = HttpServerOptions {
max_headers: Some(crate::protocols::http::v1::common::MAX_HEADERS + 1),
..Default::default()
};
assert!(over_max_headers_opts.validate().is_err());
}

struct MockAppWithInvalidOptions {
opts: HttpServerOptions,
}

#[async_trait]
impl HttpServerApp for MockAppWithInvalidOptions {
async fn process_new_http(
self: &Arc<Self>,
_session: ServerSession,
_shutdown: &ShutdownWatch,
) -> Option<ReusedHttpStream> {
panic!("Should not be called when options are invalid!");
}

fn server_options(&self) -> Option<&HttpServerOptions> {
Some(&self.opts)
}
}

#[tokio::test]
async fn test_fail_closed_activation_with_invalid_server_options() {
let (_tx, shutdown_rx) = tokio::sync::watch::channel(false);

for invalid_opts in [
HttpServerOptions {
max_header_size: Some(0),
..Default::default()
},
HttpServerOptions {
max_headers: Some(0),
..Default::default()
},
HttpServerOptions {
max_header_size: Some(crate::protocols::http::v1::common::MAX_HEADER_SIZE + 1),
..Default::default()
},
HttpServerOptions {
max_headers: Some(crate::protocols::http::v1::common::MAX_HEADERS + 1),
..Default::default()
},
] {
let app = Arc::new(MockAppWithInvalidOptions { opts: invalid_opts });
let mock_io = Builder::new().build();
let stream = Box::new(mock_io);
let res = app.process_new(stream, &shutdown_rx).await;
assert!(
res.is_none(),
"Must fail closed (return None) when server options are invalid"
);
}
}
}
77 changes: 77 additions & 0 deletions pingora-core/src/protocols/http/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,48 @@ impl Session {
}
}

/// Set the maximum size of request headers (in bytes) allowed for HTTP/1.x downstream sessions.
///
/// When set to `None`, Pingora's default limit of 1,048,575 bytes (~1 MiB) applies.
/// Returns an error if `max` is `Some(0)` or exceeds `MAX_HEADER_SIZE`.
/// For non-HTTP/1.x connections (h2, subrequest, custom), validates the bounds and returns `Ok(())`.
pub fn set_max_header_size(&mut self, max: Option<usize>) -> Result<()> {
if let Self::H1(s) = self {
s.set_max_header_size(max)
} else {
crate::protocols::http::v1::common::validate_max_header_size(max)
}
}

/// Return the configured maximum size of HTTP/1 request headers, if set.
pub fn max_header_size(&self) -> Option<usize> {
match self {
Self::H1(s) => s.max_header_size(),
_ => None,
}
}

/// Set the maximum number of request headers allowed for HTTP/1.x downstream sessions.
///
/// When set to `None`, Pingora's default limit of 256 headers applies.
/// Returns an error if `max` is `Some(0)` or exceeds `MAX_HEADERS`.
/// For non-HTTP/1.x connections (h2, subrequest, custom), validates the bounds and returns `Ok(())`.
pub fn set_max_headers(&mut self, max: Option<usize>) -> Result<()> {
if let Self::H1(s) = self {
s.set_max_headers(max)
} else {
crate::protocols::http::v1::common::validate_max_headers(max)
}
}

/// Return the configured maximum number of HTTP/1 request headers, if set.
pub fn max_headers(&self) -> Option<usize> {
match self {
Self::H1(s) => s.max_headers(),
_ => None,
}
}

/// Sets the downstream read timeout. This will trigger if we're unable
/// to read from the stream after `timeout`.
///
Expand Down Expand Up @@ -1281,4 +1323,39 @@ mod tests {
unreachable!("not used by proxy task dispatch test")
}
}

#[tokio::test]
async fn test_server_session_header_limits_bounds() {
use crate::protocols::http::v1::common::{MAX_HEADERS, MAX_HEADER_SIZE};
use tokio_test::io::Builder;

let mock_io = Builder::new().build();
let mut session = Session::new_http1(Box::new(mock_io));

// Valid bounds
assert!(session.set_max_header_size(Some(MAX_HEADER_SIZE)).is_ok());
assert_eq!(session.max_header_size(), Some(MAX_HEADER_SIZE));
assert!(session.set_max_header_size(Some(1)).is_ok());
assert_eq!(session.max_header_size(), Some(1));
assert!(session.set_max_header_size(None).is_ok());
assert_eq!(session.max_header_size(), None);

// Invalid bounds
assert!(session.set_max_header_size(Some(0)).is_err());
assert!(session
.set_max_header_size(Some(MAX_HEADER_SIZE + 1))
.is_err());

// Header count bounds
assert!(session.set_max_headers(Some(MAX_HEADERS)).is_ok());
assert_eq!(session.max_headers(), Some(MAX_HEADERS));
assert!(session.set_max_headers(Some(1)).is_ok());
assert_eq!(session.max_headers(), Some(1));
assert!(session.set_max_headers(None).is_ok());
assert_eq!(session.max_headers(), None);

// Invalid header counts
assert!(session.set_max_headers(Some(0)).is_err());
assert!(session.set_max_headers(Some(MAX_HEADERS + 1)).is_err());
}
}
40 changes: 38 additions & 2 deletions pingora-core/src/protocols/http/v1/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,46 @@ use std::time::Duration;
use super::body::BodyWriter;
use crate::utils::KVRef;

pub(super) const MAX_HEADERS: usize = 256;
pub const MAX_HEADERS: usize = 256;

pub(super) const INIT_HEADER_BUF_SIZE: usize = 4096;
pub(super) const MAX_HEADER_SIZE: usize = 1048575;
pub const MAX_HEADER_SIZE: usize = 1048575;

/// Validate an optional maximum header size.
///
/// Must be non-zero and at most `MAX_HEADER_SIZE`.
pub fn validate_max_header_size(max: Option<usize>) -> Result<()> {
if let Some(s) = max {
if s == 0 {
return Error::e_explain(InvalidHTTPHeader, "max_header_size must be greater than 0");
}
if s > MAX_HEADER_SIZE {
return Error::e_explain(
InvalidHTTPHeader,
format!("max_header_size must be at most {MAX_HEADER_SIZE}"),
);
}
}
Ok(())
}

/// Validate an optional maximum header count.
///
/// Must be non-zero and at most `MAX_HEADERS`.
pub fn validate_max_headers(max: Option<usize>) -> Result<()> {
if let Some(h) = max {
if h == 0 {
return Error::e_explain(InvalidHTTPHeader, "max_headers must be greater than 0");
}
if h > MAX_HEADERS {
return Error::e_explain(
InvalidHTTPHeader,
format!("max_headers must be at most {MAX_HEADERS}"),
);
}
}
Ok(())
}

pub(crate) const BODY_BUF_LIMIT: usize = 1024 * 64;

Expand Down
Loading
Loading