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#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn json_only(max: u64) -> ContentLimits {
251        ContentLimits {
252            max_request_size: Some(max),
253            allowed_content_types: Some(vec!["application/json".to_owned()]),
254        }
255    }
256
257    #[test]
258    fn default_is_inert_and_has_no_rules() {
259        let limits = ContentLimits::default();
260        assert!(!limits.has_rules());
261        assert!(
262            decide(&limits, Some("999999999999"), Some("text/plain"))
263                .unwrap()
264                .is_none()
265        );
266        assert!(decide(&limits, None, None).unwrap().is_none());
267    }
268
269    #[test]
270    fn has_rules_tracks_each_field() {
271        assert!(!ContentLimits::default().has_rules());
272        assert!(
273            ContentLimits {
274                max_request_size: Some(1),
275                ..ContentLimits::default()
276            }
277            .has_rules()
278        );
279        assert!(
280            ContentLimits {
281                allowed_content_types: Some(vec!["application/json".to_owned()]),
282                ..ContentLimits::default()
283            }
284            .has_rules()
285        );
286        // An empty allowed list is the reference's falsy list: no rules.
287        assert!(
288            !ContentLimits {
289                max_request_size: None,
290                allowed_content_types: Some(Vec::new()),
291            }
292            .has_rules()
293        );
294        assert!(
295            decide(
296                &ContentLimits {
297                    max_request_size: None,
298                    allowed_content_types: Some(Vec::new()),
299                },
300                Some("999999999"),
301                Some("text/plain")
302            )
303            .unwrap()
304            .is_none(),
305            "an empty allowed list never blocks"
306        );
307    }
308
309    #[test]
310    fn size_boundary_is_at_the_limit() {
311        let limits = ContentLimits {
312            max_request_size: Some(1024),
313            ..ContentLimits::default()
314        };
315        assert!(decide(&limits, Some("1024"), None).unwrap().is_none());
316        let block = decide(&limits, Some("1025"), None).unwrap().expect("over");
317        assert_eq!(block.violation, ContentViolation::MaxRequestSize);
318        assert_eq!(block.status, 413);
319        assert_eq!(block.body, "Request too large");
320        assert_eq!(block.reason, "Request size 1025 exceeds limit: 1024");
321    }
322
323    #[test]
324    fn missing_or_empty_content_length_passes_the_size_check() {
325        let limits = ContentLimits {
326            max_request_size: Some(10),
327            ..ContentLimits::default()
328        };
329        assert!(decide(&limits, None, None).unwrap().is_none());
330        assert!(decide(&limits, Some(""), None).unwrap().is_none());
331    }
332
333    #[test]
334    fn signed_and_padded_content_lengths_mirror_python_int() {
335        let limits = ContentLimits {
336            max_request_size: Some(10),
337            ..ContentLimits::default()
338        };
339        // Surrounding whitespace is stripped before the parse.
340        assert!(decide(&limits, Some(" 10 "), None).unwrap().is_none());
341        // A leading sign parses; negative values pass like Python's `-1 <= 10`.
342        assert!(decide(&limits, Some("-1"), None).unwrap().is_none());
343        assert!(decide(&limits, Some("+5"), None).unwrap().is_none());
344        let block = decide(&limits, Some(" 11 "), None).unwrap().expect("over");
345        assert_eq!(block.reason, "Request size  11  exceeds limit: 10");
346    }
347
348    #[test]
349    fn junk_content_length_is_an_error_not_a_block() {
350        let limits = ContentLimits {
351            max_request_size: Some(10),
352            ..ContentLimits::default()
353        };
354        let error = decide(&limits, Some("about-twenty"), None).unwrap_err();
355        assert_eq!(error.value, "about-twenty");
356        assert_eq!(
357            error.to_string(),
358            "invalid content_length value 'about-twenty': expected an integer"
359        );
360        // Without a size limit the junk header is never read.
361        assert!(
362            decide(&ContentLimits::default(), Some("junk"), None)
363                .unwrap()
364                .is_none()
365        );
366    }
367
368    #[test]
369    fn content_type_matches_the_exact_media_type_prefix() {
370        let limits = ContentLimits {
371            max_request_size: None,
372            allowed_content_types: Some(vec![
373                "application/json".to_owned(),
374                "application/merge-patch+json".to_owned(),
375            ]),
376        };
377        // Parameters are cut away, not stripped.
378        assert!(
379            decide(&limits, None, Some("application/json; charset=utf-8"))
380                .unwrap()
381                .is_none()
382        );
383        assert!(
384            decide(&limits, None, Some("application/merge-patch+json"))
385                .unwrap()
386                .is_none(),
387            "a type that merely contains an allowed entry as a substring must not pass"
388        );
389        // No trim: a space before the parameters stays part of the media type.
390        let block = decide(&limits, None, Some("application/json ; charset=utf-8"))
391            .unwrap()
392            .expect("padded separator");
393        assert_eq!(block.reason, "Invalid content type: application/json ");
394        assert_eq!(block.status, 415);
395        assert_eq!(block.body, "Unsupported content type");
396        assert_eq!(block.violation, ContentViolation::ContentType);
397        // Comparison is case-sensitive, like the reference `in` check.
398        assert!(
399            decide(&limits, None, Some("Application/JSON"))
400                .unwrap()
401                .is_some()
402        );
403    }
404
405    #[test]
406    fn missing_content_type_is_blocked_by_an_allowed_list() {
407        let limits = ContentLimits {
408            max_request_size: None,
409            allowed_content_types: Some(vec!["application/json".to_owned()]),
410        };
411        let block = decide(&limits, None, None).unwrap().expect("no header");
412        assert_eq!(block.status, 415);
413        assert_eq!(block.reason, "Invalid content type: ");
414        // The empty-string media type only passes if a route allows it.
415        let limits = ContentLimits {
416            max_request_size: None,
417            allowed_content_types: Some(vec![String::new()]),
418        };
419        assert!(decide(&limits, None, None).unwrap().is_none());
420    }
421
422    #[test]
423    fn size_block_wins_over_the_type_block() {
424        let limits = json_only(10);
425        let block = decide(&limits, Some("11"), Some("text/plain"))
426            .unwrap()
427            .expect("blocked");
428        assert_eq!(block.violation, ContentViolation::MaxRequestSize);
429        assert_eq!(block.status, 413);
430        // Under the size limit the type check answers.
431        let block = decide(&limits, Some("10"), Some("text/plain"))
432            .unwrap()
433            .expect("blocked");
434        assert_eq!(block.violation, ContentViolation::ContentType);
435        assert_eq!(block.status, 415);
436    }
437
438    #[test]
439    fn media_type_cuts_at_the_first_separator() {
440        assert_eq!(
441            media_type("application/json; charset=utf-8"),
442            "application/json"
443        );
444        assert_eq!(media_type("application/json"), "application/json");
445        assert_eq!(media_type(""), "");
446        assert_eq!(media_type(";a"), "");
447        assert_eq!(media_type("text/plain;;a=b"), "text/plain");
448    }
449}