Skip to main content

featherbit/plugins/native/
request_size_limit.rs

1//! Request body size limit plugin (`request-size-limit`).
2//!
3//! Rejects requests whose body exceeds a configured byte limit with a 413
4//! error routed through the node's error port.
5
6use async_trait::async_trait;
7use bytes::Bytes;
8use std::collections::HashMap;
9
10use crate::context::Context;
11use crate::plugins::{Plugin, PluginOutput, PluginResult};
12
13/// Enforces a maximum request body size.
14///
15/// Compares the already-buffered body length against `max_bytes`; oversized
16/// requests get a 413 JSON response and a `PAYLOAD_TOO_LARGE` error carrying
17/// the context so the graph engine routes through the error port. Does not
18/// write to `context.message`.
19pub struct RequestSizeLimitPlugin {
20    /// Maximum allowed request body size in bytes.
21    max_bytes: usize,
22}
23
24impl RequestSizeLimitPlugin {
25    /// Builds the plugin from node config. Never fails.
26    ///
27    /// Accepted keys:
28    /// - `max_bytes` (integer, default `1048576` = 1 MiB): maximum request
29    ///   body size in bytes.
30    ///
31    /// ```yaml
32    /// type: request-size-limit
33    /// config:
34    ///   max_bytes: 262144
35    /// ```
36    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
37        let max_bytes = config
38            .get("max_bytes")
39            .and_then(|v| v.as_u64())
40            .unwrap_or(1_048_576) as usize; // 1MB default
41
42        Ok(Self { max_bytes })
43    }
44}
45
46#[async_trait]
47impl Plugin for RequestSizeLimitPlugin {
48    fn plugin_type(&self) -> &str {
49        "request-size-limit"
50    }
51
52    async fn execute(&self, ctx: Context) -> PluginResult {
53        let body_len = ctx.request.body.len();
54        if body_len > self.max_bytes {
55            let mut ctx = ctx;
56            ctx.response.status_code = 413;
57            ctx.response.body = Bytes::from(
58                r#"{"error": "payload_too_large", "message": "Request body exceeds size limit"}"#,
59            );
60            ctx.response.headers.insert(
61                "content-type".to_string(),
62                vec!["application/json".to_string()],
63            );
64            return Ok(PluginOutput::on_port(ctx, "denied"));
65        }
66
67        Ok(PluginOutput::success(ctx))
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    //! featherbit-native plugin (APISIX handles body limits at the nginx layer,
74    //! not as a portable plugin), so these derive from featherbit's own spec:
75    //! reject bodies larger than `max_bytes` with 413, measured on the actual
76    //! buffered body length.
77    use super::*;
78    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
79
80    fn ctx(body: &[u8]) -> Context {
81        Context {
82            request: GatewayRequest {
83                method: "POST".to_string(),
84                path: "/hello".to_string(),
85                host: "h".to_string(),
86                scheme: "http".to_string(),
87                headers: HashMap::new(),
88                query_params: HashMap::new(),
89                body: Bytes::copy_from_slice(body),
90                remote_addr: "1.2.3.4:5".to_string(),
91                protocol: Protocol::Http1,
92            },
93            response: GatewayResponse {
94                status_code: 0,
95                headers: HashMap::new(),
96                body: Bytes::new(),
97                stream: None,
98            },
99            message: HashMap::new(),
100            errors: Vec::new(),
101        }
102    }
103
104    fn plugin(max_bytes: u64) -> RequestSizeLimitPlugin {
105        let mut map = HashMap::new();
106        map.insert("max_bytes".to_string(), serde_json::json!(max_bytes));
107        RequestSizeLimitPlugin::from_config(&map).unwrap()
108    }
109
110    #[tokio::test]
111    async fn test_body_under_limit_passes() {
112        let out = plugin(10).execute(ctx(b"12345")).await.unwrap();
113        assert!(out.port.is_none());
114    }
115
116    #[tokio::test]
117    async fn test_body_at_limit_passes() {
118        // Boundary: exactly max_bytes is allowed (only strictly greater fails).
119        let out = plugin(5).execute(ctx(b"12345")).await.unwrap();
120        assert!(out.port.is_none());
121    }
122
123    #[tokio::test]
124    async fn test_body_over_limit_rejected_413() {
125        let out = plugin(5).execute(ctx(b"123456")).await.unwrap();
126        assert_eq!(out.port, Some("denied"));
127        assert_eq!(out.context.response.status_code, 413);
128    }
129
130    #[tokio::test]
131    async fn test_empty_body_passes() {
132        assert!(plugin(0).execute(ctx(b"")).await.unwrap().port.is_none());
133    }
134
135    #[test]
136    fn test_default_limit_is_1mib() {
137        let p = RequestSizeLimitPlugin::from_config(&HashMap::new()).unwrap();
138        assert_eq!(p.max_bytes, 1_048_576);
139    }
140}