Skip to main content

featherbit/plugins/native/
request_validation.rs

1//! The `request-validation` node — validates request headers and/or body
2//! against JSON Schemas before the request reaches the upstream, rejecting
3//! non-conforming requests with a configurable status code.
4//!
5//! Port of APISIX's `request-validation` plugin. Schemas are compiled once at
6//! config load (invalid schemas fail policy compilation, not requests).
7//! Bodies are validated as JSON by default; `application/x-www-form-urlencoded`
8//! bodies are decoded into a flat object first, mirroring the Lua plugin's
9//! `ngx.decode_args` shape (duplicate keys become arrays, keys without `=`
10//! become `true`).
11
12use async_trait::async_trait;
13use bytes::Bytes;
14use std::collections::HashMap;
15
16use crate::context::{Context, GatewayError};
17use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
18
19/// Validates `context.request` headers and body against compiled JSON
20/// Schemas. On failure the request is rejected through the `error` port with
21/// error code `VALIDATION_FAILED` and a JSON response using `rejected_code`.
22///
23/// Headers are validated as a single-value object (first value per header,
24/// names lowercased), matching the shape APISIX passes to its schema check.
25/// After a successful JSON-body validation the body is re-serialized from the
26/// parsed document, so the JSON that was validated is exactly the JSON the
27/// upstream receives (guards against JSON-interoperability smuggling).
28pub struct RequestValidationPlugin {
29    header_schema: Option<jsonschema::Validator>,
30    body_schema: Option<jsonschema::Validator>,
31    rejected_code: u16,
32    rejected_msg: Option<String>,
33}
34
35/// Compiles an optional schema key into a validator, failing fast with the
36/// key name on malformed schemas.
37fn compile_schema(
38    config: &HashMap<String, serde_json::Value>,
39    key: &str,
40) -> Result<Option<jsonschema::Validator>, String> {
41    match config.get(key) {
42        None => Ok(None),
43        Some(raw) => {
44            if !raw.is_object() {
45                return Err(format!(
46                    "request-validation: '{}' must be a JSON Schema object",
47                    key
48                ));
49            }
50            jsonschema::validator_for(raw)
51                .map(Some)
52                .map_err(|e| format!("request-validation: invalid '{}': {}", key, e))
53        }
54    }
55}
56
57/// Decodes an `application/x-www-form-urlencoded` body into a flat JSON
58/// object, mirroring `ngx.decode_args`: `a=1&a=2` becomes `{"a": ["1","2"]}`,
59/// a bare `flag` (no `=`) becomes `{"flag": true}`, values are
60/// percent-decoded and `+` maps to space.
61fn decode_urlencoded(body: &str) -> serde_json::Value {
62    let mut map = serde_json::Map::new();
63    for pair in body.split('&') {
64        if pair.is_empty() {
65            continue;
66        }
67        let (key, value) = match pair.split_once('=') {
68            Some((k, v)) => (urldecode(k), serde_json::Value::String(urldecode(v))),
69            None => (urldecode(pair), serde_json::Value::Bool(true)),
70        };
71        match map.get_mut(&key) {
72            None => {
73                map.insert(key, value);
74            }
75            Some(serde_json::Value::Array(arr)) => arr.push(value),
76            Some(existing) => {
77                let first = existing.take();
78                *existing = serde_json::Value::Array(vec![first, value]);
79            }
80        }
81    }
82    serde_json::Value::Object(map)
83}
84
85/// Percent-decodes a urlencoded component (`+` becomes space; malformed
86/// escapes pass through literally).
87fn urldecode(s: &str) -> String {
88    let bytes = s.as_bytes();
89    let mut out = Vec::with_capacity(bytes.len());
90    let mut i = 0;
91    while i < bytes.len() {
92        match bytes[i] {
93            b'+' => out.push(b' '),
94            b'%' if i + 2 < bytes.len() => {
95                if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
96                    out.push(h * 16 + l);
97                    i += 3;
98                    continue;
99                }
100                out.push(b'%');
101            }
102            b => out.push(b),
103        }
104        i += 1;
105    }
106    String::from_utf8_lossy(&out).into_owned()
107}
108
109fn hex_val(b: u8) -> Option<u8> {
110    match b {
111        b'0'..=b'9' => Some(b - b'0'),
112        b'a'..=b'f' => Some(b - b'a' + 10),
113        b'A'..=b'F' => Some(b - b'A' + 10),
114        _ => None,
115    }
116}
117
118impl RequestValidationPlugin {
119    /// Builds the plugin from node config.
120    ///
121    /// Accepted keys:
122    /// - `header_schema` (object): JSON Schema applied to the request headers,
123    ///   seen as `{name: first_value}` with lowercase names.
124    /// - `body_schema` (object): JSON Schema applied to the parsed request
125    ///   body (JSON, or urlencoded decoded to a flat object).
126    /// - `rejected_code` (integer 200–599, default `400`): response status
127    ///   for rejected requests.
128    /// - `rejected_msg` (string): fixed message returned instead of the
129    ///   validator's error description.
130    ///
131    /// At least one of `header_schema` / `body_schema` is required; both are
132    /// compiled here so malformed schemas fail at config load.
133    ///
134    /// ```yaml
135    /// type: request-validation
136    /// config:
137    ///   rejected_code: 422
138    ///   body_schema:
139    ///     type: object
140    ///     required: [name]
141    ///     properties:
142    ///       name: { type: string, minLength: 1 }
143    /// ```
144    pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
145        let header_schema = compile_schema(config, "header_schema")?;
146        let body_schema = compile_schema(config, "body_schema")?;
147
148        if header_schema.is_none() && body_schema.is_none() {
149            return Err(
150                "request-validation: at least one of 'header_schema' or 'body_schema' is required"
151                    .to_string(),
152            );
153        }
154
155        let rejected_code = match config.get("rejected_code") {
156            None => 400,
157            Some(v) => {
158                let code = v
159                    .as_u64()
160                    .filter(|c| (200..=599).contains(c))
161                    .ok_or("request-validation: 'rejected_code' must be an integer in 200..=599")?;
162                code as u16
163            }
164        };
165
166        let rejected_msg = config
167            .get("rejected_msg")
168            .and_then(|v| v.as_str())
169            .map(String::from);
170
171        Ok(Self {
172            header_schema,
173            body_schema,
174            rejected_code,
175            rejected_msg,
176        })
177    }
178
179    /// Writes the rejection onto the response and returns the error that
180    /// routes the context through the node's `error` port.
181    fn reject(&self, mut ctx: Context, detail: String) -> PluginExecutionError {
182        let message = self.rejected_msg.clone().unwrap_or(detail);
183        ctx.response.status_code = self.rejected_code;
184        ctx.response.body = Bytes::from(
185            serde_json::json!({ "error": "validation_failed", "message": message }).to_string(),
186        );
187        ctx.response.headers.insert(
188            "content-type".to_string(),
189            vec!["application/json".to_string()],
190        );
191        PluginExecutionError {
192            context: ctx,
193            error: GatewayError {
194                node_id: String::new(),
195                code: "VALIDATION_FAILED".to_string(),
196                message,
197                metadata: HashMap::new(),
198            },
199        }
200    }
201}
202
203#[async_trait]
204impl Plugin for RequestValidationPlugin {
205    fn plugin_type(&self) -> &str {
206        "request-validation"
207    }
208
209    async fn execute(
210        &self,
211        mut ctx: Context,
212        _named_inputs: &HashMap<String, serde_json::Value>,
213    ) -> PluginResult {
214        if let Some(validator) = &self.header_schema {
215            let headers: serde_json::Map<String, serde_json::Value> = ctx
216                .request
217                .headers
218                .iter()
219                .filter_map(|(k, v)| {
220                    v.first()
221                        .map(|first| (k.clone(), serde_json::Value::String(first.clone())))
222                })
223                .collect();
224            if let Err(e) = validator.validate(&serde_json::Value::Object(headers)) {
225                return Err(self.reject(ctx, format!("header validation failed: {}", e)));
226            }
227        }
228
229        if let Some(validator) = &self.body_schema {
230            if ctx.request.body.is_empty() {
231                return Err(self.reject(ctx, "request body is required".to_string()));
232            }
233
234            let is_urlencoded = ctx
235                .request
236                .headers
237                .get("content-type")
238                .and_then(|v| v.first())
239                .map(|ct| {
240                    ct.to_lowercase()
241                        .starts_with("application/x-www-form-urlencoded")
242                })
243                .unwrap_or(false);
244
245            let (parsed, body_is_json) = if is_urlencoded {
246                let text = String::from_utf8_lossy(&ctx.request.body).into_owned();
247                (decode_urlencoded(&text), false)
248            } else {
249                match serde_json::from_slice::<serde_json::Value>(&ctx.request.body) {
250                    Ok(v) => (v, true),
251                    Err(e) => {
252                        return Err(
253                            self.reject(ctx, format!("failed to decode the request body: {}", e))
254                        );
255                    }
256                }
257            };
258
259            if let Err(e) = validator.validate(&parsed) {
260                return Err(self.reject(ctx, format!("body validation failed: {}", e)));
261            }
262
263            if body_is_json {
264                // Ensure the JSON we validated is the JSON the upstream sees
265                // (JSON interoperability hardening, mirroring APISIX).
266                // Body-mutation convention: drop the stale content-length.
267                ctx.request.body = Bytes::from(serde_json::to_vec(&parsed).unwrap_or_default());
268                ctx.request.headers.remove("content-length");
269            }
270        }
271
272        Ok(PluginOutput {
273            context: ctx,
274            named_outputs: HashMap::new(),
275        })
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
283
284    fn test_context(body: &str, content_type: Option<&str>) -> Context {
285        let mut headers = HashMap::new();
286        headers.insert("x-api-version".to_string(), vec!["2".to_string()]);
287        if let Some(ct) = content_type {
288            headers.insert("content-type".to_string(), vec![ct.to_string()]);
289        }
290        if !body.is_empty() {
291            headers.insert("content-length".to_string(), vec![body.len().to_string()]);
292        }
293
294        Context {
295            request: GatewayRequest {
296                method: "POST".to_string(),
297                path: "/api".to_string(),
298                host: "localhost".to_string(),
299                scheme: "http".to_string(),
300                headers,
301                query_params: HashMap::new(),
302                body: Bytes::from(body.to_string()),
303                remote_addr: "127.0.0.1:12345".to_string(),
304                protocol: Protocol::Http1,
305            },
306            response: GatewayResponse {
307                status_code: 0,
308                headers: HashMap::new(),
309                body: Bytes::new(),
310            },
311            message: HashMap::new(),
312            errors: Vec::new(),
313        }
314    }
315
316    fn body_plugin(schema: serde_json::Value) -> RequestValidationPlugin {
317        let mut config = HashMap::new();
318        config.insert("body_schema".to_string(), schema);
319        RequestValidationPlugin::from_config(&config).unwrap()
320    }
321
322    #[tokio::test]
323    async fn test_request_validation_body_accept_and_normalize() {
324        let p = body_plugin(serde_json::json!({
325            "type": "object",
326            "required": ["name"],
327            "properties": { "name": { "type": "string" } }
328        }));
329        let ctx = test_context(r#"{"name":  "jack"}"#, Some("application/json"));
330        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
331        // validated JSON is re-serialized (normalized) and content-length dropped
332        assert_eq!(out.context.request.body, Bytes::from(r#"{"name":"jack"}"#));
333        assert!(!out.context.request.headers.contains_key("content-length"));
334    }
335
336    #[tokio::test]
337    async fn test_request_validation_body_reject() {
338        let p = body_plugin(serde_json::json!({
339            "type": "object",
340            "required": ["name"]
341        }));
342        let ctx = test_context(r#"{"age": 3}"#, Some("application/json"));
343        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
344        assert_eq!(err.error.code, "VALIDATION_FAILED");
345        assert_eq!(err.context.response.status_code, 400);
346        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
347        assert_eq!(body["error"], "validation_failed");
348    }
349
350    #[tokio::test]
351    async fn test_request_validation_non_json_body_rejected() {
352        let p = body_plugin(serde_json::json!({ "type": "object" }));
353        let ctx = test_context("this is not json", Some("application/json"));
354        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
355        assert_eq!(err.error.code, "VALIDATION_FAILED");
356    }
357
358    #[tokio::test]
359    async fn test_request_validation_missing_body_rejected() {
360        let p = body_plugin(serde_json::json!({ "type": "object" }));
361        let err = p
362            .execute(test_context("", Some("application/json")), &HashMap::new())
363            .await
364            .unwrap_err();
365        assert_eq!(err.error.code, "VALIDATION_FAILED");
366    }
367
368    #[tokio::test]
369    async fn test_request_validation_urlencoded_body() {
370        let p = body_plugin(serde_json::json!({
371            "type": "object",
372            "required": ["user"],
373            "properties": { "user": { "type": "string", "minLength": 2 } }
374        }));
375        let ctx = test_context(
376            "user=jack&note=hello%20world",
377            Some("application/x-www-form-urlencoded"),
378        );
379        let out = p.execute(ctx, &HashMap::new()).await.unwrap();
380        // urlencoded bodies are not rewritten
381        assert_eq!(
382            out.context.request.body,
383            Bytes::from("user=jack&note=hello%20world")
384        );
385
386        let ctx = test_context("note=only", Some("application/x-www-form-urlencoded"));
387        assert!(p.execute(ctx, &HashMap::new()).await.is_err());
388    }
389
390    #[tokio::test]
391    async fn test_request_validation_header_schema() {
392        let mut config = HashMap::new();
393        config.insert(
394            "header_schema".to_string(),
395            serde_json::json!({
396                "type": "object",
397                "required": ["x-api-version"],
398                "properties": { "x-api-version": { "type": "string", "enum": ["2"] } }
399            }),
400        );
401        let p = RequestValidationPlugin::from_config(&config).unwrap();
402
403        assert!(p
404            .execute(test_context("", None), &HashMap::new())
405            .await
406            .is_ok());
407
408        let mut ctx = test_context("", None);
409        ctx.request.headers.remove("x-api-version");
410        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
411        assert_eq!(err.error.code, "VALIDATION_FAILED");
412    }
413
414    #[tokio::test]
415    async fn test_request_validation_rejected_code_and_msg() {
416        let mut config = HashMap::new();
417        config.insert(
418            "body_schema".to_string(),
419            serde_json::json!({ "type": "object" }),
420        );
421        config.insert("rejected_code".to_string(), serde_json::json!(422));
422        config.insert("rejected_msg".to_string(), serde_json::json!("bad payload"));
423        let p = RequestValidationPlugin::from_config(&config).unwrap();
424
425        let ctx = test_context("[1,2,3]", Some("application/json"));
426        let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
427        assert_eq!(err.context.response.status_code, 422);
428        assert_eq!(err.error.message, "bad payload");
429    }
430
431    #[test]
432    fn test_request_validation_config_rejections() {
433        // no schema at all
434        assert!(RequestValidationPlugin::from_config(&HashMap::new()).is_err());
435
436        // invalid JSON Schema fails fast at config load
437        let mut config = HashMap::new();
438        config.insert(
439            "body_schema".to_string(),
440            serde_json::json!({ "type": "definitely-not-a-type" }),
441        );
442        assert!(RequestValidationPlugin::from_config(&config).is_err());
443
444        // schema must be an object
445        let mut config = HashMap::new();
446        config.insert("body_schema".to_string(), serde_json::json!("nope"));
447        assert!(RequestValidationPlugin::from_config(&config).is_err());
448
449        // rejected_code out of range
450        let mut config = HashMap::new();
451        config.insert(
452            "body_schema".to_string(),
453            serde_json::json!({ "type": "object" }),
454        );
455        config.insert("rejected_code".to_string(), serde_json::json!(199));
456        assert!(RequestValidationPlugin::from_config(&config).is_err());
457    }
458
459    #[test]
460    fn test_request_validation_decode_urlencoded_shape() {
461        let v = decode_urlencoded("a=1&a=2&flag&note=hello+world");
462        assert_eq!(v["a"], serde_json::json!(["1", "2"]));
463        assert_eq!(v["flag"], serde_json::json!(true));
464        assert_eq!(v["note"], serde_json::json!("hello world"));
465    }
466}