guard_core_engine/request_limits.rs
1//! The request size and content-type gate: `max_request_size` and
2//! `allowed_content_types`.
3//!
4//! This is the Rust family's port of the reference engine's
5//! `request_size_content` check
6//! (`guard_core/core/checks/implementations/request_size_content.py`). One
7//! call, [`decide`], carries a request's size and content-type header values
8//! through the reference's two sub-checks in its order:
9//!
10//! ```text
11//! no max_request_size: skip the size check
12//! no content-length header: pass the size check
13//! content-length <= max_request_size: pass
14//! content-length > max_request_size: 413 "Request too large"
15//! no allowed_content_types: skip the type check
16//! media type (before ";") in the allowed list: pass
17//! anything else (a missing header included): 415 "Unsupported content type"
18//! ```
19//!
20//! Both limits are route-scoped in the reference (`RouteConfig.max_request_size`,
21//! `RouteConfig.allowed_content_types`); there is no global knob, so the
22//! config value is `Option`-shaped on both fields and the all-`None` default
23//! is inert: [`decide`] passes everything.
24//!
25//! ## Details that mirror the reference exactly
26//!
27//! - The size comparison is `int(content_length) <= max_request_size`: a
28//! request at exactly the limit passes, one byte over is blocked, and a
29//! missing or empty `content-length` header passes (`if not content_length`
30//! in the reference). A content-length that is not an integer raises
31//! `ValueError` in the reference, which the pipeline answers with the
32//! fail-secure `500 "Security check failed"`; [`decide`] mirrors that with
33//! an [`Err`] return and a negative content-length passes (`-1 <= limit`),
34//! like Python's signed `int()`.
35//! - The media type is `content_type.split(";")[0]` with no trim: the exact
36//! prefix before the first `;` is compared case-sensitively against the
37//! configured list, so `application/json; charset=utf-8` matches
38//! `application/json` and `application/json ; charset=utf-8` does not
39//! (trailing space), exactly as in Python. A missing `content-type`
40//! header compares as the empty string and is blocked by any non-empty
41//! allowed list.
42//! - The size check runs first and wins: a request over the size limit with
43//! a disallowed content type is answered `413`, never `415`.
44//!
45//! # Example
46//!
47//! ```
48//! use guard_core_engine::request_limits::{decide, ContentLimits};
49//!
50//! let limits = ContentLimits {
51//! max_request_size: Some(1024),
52//! allowed_content_types: Some(vec!["application/json".to_owned()]),
53//! };
54//!
55//! // At the limit passes, one byte over blocks with the 413 shape.
56//! assert!(decide(&limits, Some("1024"), Some("application/json"))
57//! .unwrap()
58//! .is_none());
59//! let block = decide(&limits, Some("1025"), Some("application/json"))
60//! .unwrap()
61//! .expect("over the limit");
62//! assert_eq!(block.status, 413);
63//! assert_eq!(block.body, "Request too large");
64//!
65//! // The media type is the prefix before ";", matched exactly.
66//! assert!(decide(&limits, Some("10"), Some("application/json; charset=utf-8"))
67//! .unwrap()
68//! .is_none());
69//! let block = decide(&limits, Some("10"), Some("text/plain"))
70//! .unwrap()
71//! .expect("disallowed type");
72//! assert_eq!(block.status, 415);
73//! assert_eq!(block.body, "Unsupported content type");
74//!
75//! // The default is inert.
76//! assert!(decide(&ContentLimits::default(), Some("999999999"), Some("text/plain"))
77//! .unwrap()
78//! .is_none());
79//! ```
80
81use std::fmt;
82
83/// The route-level content limits (the reference `RouteConfig.max_request_size`
84/// and `RouteConfig.allowed_content_types`).
85///
86/// The default is both fields unset: the gate is inert and every request
87/// passes, the reference's no-decorator shape (`applies_to` registers the
88/// check only when a route carries either limit).
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct ContentLimits {
91 /// `max_request_size`: the largest accepted `content-length` in bytes.
92 /// `None` (or the reference's `0`-falsy shape) skips the size check.
93 pub max_request_size: Option<u64>,
94 /// `allowed_content_types`: the exact media types accepted. `None` skips
95 /// the type check; an empty list behaves like `None` (the reference only
96 /// registers the check for a truthy list).
97 pub allowed_content_types: Option<Vec<String>>,
98}
99
100impl ContentLimits {
101 /// Whether the gate has anything to enforce (the reference
102 /// `applies_to` predicate: `max_request_size is not None or
103 /// bool(allowed_content_types)`).
104 #[must_use]
105 pub fn has_rules(&self) -> bool {
106 self.max_request_size.is_some()
107 || self
108 .allowed_content_types
109 .as_ref()
110 .is_some_and(|types| !types.is_empty())
111 }
112}
113
114/// Which sub-check blocked the request (the reference's
115/// `violation_type` event fields, carried for observability).
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum ContentViolation {
118 /// The `content-length` crossed `max_request_size`
119 /// (`violation_type="max_request_size"`).
120 MaxRequestSize,
121 /// The media type was not in `allowed_content_types`
122 /// (`violation_type="content_type"`).
123 ContentType,
124}
125
126/// The block answer of a failed size or type check: the status and default
127/// message body the reference's `create_error_response` answers with, plus
128/// the `log_activity` reason string.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ContentBlock {
131 /// Which sub-check blocked.
132 pub violation: ContentViolation,
133 /// `413` for the size block, `415` for the type block.
134 pub status: u16,
135 /// The reference default message body.
136 pub body: &'static str,
137 /// The reference `log_activity` reason (`"Request size {n} exceeds
138 /// limit: {max}"` / `"Invalid content type: {type}"`).
139 pub reason: String,
140}
141
142/// A `content-length` header value that is not an integer: the error
143/// [`decide`] fails with.
144///
145/// This mirrors the reference's `int(content_length)` `ValueError`, which
146/// the pipeline answers with the fail-secure `500 "Security check failed"`.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ContentLengthError {
149 /// The rejected header value.
150 pub value: String,
151}
152
153impl fmt::Display for ContentLengthError {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(
156 f,
157 "invalid content_length value '{}': expected an integer",
158 self.value
159 )
160 }
161}
162
163impl std::error::Error for ContentLengthError {}
164
165/// The media type of a `content-type` header value: the exact prefix before
166/// the first `;`, with no trim (the reference `split(";")[0]`). A missing
167/// header reads as the empty string.
168#[must_use]
169pub fn media_type(content_type: &str) -> &str {
170 match content_type.split_once(';') {
171 Some((prefix, _)) => prefix,
172 None => content_type,
173 }
174}
175
176/// Carry one request's size and content-type header values through the
177/// reference's two sub-checks.
178///
179/// `content_length` is the raw `content-length` header value (`None` when
180/// the header is absent) and `content_type` the raw `content-type` header
181/// value (`None` when absent, compared as the empty string). `Ok(None)` means
182/// the request passes; `Ok(Some(block))` is the reference's block answer; the
183/// [`Err`] shape mirrors the reference's non-integer `content-length`
184/// exception (answer the fail-secure `500 "Security check failed"`).
185pub fn decide(
186 limits: &ContentLimits,
187 content_length: Option<&str>,
188 content_type: Option<&str>,
189) -> Result<Option<ContentBlock>, ContentLengthError> {
190 if let Some(response) = size_block(limits, content_length)? {
191 return Ok(Some(response));
192 }
193 Ok(content_type_block(limits, content_type))
194}
195
196/// The size sub-check: `None` passes, `Err` mirrors the reference's
197/// `int(content_length)` exception.
198fn size_block(
199 limits: &ContentLimits,
200 content_length: Option<&str>,
201) -> Result<Option<ContentBlock>, ContentLengthError> {
202 let Some(max) = limits.max_request_size else {
203 return Ok(None);
204 };
205 let Some(raw) = content_length.filter(|raw| !raw.is_empty()) else {
206 // `if not content_length` in the reference: an absent or empty
207 // header passes the size check.
208 return Ok(None);
209 };
210 // Python's int() strips surrounding whitespace and accepts a leading
211 // sign; a negative value is `<= limit` and passes, like Python.
212 let trimmed = raw.trim();
213 let parsed: i64 = trimmed.parse().map_err(|_| ContentLengthError {
214 value: raw.to_owned(),
215 })?;
216 if parsed <= 0 || u64::try_from(parsed).is_ok_and(|size| size <= max) {
217 return Ok(None);
218 }
219 Ok(Some(ContentBlock {
220 violation: ContentViolation::MaxRequestSize,
221 status: 413,
222 body: "Request too large",
223 reason: format!("Request size {raw} exceeds limit: {max}"),
224 }))
225}
226
227/// The content-type sub-check: `None` passes.
228fn content_type_block(limits: &ContentLimits, content_type: Option<&str>) -> Option<ContentBlock> {
229 let allowed = limits
230 .allowed_content_types
231 .as_ref()
232 .filter(|types| !types.is_empty())?;
233 let value = content_type.unwrap_or("");
234 let media = media_type(value);
235 if allowed.iter().any(|entry| entry == media) {
236 return None;
237 }
238 Some(ContentBlock {
239 violation: ContentViolation::ContentType,
240 status: 415,
241 body: "Unsupported content type",
242 reason: format!("Invalid content type: {media}"),
243 })
244}
245
246/// The status of a request body that did not arrive within
247/// `body_read_timeout` (`SecurityConfig::body_read_timeout`): the adapter
248/// answers it instead of forwarding a body it could not read in full.
249pub const BODY_READ_TIMEOUT_STATUS: u16 = 408;
250
251/// The body of the request-body read timeout answer.
252pub const BODY_READ_TIMEOUT_MESSAGE: &str = "Request body read timed out";
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn json_only(max: u64) -> ContentLimits {
259 ContentLimits {
260 max_request_size: Some(max),
261 allowed_content_types: Some(vec!["application/json".to_owned()]),
262 }
263 }
264
265 #[test]
266 fn default_is_inert_and_has_no_rules() {
267 let limits = ContentLimits::default();
268 assert!(!limits.has_rules());
269 assert!(
270 decide(&limits, Some("999999999999"), Some("text/plain"))
271 .unwrap()
272 .is_none()
273 );
274 assert!(decide(&limits, None, None).unwrap().is_none());
275 }
276
277 #[test]
278 fn has_rules_tracks_each_field() {
279 assert!(!ContentLimits::default().has_rules());
280 assert!(
281 ContentLimits {
282 max_request_size: Some(1),
283 ..ContentLimits::default()
284 }
285 .has_rules()
286 );
287 assert!(
288 ContentLimits {
289 allowed_content_types: Some(vec!["application/json".to_owned()]),
290 ..ContentLimits::default()
291 }
292 .has_rules()
293 );
294 // An empty allowed list is the reference's falsy list: no rules.
295 assert!(
296 !ContentLimits {
297 max_request_size: None,
298 allowed_content_types: Some(Vec::new()),
299 }
300 .has_rules()
301 );
302 assert!(
303 decide(
304 &ContentLimits {
305 max_request_size: None,
306 allowed_content_types: Some(Vec::new()),
307 },
308 Some("999999999"),
309 Some("text/plain")
310 )
311 .unwrap()
312 .is_none(),
313 "an empty allowed list never blocks"
314 );
315 }
316
317 #[test]
318 fn size_boundary_is_at_the_limit() {
319 let limits = ContentLimits {
320 max_request_size: Some(1024),
321 ..ContentLimits::default()
322 };
323 assert!(decide(&limits, Some("1024"), None).unwrap().is_none());
324 let block = decide(&limits, Some("1025"), None).unwrap().expect("over");
325 assert_eq!(block.violation, ContentViolation::MaxRequestSize);
326 assert_eq!(block.status, 413);
327 assert_eq!(block.body, "Request too large");
328 assert_eq!(block.reason, "Request size 1025 exceeds limit: 1024");
329 }
330
331 #[test]
332 fn missing_or_empty_content_length_passes_the_size_check() {
333 let limits = ContentLimits {
334 max_request_size: Some(10),
335 ..ContentLimits::default()
336 };
337 assert!(decide(&limits, None, None).unwrap().is_none());
338 assert!(decide(&limits, Some(""), None).unwrap().is_none());
339 }
340
341 #[test]
342 fn signed_and_padded_content_lengths_mirror_python_int() {
343 let limits = ContentLimits {
344 max_request_size: Some(10),
345 ..ContentLimits::default()
346 };
347 // Surrounding whitespace is stripped before the parse.
348 assert!(decide(&limits, Some(" 10 "), None).unwrap().is_none());
349 // A leading sign parses; negative values pass like Python's `-1 <= 10`.
350 assert!(decide(&limits, Some("-1"), None).unwrap().is_none());
351 assert!(decide(&limits, Some("+5"), None).unwrap().is_none());
352 let block = decide(&limits, Some(" 11 "), None).unwrap().expect("over");
353 assert_eq!(block.reason, "Request size 11 exceeds limit: 10");
354 }
355
356 #[test]
357 fn junk_content_length_is_an_error_not_a_block() {
358 let limits = ContentLimits {
359 max_request_size: Some(10),
360 ..ContentLimits::default()
361 };
362 let error = decide(&limits, Some("about-twenty"), None).unwrap_err();
363 assert_eq!(error.value, "about-twenty");
364 assert_eq!(
365 error.to_string(),
366 "invalid content_length value 'about-twenty': expected an integer"
367 );
368 // Without a size limit the junk header is never read.
369 assert!(
370 decide(&ContentLimits::default(), Some("junk"), None)
371 .unwrap()
372 .is_none()
373 );
374 }
375
376 #[test]
377 fn content_type_matches_the_exact_media_type_prefix() {
378 let limits = ContentLimits {
379 max_request_size: None,
380 allowed_content_types: Some(vec![
381 "application/json".to_owned(),
382 "application/merge-patch+json".to_owned(),
383 ]),
384 };
385 // Parameters are cut away, not stripped.
386 assert!(
387 decide(&limits, None, Some("application/json; charset=utf-8"))
388 .unwrap()
389 .is_none()
390 );
391 assert!(
392 decide(&limits, None, Some("application/merge-patch+json"))
393 .unwrap()
394 .is_none(),
395 "a type that merely contains an allowed entry as a substring must not pass"
396 );
397 // No trim: a space before the parameters stays part of the media type.
398 let block = decide(&limits, None, Some("application/json ; charset=utf-8"))
399 .unwrap()
400 .expect("padded separator");
401 assert_eq!(block.reason, "Invalid content type: application/json ");
402 assert_eq!(block.status, 415);
403 assert_eq!(block.body, "Unsupported content type");
404 assert_eq!(block.violation, ContentViolation::ContentType);
405 // Comparison is case-sensitive, like the reference `in` check.
406 assert!(
407 decide(&limits, None, Some("Application/JSON"))
408 .unwrap()
409 .is_some()
410 );
411 }
412
413 #[test]
414 fn missing_content_type_is_blocked_by_an_allowed_list() {
415 let limits = ContentLimits {
416 max_request_size: None,
417 allowed_content_types: Some(vec!["application/json".to_owned()]),
418 };
419 let block = decide(&limits, None, None).unwrap().expect("no header");
420 assert_eq!(block.status, 415);
421 assert_eq!(block.reason, "Invalid content type: ");
422 // The empty-string media type only passes if a route allows it.
423 let limits = ContentLimits {
424 max_request_size: None,
425 allowed_content_types: Some(vec![String::new()]),
426 };
427 assert!(decide(&limits, None, None).unwrap().is_none());
428 }
429
430 #[test]
431 fn size_block_wins_over_the_type_block() {
432 let limits = json_only(10);
433 let block = decide(&limits, Some("11"), Some("text/plain"))
434 .unwrap()
435 .expect("blocked");
436 assert_eq!(block.violation, ContentViolation::MaxRequestSize);
437 assert_eq!(block.status, 413);
438 // Under the size limit the type check answers.
439 let block = decide(&limits, Some("10"), Some("text/plain"))
440 .unwrap()
441 .expect("blocked");
442 assert_eq!(block.violation, ContentViolation::ContentType);
443 assert_eq!(block.status, 415);
444 }
445
446 #[test]
447 fn media_type_cuts_at_the_first_separator() {
448 assert_eq!(
449 media_type("application/json; charset=utf-8"),
450 "application/json"
451 );
452 assert_eq!(media_type("application/json"), "application/json");
453 assert_eq!(media_type(""), "");
454 assert_eq!(media_type(";a"), "");
455 assert_eq!(media_type("text/plain;;a=b"), "text/plain");
456 }
457}