1use async_trait::async_trait;
13use bytes::Bytes;
14use std::collections::HashMap;
15
16use crate::context::Context;
17use crate::plugins::{Plugin, PluginOutput, PluginResult};
18use crate::vars::template::Template;
19
20#[derive(Debug)]
31pub struct RequestValidationPlugin {
32 header_schema: Option<jsonschema::Validator>,
33 body_schema: Option<jsonschema::Validator>,
34 conditions: Option<crate::vars::Expr>,
35 rejected_code: u16,
36 rejected_msg: Option<Template>,
41}
42
43fn compile_schema(
46 config: &HashMap<String, serde_json::Value>,
47 key: &str,
48) -> Result<Option<jsonschema::Validator>, String> {
49 match config.get(key) {
50 None => Ok(None),
51 Some(raw) => {
52 if !raw.is_object() {
53 return Err(format!(
54 "request-validation: '{}' must be a JSON Schema object",
55 key
56 ));
57 }
58 jsonschema::validator_for(raw)
59 .map(Some)
60 .map_err(|e| format!("request-validation: invalid '{}': {}", key, e))
61 }
62 }
63}
64
65fn decode_urlencoded(body: &str) -> serde_json::Value {
70 let mut map = serde_json::Map::new();
71 for pair in body.split('&') {
72 if pair.is_empty() {
73 continue;
74 }
75 let (key, value) = match pair.split_once('=') {
76 Some((k, v)) => (urldecode(k), serde_json::Value::String(urldecode(v))),
77 None => (urldecode(pair), serde_json::Value::Bool(true)),
78 };
79 match map.get_mut(&key) {
80 None => {
81 map.insert(key, value);
82 }
83 Some(serde_json::Value::Array(arr)) => arr.push(value),
84 Some(existing) => {
85 let first = existing.take();
86 *existing = serde_json::Value::Array(vec![first, value]);
87 }
88 }
89 }
90 serde_json::Value::Object(map)
91}
92
93fn urldecode(s: &str) -> String {
96 let bytes = s.as_bytes();
97 let mut out = Vec::with_capacity(bytes.len());
98 let mut i = 0;
99 while i < bytes.len() {
100 match bytes[i] {
101 b'+' => out.push(b' '),
102 b'%' if i + 2 < bytes.len() => {
103 if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
104 out.push(h * 16 + l);
105 i += 3;
106 continue;
107 }
108 out.push(b'%');
109 }
110 b => out.push(b),
111 }
112 i += 1;
113 }
114 String::from_utf8_lossy(&out).into_owned()
115}
116
117fn hex_val(b: u8) -> Option<u8> {
118 match b {
119 b'0'..=b'9' => Some(b - b'0'),
120 b'a'..=b'f' => Some(b - b'a' + 10),
121 b'A'..=b'F' => Some(b - b'A' + 10),
122 _ => None,
123 }
124}
125
126impl RequestValidationPlugin {
127 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
159 let header_schema = compile_schema(config, "header_schema")?;
160 let body_schema = compile_schema(config, "body_schema")?;
161
162 let conditions = match config.get("conditions") {
163 None => None,
164 Some(v) => Some(
165 crate::vars::Expr::parse(v)
166 .map_err(|e| format!("request-validation: invalid 'conditions': {}", e))?,
167 ),
168 };
169
170 if header_schema.is_none() && body_schema.is_none() && conditions.is_none() {
171 return Err(
172 "request-validation: at least one of 'header_schema', 'body_schema', or 'conditions' is required"
173 .to_string(),
174 );
175 }
176
177 let rejected_code = match config.get("rejected_code") {
178 None => 400,
179 Some(v) => {
180 let code = v
181 .as_u64()
182 .filter(|c| (200..=599).contains(c))
183 .ok_or("request-validation: 'rejected_code' must be an integer in 200..=599")?;
184 code as u16
185 }
186 };
187
188 let rejected_msg = config
189 .get("rejected_msg")
190 .and_then(|v| v.as_str())
191 .map(|s| Template::parse(s).0);
194
195 Ok(Self {
196 header_schema,
197 body_schema,
198 conditions,
199 rejected_code,
200 rejected_msg,
201 })
202 }
203
204 fn reject(&self, mut ctx: Context, detail: String) -> PluginResult {
207 let message = self
208 .rejected_msg
209 .as_ref()
210 .map(|t| t.render(&ctx).into_owned())
211 .unwrap_or(detail);
212 ctx.response.status_code = self.rejected_code;
213 ctx.response.body = Bytes::from(
214 serde_json::json!({ "error": "validation_failed", "message": message }).to_string(),
215 );
216 ctx.response.headers.insert(
217 "content-type".to_string(),
218 vec!["application/json".to_string()],
219 );
220 Ok(PluginOutput::on_port(ctx, "denied"))
221 }
222}
223
224#[async_trait]
225impl Plugin for RequestValidationPlugin {
226 fn plugin_type(&self) -> &str {
227 "request-validation"
228 }
229
230 async fn execute(&self, mut ctx: Context) -> PluginResult {
231 if let Some(validator) = &self.header_schema {
232 let headers: serde_json::Map<String, serde_json::Value> = ctx
233 .request
234 .headers
235 .iter()
236 .filter_map(|(k, v)| {
237 v.first()
238 .map(|first| (k.clone(), serde_json::Value::String(first.clone())))
239 })
240 .collect();
241 if let Err(e) = validator.validate(&serde_json::Value::Object(headers)) {
242 return self.reject(ctx, format!("header validation failed: {}", e));
243 }
244 }
245
246 if let Some(validator) = &self.body_schema {
247 if ctx.request.body.is_empty() {
248 return self.reject(ctx, "request body is required".to_string());
249 }
250
251 let is_urlencoded = ctx
252 .request
253 .headers
254 .get("content-type")
255 .and_then(|v| v.first())
256 .map(|ct| {
257 ct.to_lowercase()
258 .starts_with("application/x-www-form-urlencoded")
259 })
260 .unwrap_or(false);
261
262 let (parsed, body_is_json) = if is_urlencoded {
263 let text = String::from_utf8_lossy(&ctx.request.body).into_owned();
264 (decode_urlencoded(&text), false)
265 } else {
266 match serde_json::from_slice::<serde_json::Value>(&ctx.request.body) {
267 Ok(v) => (v, true),
268 Err(e) => {
269 return self
270 .reject(ctx, format!("failed to decode the request body: {}", e));
271 }
272 }
273 };
274
275 if let Err(e) = validator.validate(&parsed) {
276 return self.reject(ctx, format!("body validation failed: {}", e));
277 }
278
279 if body_is_json {
280 ctx.request.body = Bytes::from(serde_json::to_vec(&parsed).unwrap_or_default());
284 ctx.request.headers.remove("content-length");
285 }
286 }
287
288 if let Some(expr) = &self.conditions {
289 if !expr.eval(&ctx) {
290 return self.reject(ctx, "request conditions not satisfied".to_string());
291 }
292 }
293
294 Ok(PluginOutput::success(ctx))
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
302
303 fn test_context(body: &str, content_type: Option<&str>) -> Context {
304 let mut headers = HashMap::new();
305 headers.insert("x-api-version".to_string(), vec!["2".to_string()]);
306 if let Some(ct) = content_type {
307 headers.insert("content-type".to_string(), vec![ct.to_string()]);
308 }
309 if !body.is_empty() {
310 headers.insert("content-length".to_string(), vec![body.len().to_string()]);
311 }
312
313 Context {
314 request: GatewayRequest {
315 method: "POST".to_string(),
316 path: "/api".to_string(),
317 host: "localhost".to_string(),
318 scheme: "http".to_string(),
319 headers,
320 query_params: HashMap::new(),
321 body: Bytes::from(body.to_string()),
322 remote_addr: "127.0.0.1:12345".to_string(),
323 protocol: Protocol::Http1,
324 },
325 response: GatewayResponse {
326 status_code: 0,
327 headers: HashMap::new(),
328 body: Bytes::new(),
329 stream: None,
330 },
331 message: HashMap::new(),
332 errors: Vec::new(),
333 }
334 }
335
336 fn body_plugin(schema: serde_json::Value) -> RequestValidationPlugin {
337 let mut config = HashMap::new();
338 config.insert("body_schema".to_string(), schema);
339 RequestValidationPlugin::from_config(&config).unwrap()
340 }
341
342 #[tokio::test]
343 async fn test_request_validation_body_accept_and_normalize() {
344 let p = body_plugin(serde_json::json!({
345 "type": "object",
346 "required": ["name"],
347 "properties": { "name": { "type": "string" } }
348 }));
349 let ctx = test_context(r#"{"name": "jack"}"#, Some("application/json"));
350 let out = p.execute(ctx).await.unwrap();
351 assert_eq!(out.context.request.body, Bytes::from(r#"{"name":"jack"}"#));
353 assert!(!out.context.request.headers.contains_key("content-length"));
354 }
355
356 #[tokio::test]
357 async fn test_request_validation_body_reject() {
358 let p = body_plugin(serde_json::json!({
359 "type": "object",
360 "required": ["name"]
361 }));
362 let ctx = test_context(r#"{"age": 3}"#, Some("application/json"));
363 let out = p.execute(ctx).await.unwrap();
364 assert_eq!(out.port, Some("denied"));
365 assert_eq!(out.context.response.status_code, 400);
366 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
367 assert_eq!(body["error"], "validation_failed");
368 }
369
370 #[tokio::test]
371 async fn test_request_validation_non_json_body_rejected() {
372 let p = body_plugin(serde_json::json!({ "type": "object" }));
373 let ctx = test_context("this is not json", Some("application/json"));
374 let out = p.execute(ctx).await.unwrap();
375 assert_eq!(out.port, Some("denied"));
376 }
377
378 #[tokio::test]
379 async fn test_request_validation_missing_body_rejected() {
380 let p = body_plugin(serde_json::json!({ "type": "object" }));
381 let out = p
382 .execute(test_context("", Some("application/json")))
383 .await
384 .unwrap();
385 assert_eq!(out.port, Some("denied"));
386 }
387
388 #[tokio::test]
389 async fn test_request_validation_urlencoded_body() {
390 let p = body_plugin(serde_json::json!({
391 "type": "object",
392 "required": ["user"],
393 "properties": { "user": { "type": "string", "minLength": 2 } }
394 }));
395 let ctx = test_context(
396 "user=jack¬e=hello%20world",
397 Some("application/x-www-form-urlencoded"),
398 );
399 let out = p.execute(ctx).await.unwrap();
400 assert_eq!(
402 out.context.request.body,
403 Bytes::from("user=jack¬e=hello%20world")
404 );
405
406 let ctx = test_context("note=only", Some("application/x-www-form-urlencoded"));
407 assert_eq!(p.execute(ctx).await.unwrap().port, Some("denied"));
408 }
409
410 #[tokio::test]
411 async fn test_request_validation_header_schema() {
412 let mut config = HashMap::new();
413 config.insert(
414 "header_schema".to_string(),
415 serde_json::json!({
416 "type": "object",
417 "required": ["x-api-version"],
418 "properties": { "x-api-version": { "type": "string", "enum": ["2"] } }
419 }),
420 );
421 let p = RequestValidationPlugin::from_config(&config).unwrap();
422
423 assert!(p
424 .execute(test_context("", None))
425 .await
426 .unwrap()
427 .port
428 .is_none());
429
430 let mut ctx = test_context("", None);
431 ctx.request.headers.remove("x-api-version");
432 let out = p.execute(ctx).await.unwrap();
433 assert_eq!(out.port, Some("denied"));
434 }
435
436 #[tokio::test]
437 async fn test_request_validation_rejected_code_and_msg() {
438 let mut config = HashMap::new();
439 config.insert(
440 "body_schema".to_string(),
441 serde_json::json!({ "type": "object" }),
442 );
443 config.insert("rejected_code".to_string(), serde_json::json!(422));
444 config.insert("rejected_msg".to_string(), serde_json::json!("bad payload"));
445 let p = RequestValidationPlugin::from_config(&config).unwrap();
446
447 let ctx = test_context("[1,2,3]", Some("application/json"));
448 let out = p.execute(ctx).await.unwrap();
449 assert_eq!(out.port, Some("denied"));
450 assert_eq!(out.context.response.status_code, 422);
451 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
452 assert_eq!(body["message"], "bad payload");
453 }
454
455 #[tokio::test]
456 async fn test_request_validation_rejected_msg_renders_template() {
457 let mut config = HashMap::new();
458 config.insert(
459 "body_schema".to_string(),
460 serde_json::json!({ "type": "object", "required": ["name"] }),
461 );
462 config.insert(
463 "rejected_msg".to_string(),
464 serde_json::json!("bad payload for {{request.path}}"),
465 );
466 let p = RequestValidationPlugin::from_config(&config).unwrap();
467
468 let ctx = test_context(r#"{"age": 3}"#, Some("application/json"));
469 let out = p.execute(ctx).await.unwrap();
470 assert_eq!(out.port, Some("denied"));
471 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
472 assert_eq!(body["message"], "bad payload for /api");
473 }
474
475 #[test]
476 fn test_request_validation_config_rejections() {
477 assert!(RequestValidationPlugin::from_config(&HashMap::new()).is_err());
479
480 let mut config = HashMap::new();
482 config.insert(
483 "body_schema".to_string(),
484 serde_json::json!({ "type": "definitely-not-a-type" }),
485 );
486 assert!(RequestValidationPlugin::from_config(&config).is_err());
487
488 let mut config = HashMap::new();
490 config.insert("body_schema".to_string(), serde_json::json!("nope"));
491 assert!(RequestValidationPlugin::from_config(&config).is_err());
492
493 let mut config = HashMap::new();
495 config.insert(
496 "body_schema".to_string(),
497 serde_json::json!({ "type": "object" }),
498 );
499 config.insert("rejected_code".to_string(), serde_json::json!(199));
500 assert!(RequestValidationPlugin::from_config(&config).is_err());
501 }
502
503 #[test]
504 fn test_request_validation_decode_urlencoded_shape() {
505 let v = decode_urlencoded("a=1&a=2&flag¬e=hello+world");
506 assert_eq!(v["a"], serde_json::json!(["1", "2"]));
507 assert_eq!(v["flag"], serde_json::json!(true));
508 assert_eq!(v["note"], serde_json::json!("hello world"));
509 }
510
511 fn conditions_plugin(conditions: serde_json::Value) -> RequestValidationPlugin {
512 let mut config = HashMap::new();
513 config.insert("conditions".to_string(), conditions);
514 RequestValidationPlugin::from_config(&config).unwrap()
515 }
516
517 #[tokio::test]
518 async fn test_request_validation_conditions_accept() {
519 let p = conditions_plugin(serde_json::json!([
520 ["http_authorization", "present"],
521 ["http_authorization", "contains", "Bearer"],
522 [
523 "OR",
524 ["$.user.email", "present"],
525 ["NOT", ["$.user.id", "is_null"]]
526 ]
527 ]));
528 let mut ctx = test_context(r#"{"user":{"email":"a@b.c"}}"#, Some("application/json"));
529 ctx.request
530 .headers
531 .insert("authorization".to_string(), vec!["Bearer tok".to_string()]);
532 let out = p.execute(ctx).await.unwrap();
533 assert!(out.port.is_none());
534 }
535
536 #[tokio::test]
537 async fn test_request_validation_conditions_reject() {
538 let p = conditions_plugin(serde_json::json!([[
539 "http_authorization",
540 "contains",
541 "Bearer"
542 ]]));
543 let out = p.execute(test_context("", None)).await.unwrap();
544 assert_eq!(out.port, Some("denied"));
545 assert_eq!(out.context.response.status_code, 400);
546 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
547 assert_eq!(body["error"], "validation_failed");
548 assert_eq!(body["message"], "request conditions not satisfied");
549 }
550
551 #[tokio::test]
552 async fn test_request_validation_conditions_after_schema() {
553 let mut config = HashMap::new();
555 config.insert(
556 "body_schema".to_string(),
557 serde_json::json!({ "type": "object" }),
558 );
559 config.insert(
560 "conditions".to_string(),
561 serde_json::json!([["$.name", "==", "jack"]]),
562 );
563 let p = RequestValidationPlugin::from_config(&config).unwrap();
564
565 let ctx = test_context(r#"{"name": "jack"}"#, Some("application/json"));
566 assert!(p.execute(ctx).await.unwrap().port.is_none());
567
568 let ctx = test_context(r#"{"name": "jill"}"#, Some("application/json"));
569 assert_eq!(p.execute(ctx).await.unwrap().port, Some("denied"));
570 }
571
572 #[test]
573 fn test_request_validation_conditions_config() {
574 let mut config = HashMap::new();
576 config.insert(
577 "conditions".to_string(),
578 serde_json::json!([["http_x", "present"]]),
579 );
580 assert!(RequestValidationPlugin::from_config(&config).is_ok());
581
582 let mut config = HashMap::new();
584 config.insert(
585 "conditions".to_string(),
586 serde_json::json!([["$.a", "bogus_op", 1]]),
587 );
588 let err = RequestValidationPlugin::from_config(&config).unwrap_err();
589 assert!(
590 err.starts_with("request-validation: invalid 'conditions'"),
591 "{err}"
592 );
593 }
594}