Skip to main content

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}