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, GatewayError};
11use crate::plugins::{Plugin, PluginExecutionError, 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(
53        &self,
54        ctx: Context,
55        _named_inputs: &HashMap<String, serde_json::Value>,
56    ) -> PluginResult {
57        let body_len = ctx.request.body.len();
58        if body_len > self.max_bytes {
59            let mut ctx = ctx;
60            ctx.response.status_code = 413;
61            ctx.response.body = Bytes::from(
62                r#"{"error": "payload_too_large", "message": "Request body exceeds size limit"}"#,
63            );
64            ctx.response.headers.insert(
65                "content-type".to_string(),
66                vec!["application/json".to_string()],
67            );
68            return Err(PluginExecutionError {
69                context: ctx,
70                error: GatewayError {
71                    node_id: String::new(),
72                    code: "PAYLOAD_TOO_LARGE".to_string(),
73                    message: format!("Body size {} exceeds limit {}", body_len, self.max_bytes),
74                    metadata: HashMap::new(),
75                },
76            });
77        }
78
79        Ok(PluginOutput {
80            context: ctx,
81            named_outputs: HashMap::new(),
82        })
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    //! featherbit-native plugin (APISIX handles body limits at the nginx layer,
89    //! not as a portable plugin), so these derive from featherbit's own spec:
90    //! reject bodies larger than `max_bytes` with 413, measured on the actual
91    //! buffered body length.
92    use super::*;
93    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
94
95    fn ctx(body: &[u8]) -> Context {
96        Context {
97            request: GatewayRequest {
98                method: "POST".to_string(),
99                path: "/hello".to_string(),
100                host: "h".to_string(),
101                scheme: "http".to_string(),
102                headers: HashMap::new(),
103                query_params: HashMap::new(),
104                body: Bytes::copy_from_slice(body),
105                remote_addr: "1.2.3.4:5".to_string(),
106                protocol: Protocol::Http1,
107            },
108            response: GatewayResponse {
109                status_code: 0,
110                headers: HashMap::new(),
111                body: Bytes::new(),
112            },
113            message: HashMap::new(),
114            errors: Vec::new(),
115        }
116    }
117
118    fn plugin(max_bytes: u64) -> RequestSizeLimitPlugin {
119        let mut map = HashMap::new();
120        map.insert("max_bytes".to_string(), serde_json::json!(max_bytes));
121        RequestSizeLimitPlugin::from_config(&map).unwrap()
122    }
123
124    #[tokio::test]
125    async fn test_body_under_limit_passes() {
126        let out = plugin(10).execute(ctx(b"12345"), &HashMap::new()).await;
127        assert!(out.is_ok());
128    }
129
130    #[tokio::test]
131    async fn test_body_at_limit_passes() {
132        // Boundary: exactly max_bytes is allowed (only strictly greater fails).
133        let out = plugin(5).execute(ctx(b"12345"), &HashMap::new()).await;
134        assert!(out.is_ok());
135    }
136
137    #[tokio::test]
138    async fn test_body_over_limit_rejected_413() {
139        let err = plugin(5)
140            .execute(ctx(b"123456"), &HashMap::new())
141            .await
142            .unwrap_err();
143        assert_eq!(err.error.code, "PAYLOAD_TOO_LARGE");
144        assert_eq!(err.context.response.status_code, 413);
145    }
146
147    #[tokio::test]
148    async fn test_empty_body_passes() {
149        assert!(plugin(0).execute(ctx(b""), &HashMap::new()).await.is_ok());
150    }
151
152    #[test]
153    fn test_default_limit_is_1mib() {
154        let p = RequestSizeLimitPlugin::from_config(&HashMap::new()).unwrap();
155        assert_eq!(p.max_bytes, 1_048_576);
156    }
157}