use std::fmt;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ContentLimits {
pub max_request_size: Option<u64>,
pub allowed_content_types: Option<Vec<String>>,
}
impl ContentLimits {
#[must_use]
pub fn has_rules(&self) -> bool {
self.max_request_size.is_some()
|| self
.allowed_content_types
.as_ref()
.is_some_and(|types| !types.is_empty())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContentViolation {
MaxRequestSize,
ContentType,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentBlock {
pub violation: ContentViolation,
pub status: u16,
pub body: &'static str,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentLengthError {
pub value: String,
}
impl fmt::Display for ContentLengthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid content_length value '{}': expected an integer",
self.value
)
}
}
impl std::error::Error for ContentLengthError {}
#[must_use]
pub fn media_type(content_type: &str) -> &str {
match content_type.split_once(';') {
Some((prefix, _)) => prefix,
None => content_type,
}
}
pub fn decide(
limits: &ContentLimits,
content_length: Option<&str>,
content_type: Option<&str>,
) -> Result<Option<ContentBlock>, ContentLengthError> {
if let Some(response) = size_block(limits, content_length)? {
return Ok(Some(response));
}
Ok(content_type_block(limits, content_type))
}
fn size_block(
limits: &ContentLimits,
content_length: Option<&str>,
) -> Result<Option<ContentBlock>, ContentLengthError> {
let Some(max) = limits.max_request_size else {
return Ok(None);
};
let Some(raw) = content_length.filter(|raw| !raw.is_empty()) else {
return Ok(None);
};
let trimmed = raw.trim();
let parsed: i64 = trimmed.parse().map_err(|_| ContentLengthError {
value: raw.to_owned(),
})?;
if parsed <= 0 || u64::try_from(parsed).is_ok_and(|size| size <= max) {
return Ok(None);
}
Ok(Some(ContentBlock {
violation: ContentViolation::MaxRequestSize,
status: 413,
body: "Request too large",
reason: format!("Request size {raw} exceeds limit: {max}"),
}))
}
fn content_type_block(limits: &ContentLimits, content_type: Option<&str>) -> Option<ContentBlock> {
let allowed = limits
.allowed_content_types
.as_ref()
.filter(|types| !types.is_empty())?;
let value = content_type.unwrap_or("");
let media = media_type(value);
if allowed.iter().any(|entry| entry == media) {
return None;
}
Some(ContentBlock {
violation: ContentViolation::ContentType,
status: 415,
body: "Unsupported content type",
reason: format!("Invalid content type: {media}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn json_only(max: u64) -> ContentLimits {
ContentLimits {
max_request_size: Some(max),
allowed_content_types: Some(vec!["application/json".to_owned()]),
}
}
#[test]
fn default_is_inert_and_has_no_rules() {
let limits = ContentLimits::default();
assert!(!limits.has_rules());
assert!(
decide(&limits, Some("999999999999"), Some("text/plain"))
.unwrap()
.is_none()
);
assert!(decide(&limits, None, None).unwrap().is_none());
}
#[test]
fn has_rules_tracks_each_field() {
assert!(!ContentLimits::default().has_rules());
assert!(
ContentLimits {
max_request_size: Some(1),
..ContentLimits::default()
}
.has_rules()
);
assert!(
ContentLimits {
allowed_content_types: Some(vec!["application/json".to_owned()]),
..ContentLimits::default()
}
.has_rules()
);
assert!(
!ContentLimits {
max_request_size: None,
allowed_content_types: Some(Vec::new()),
}
.has_rules()
);
assert!(
decide(
&ContentLimits {
max_request_size: None,
allowed_content_types: Some(Vec::new()),
},
Some("999999999"),
Some("text/plain")
)
.unwrap()
.is_none(),
"an empty allowed list never blocks"
);
}
#[test]
fn size_boundary_is_at_the_limit() {
let limits = ContentLimits {
max_request_size: Some(1024),
..ContentLimits::default()
};
assert!(decide(&limits, Some("1024"), None).unwrap().is_none());
let block = decide(&limits, Some("1025"), None).unwrap().expect("over");
assert_eq!(block.violation, ContentViolation::MaxRequestSize);
assert_eq!(block.status, 413);
assert_eq!(block.body, "Request too large");
assert_eq!(block.reason, "Request size 1025 exceeds limit: 1024");
}
#[test]
fn missing_or_empty_content_length_passes_the_size_check() {
let limits = ContentLimits {
max_request_size: Some(10),
..ContentLimits::default()
};
assert!(decide(&limits, None, None).unwrap().is_none());
assert!(decide(&limits, Some(""), None).unwrap().is_none());
}
#[test]
fn signed_and_padded_content_lengths_mirror_python_int() {
let limits = ContentLimits {
max_request_size: Some(10),
..ContentLimits::default()
};
assert!(decide(&limits, Some(" 10 "), None).unwrap().is_none());
assert!(decide(&limits, Some("-1"), None).unwrap().is_none());
assert!(decide(&limits, Some("+5"), None).unwrap().is_none());
let block = decide(&limits, Some(" 11 "), None).unwrap().expect("over");
assert_eq!(block.reason, "Request size 11 exceeds limit: 10");
}
#[test]
fn junk_content_length_is_an_error_not_a_block() {
let limits = ContentLimits {
max_request_size: Some(10),
..ContentLimits::default()
};
let error = decide(&limits, Some("about-twenty"), None).unwrap_err();
assert_eq!(error.value, "about-twenty");
assert_eq!(
error.to_string(),
"invalid content_length value 'about-twenty': expected an integer"
);
assert!(
decide(&ContentLimits::default(), Some("junk"), None)
.unwrap()
.is_none()
);
}
#[test]
fn content_type_matches_the_exact_media_type_prefix() {
let limits = ContentLimits {
max_request_size: None,
allowed_content_types: Some(vec![
"application/json".to_owned(),
"application/merge-patch+json".to_owned(),
]),
};
assert!(
decide(&limits, None, Some("application/json; charset=utf-8"))
.unwrap()
.is_none()
);
assert!(
decide(&limits, None, Some("application/merge-patch+json"))
.unwrap()
.is_none(),
"a type that merely contains an allowed entry as a substring must not pass"
);
let block = decide(&limits, None, Some("application/json ; charset=utf-8"))
.unwrap()
.expect("padded separator");
assert_eq!(block.reason, "Invalid content type: application/json ");
assert_eq!(block.status, 415);
assert_eq!(block.body, "Unsupported content type");
assert_eq!(block.violation, ContentViolation::ContentType);
assert!(
decide(&limits, None, Some("Application/JSON"))
.unwrap()
.is_some()
);
}
#[test]
fn missing_content_type_is_blocked_by_an_allowed_list() {
let limits = ContentLimits {
max_request_size: None,
allowed_content_types: Some(vec!["application/json".to_owned()]),
};
let block = decide(&limits, None, None).unwrap().expect("no header");
assert_eq!(block.status, 415);
assert_eq!(block.reason, "Invalid content type: ");
let limits = ContentLimits {
max_request_size: None,
allowed_content_types: Some(vec![String::new()]),
};
assert!(decide(&limits, None, None).unwrap().is_none());
}
#[test]
fn size_block_wins_over_the_type_block() {
let limits = json_only(10);
let block = decide(&limits, Some("11"), Some("text/plain"))
.unwrap()
.expect("blocked");
assert_eq!(block.violation, ContentViolation::MaxRequestSize);
assert_eq!(block.status, 413);
let block = decide(&limits, Some("10"), Some("text/plain"))
.unwrap()
.expect("blocked");
assert_eq!(block.violation, ContentViolation::ContentType);
assert_eq!(block.status, 415);
}
#[test]
fn media_type_cuts_at_the_first_separator() {
assert_eq!(
media_type("application/json; charset=utf-8"),
"application/json"
);
assert_eq!(media_type("application/json"), "application/json");
assert_eq!(media_type(""), "");
assert_eq!(media_type(";a"), "");
assert_eq!(media_type("text/plain;;a=b"), "text/plain");
}
}