1use std::collections::{BTreeMap, BTreeSet};
37use std::sync::Arc;
38
39use crate::behavior::BehaviorRule;
40use crate::custom_checks::{CustomRequestContext, CustomValidatorFn, ValidatorAnswer};
41use crate::headers_auth::AuthVerifier;
42use crate::route_config::RouteConfig;
43
44pub const VALID_BYPASS_CHECKS: [&str; 6] =
53 ["all", "ip_ban", "ip", "clouds", "rate_limit", "penetration"];
54
55pub const VALID_CLOUD_PROVIDERS: [&str; 6] =
59 ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"];
60
61#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct DecoratorError {
66 pub message: String,
68}
69
70impl std::fmt::Display for DecoratorError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 write!(f, "{}", self.message)
73 }
74}
75
76impl std::error::Error for DecoratorError {}
77
78fn conflict(message: &'static str) -> DecoratorError {
79 DecoratorError {
80 message: message.to_owned(),
81 }
82}
83
84#[derive(Clone)]
88pub struct SecurityDecorator {
89 config: crate::security_config::SecurityConfig,
90 route_configs: BTreeMap<String, RouteConfig>,
91 revision: u64,
92}
93
94impl core::fmt::Debug for SecurityDecorator {
95 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
96 f.debug_struct("SecurityDecorator")
97 .field("routes", &self.route_configs.len())
98 .field("revision", &self.revision)
99 .finish_non_exhaustive()
100 }
101}
102
103impl SecurityDecorator {
104 #[must_use]
107 pub const fn new(config: crate::security_config::SecurityConfig) -> Self {
108 Self {
109 config,
110 route_configs: BTreeMap::new(),
111 revision: 0,
112 }
113 }
114
115 #[must_use]
118 pub const fn route_config_revision(&self) -> u64 {
119 self.revision
120 }
121
122 #[must_use]
125 pub fn get_route_config(&self, route_id: &str) -> Option<&RouteConfig> {
126 self.route_configs.get(route_id)
127 }
128
129 pub fn route_ids(&self) -> impl Iterator<Item = &str> {
131 self.route_configs.keys().map(String::as_str)
132 }
133
134 pub fn ensure_route_config(&mut self, route_id: &str) -> &mut RouteConfig {
139 if !self.route_configs.contains_key(route_id) {
140 let mut config = RouteConfig::default();
141 config.enable_suspicious_detection = self.config.enable_penetration_detection;
142 self.route_configs.insert(route_id.to_owned(), config);
143 self.revision += 1;
144 }
145 self.route_configs
146 .get_mut(route_id)
147 .expect("the route was just ensured")
148 }
149
150 const fn bump(&mut self) {
151 self.revision += 1;
152 }
153
154 const fn bump_n(&mut self, times: usize) {
163 self.revision += times as u64;
164 }
165
166 pub fn require_ip(
169 &mut self,
170 route_id: &str,
171 whitelist: Option<Vec<String>>,
172 blacklist: Option<Vec<String>>,
173 ) -> &mut Self {
174 let route = self.ensure_route_config(route_id);
175 let mut assignments = 0;
176 if let Some(whitelist) = whitelist
177 && !whitelist.is_empty()
178 {
179 route.ip_whitelist = Some(whitelist);
180 assignments += 1;
181 }
182 if let Some(blacklist) = blacklist
183 && !blacklist.is_empty()
184 {
185 route.ip_blacklist = Some(blacklist);
186 assignments += 1;
187 }
188 self.bump_n(assignments);
189 self
190 }
191
192 pub fn block_countries(&mut self, route_id: &str, countries: &[&str]) -> &mut Self {
194 let route = self.ensure_route_config(route_id);
195 route.blocked_countries = Some(
196 countries
197 .iter()
198 .map(|country| country.to_ascii_uppercase())
199 .collect(),
200 );
201 self.bump();
202 self
203 }
204
205 pub fn allow_countries(&mut self, route_id: &str, countries: &[&str]) -> &mut Self {
207 let route = self.ensure_route_config(route_id);
208 route.whitelist_countries = Some(
209 countries
210 .iter()
211 .map(|country| country.to_ascii_uppercase())
212 .collect(),
213 );
214 self.bump();
215 self
216 }
217
218 pub fn block_clouds(&mut self, route_id: &str, providers: Option<Vec<String>>) -> Vec<String> {
223 let route = self.ensure_route_config(route_id);
224 let ignored = match providers {
225 None => {
226 route.block_cloud_providers = VALID_CLOUD_PROVIDERS
227 .iter()
228 .map(|&p| p.to_owned())
229 .collect();
230 BTreeSet::new()
231 }
232 Some(providers) => {
233 let mut ignored: BTreeSet<String> = BTreeSet::new();
234 let valid: BTreeSet<String> = providers
235 .into_iter()
236 .filter(|provider| {
237 let base = provider
238 .split_once(":!")
239 .map_or(provider.as_str(), |(base, _)| base);
240 let known = VALID_CLOUD_PROVIDERS.contains(&base);
241 if !known {
242 ignored.insert(provider.clone());
243 }
244 known
245 })
246 .collect();
247 route.block_cloud_providers = valid;
248 ignored
249 }
250 };
251 self.bump();
252 ignored.into_iter().collect()
253 }
254
255 pub fn bypass(&mut self, route_id: &str, checks: &[&str]) -> Vec<String> {
260 let route = self.ensure_route_config(route_id);
261 let mut ignored: BTreeSet<String> = BTreeSet::new();
262 for check in checks {
263 if VALID_BYPASS_CHECKS.contains(check) {
264 route.bypassed_checks.insert((*check).to_owned());
265 } else {
266 ignored.insert((*check).to_owned());
267 }
268 }
269 ignored.into_iter().collect()
270 }
271
272 pub fn time_window(
275 &mut self,
276 route_id: &str,
277 start_time: &str,
278 end_time: &str,
279 timezone: &str,
280 ) -> &mut Self {
281 let route = self.ensure_route_config(route_id);
282 route.time_restrictions = Some(BTreeMap::from([
283 (String::from("start"), start_time.to_owned()),
284 (String::from("end"), end_time.to_owned()),
285 (String::from("timezone"), timezone.to_owned()),
286 ]));
287 self.bump();
288 self
289 }
290
291 pub fn suspicious_detection(&mut self, route_id: &str, enabled: bool) -> &mut Self {
293 self.ensure_route_config(route_id)
294 .enable_suspicious_detection = enabled;
295 self.bump();
296 self
297 }
298
299 pub fn honeypot_detection(&mut self, route_id: &str, trap_fields: &[&str]) -> &mut Self {
304 let traps: Vec<String> = trap_fields
305 .iter()
306 .map(|field| (*field).to_owned())
307 .collect();
308 let validator: CustomValidatorFn =
309 Arc::new(move |context: &CustomRequestContext<'_>| honeypot_validator(&traps, context));
310 self.ensure_route_config(route_id)
311 .custom_validators
312 .push(validator);
313 self.bump();
314 self
315 }
316
317 pub fn require_https(&mut self, route_id: &str) -> &mut Self {
319 self.ensure_route_config(route_id).require_https = true;
320 self.bump();
321 self
322 }
323
324 pub fn require_auth(
332 &mut self,
333 route_id: &str,
334 auth_type: &str,
335 verifier: Option<AuthVerifier>,
336 ) -> Result<&mut Self, DecoratorError> {
337 let route = self.ensure_route_config(route_id);
338 if route.authorization_header_required.is_some() {
339 return Err(conflict(
340 "require_auth cannot be combined with require_authorization_header; the latter is presence-only and mutually exclusive with authenticated routes",
341 ));
342 }
343 route.auth_required = Some(auth_type.to_owned());
344 route.auth_verifier = verifier;
345 self.bump_n(2);
346 Ok(self)
347 }
348
349 pub fn api_key_auth(
358 &mut self,
359 route_id: &str,
360 header_name: &str,
361 verifier: Option<AuthVerifier>,
362 ) -> Result<&mut Self, DecoratorError> {
363 let route = self.ensure_route_config(route_id);
364 if route.authorization_header_required.is_some() {
365 return Err(conflict(
366 "api_key_auth cannot be combined with require_authorization_header; the latter is presence-only and mutually exclusive with authenticated routes",
367 ));
368 }
369 route.api_key_required = true;
370 route
371 .required_headers
372 .insert(header_name.to_owned(), String::from("required"));
373 route.api_key_header = Some(header_name.to_owned());
374 route.api_key_verifier = verifier;
375 self.bump_n(4);
376 Ok(self)
377 }
378
379 pub fn require_authorization_header(
386 &mut self,
387 route_id: &str,
388 scheme: &str,
389 ) -> Result<&mut Self, DecoratorError> {
390 let route = self.ensure_route_config(route_id);
391 if route.auth_required.is_some() || route.api_key_required {
392 return Err(conflict(
393 "require_authorization_header cannot be combined with require_auth or api_key_auth; it is presence-only and mutually exclusive with authenticated routes",
394 ));
395 }
396 route.authorization_header_required = Some(scheme.to_owned());
397 self.bump();
398 Ok(self)
399 }
400
401 pub fn require_headers(
403 &mut self,
404 route_id: &str,
405 headers: BTreeMap<String, String>,
406 ) -> &mut Self {
407 self.ensure_route_config(route_id)
408 .required_headers
409 .extend(headers);
410 self.bump();
411 self
412 }
413
414 pub fn block_user_agents(&mut self, route_id: &str, patterns: &[&str]) -> &mut Self {
416 let route = self.ensure_route_config(route_id);
417 route
418 .blocked_user_agents
419 .extend(patterns.iter().map(|pattern| (*pattern).to_owned()));
420 self.bump();
421 self
422 }
423
424 pub fn usage_monitor(
427 &mut self,
428 route_id: &str,
429 max_calls: u32,
430 window: u64,
431 action: &str,
432 ) -> &mut Self {
433 let route = self.ensure_route_config(route_id);
434 route.behavior_rules.push(BehaviorRule {
435 rule_type: String::from("usage"),
436 threshold: max_calls,
437 window,
438 pattern: String::new(),
439 action: action.to_owned(),
440 ban_duration: None,
441 correlate_with_detection: false,
442 });
443 self
444 }
445
446 pub fn return_monitor(
453 &mut self,
454 route_id: &str,
455 pattern: &str,
456 max_occurrences: u32,
457 window: u64,
458 action: &str,
459 ) -> Result<&mut Self, DecoratorError> {
460 validate_return_pattern(pattern)?;
461 let route = self.ensure_route_config(route_id);
462 route.behavior_rules.push(BehaviorRule {
463 rule_type: String::from("return_pattern"),
464 threshold: max_occurrences,
465 window,
466 pattern: pattern.to_owned(),
467 action: action.to_owned(),
468 ban_duration: None,
469 correlate_with_detection: false,
470 });
471 Ok(self)
472 }
473
474 pub fn behavior_analysis(
482 &mut self,
483 route_id: &str,
484 rules: Vec<BehaviorRule>,
485 ) -> Result<&mut Self, DecoratorError> {
486 for rule in &rules {
487 if rule.rule_type == "return_pattern" && !rule.pattern.is_empty() {
488 validate_return_pattern(&rule.pattern)?;
489 }
490 }
491 let route = self.ensure_route_config(route_id);
492 route.behavior_rules.extend(rules);
493 Ok(self)
494 }
495
496 pub fn suspicious_frequency(
500 &mut self,
501 route_id: &str,
502 max_frequency: f64,
503 window: u64,
504 action: &str,
505 ) -> &mut Self {
506 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
507 let max_calls = (max_frequency * window as f64) as u32;
509 let route = self.ensure_route_config(route_id);
510 route.behavior_rules.push(BehaviorRule {
511 rule_type: String::from("frequency"),
512 threshold: max_calls,
513 window,
514 pattern: String::new(),
515 action: action.to_owned(),
516 ban_duration: None,
517 correlate_with_detection: false,
518 });
519 self
520 }
521
522 pub fn content_type_filter(&mut self, route_id: &str, allowed_types: &[&str]) -> &mut Self {
524 self.ensure_route_config(route_id).allowed_content_types =
525 Some(allowed_types.iter().map(|ty| (*ty).to_owned()).collect());
526 self.bump();
527 self
528 }
529
530 pub fn max_request_size(&mut self, route_id: &str, size_bytes: u64) -> &mut Self {
532 self.ensure_route_config(route_id).max_request_size = Some(size_bytes);
533 self.bump();
534 self
535 }
536
537 pub fn require_referrer(&mut self, route_id: &str, allowed_domains: &[&str]) -> &mut Self {
539 self.ensure_route_config(route_id).require_referrer = Some(
540 allowed_domains
541 .iter()
542 .map(|domain| (*domain).to_owned())
543 .collect(),
544 );
545 self.bump();
546 self
547 }
548
549 pub fn custom_validation(&mut self, route_id: &str, validator: CustomValidatorFn) -> &mut Self {
551 self.ensure_route_config(route_id)
552 .custom_validators
553 .push(validator);
554 self.bump();
555 self
556 }
557
558 pub fn detection_exclusion(
561 &mut self,
562 route_id: &str,
563 headers: Option<BTreeSet<String>>,
564 params: Option<BTreeSet<String>>,
565 body_fields: Option<BTreeSet<String>>,
566 categories: Option<BTreeSet<String>>,
567 scan_body: Option<bool>,
568 ) -> &mut Self {
569 let present = [
570 headers.is_some(),
571 params.is_some(),
572 body_fields.is_some(),
573 categories.is_some(),
574 scan_body.is_some(),
575 ]
576 .into_iter()
577 .filter(|present| *present)
578 .count();
579 let route = self.ensure_route_config(route_id);
580 if let Some(headers) = headers {
581 route.excluded_detection_headers = Some(headers);
582 }
583 if let Some(params) = params {
584 route.excluded_detection_params = Some(params);
585 }
586 if let Some(body_fields) = body_fields {
587 route.excluded_detection_body_fields = Some(body_fields);
588 }
589 if let Some(categories) = categories {
590 route.enabled_detection_categories = Some(categories);
591 }
592 if let Some(scan_body) = scan_body {
593 route.detection_scan_body = Some(scan_body);
594 }
595 self.bump_n(present);
596 self
597 }
598
599 pub fn rate_limit(&mut self, route_id: &str, requests: u32, window: u64) -> &mut Self {
601 let route = self.ensure_route_config(route_id);
602 route.rate_limit = Some(requests);
603 route.rate_limit_window = Some(window);
604 self.bump_n(2);
605 self
606 }
607
608 pub fn geo_rate_limit(
610 &mut self,
611 route_id: &str,
612 limits: BTreeMap<String, (u32, u64)>,
613 ) -> &mut Self {
614 self.ensure_route_config(route_id).geo_rate_limits = Some(limits);
615 self.bump();
616 self
617 }
618}
619
620fn validate_return_pattern(pattern: &str) -> Result<(), DecoratorError> {
625 crate::compiler::compile(pattern).map_err(|error| DecoratorError {
626 message: format!("return_pattern does not compile ({error})"),
627 })?;
628 Ok(())
629}
630
631fn percent_decode(component: &str) -> String {
634 let plus_decoded = component.replace('+', " ");
635 let bytes = plus_decoded.as_bytes();
636 let mut out = Vec::with_capacity(bytes.len());
637 let mut index = 0;
638 while index < bytes.len() {
639 if bytes[index] == b'%'
640 && index + 2 < bytes.len()
641 && let Ok(byte) = u8::from_str_radix(
642 std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""),
643 16,
644 )
645 {
646 out.push(byte);
647 index += 3;
648 } else {
649 out.push(bytes[index]);
650 index += 1;
651 }
652 }
653 String::from_utf8_lossy(&out).into_owned()
654}
655
656fn honeypot_validator(
660 trap_fields: &[String],
661 context: &CustomRequestContext<'_>,
662) -> Option<ValidatorAnswer> {
663 if context.method != "POST" && context.method != "PUT" && context.method != "PATCH" {
664 return None;
665 }
666 let body = context.body?;
667 let filled = |data: &BTreeMap<String, String>| {
668 trap_fields
669 .iter()
670 .any(|field| data.get(field).is_some_and(|value| !value.is_empty()))
671 };
672
673 let mut form: BTreeMap<String, String> = BTreeMap::new();
675 for pair in body.split('&').filter(|pair| !pair.is_empty()) {
676 let (name, value) = match pair.split_once('=') {
677 Some((name, value)) => (name, value),
678 None => (pair, ""),
679 };
680 form.entry(percent_decode(name))
681 .or_insert_with(|| percent_decode(value));
682 }
683 if filled(&form) {
684 return Some(ValidatorAnswer::Response(
685 crate::custom_checks::CustomResponse {
686 status: Some(403),
687 body: Some(String::from("Forbidden")),
688 },
689 ));
690 }
691
692 if let Ok(value) = serde_json::from_str::<serde_json::Value>(body)
695 && let Some(object) = value.as_object()
696 {
697 let json_fields: BTreeMap<String, String> = object
698 .iter()
699 .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_owned())))
700 .collect();
701 if filled(&json_fields) {
702 return Some(ValidatorAnswer::Response(
703 crate::custom_checks::CustomResponse {
704 status: Some(403),
705 body: Some(String::from("Forbidden")),
706 },
707 ));
708 }
709 }
710 None
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::custom_checks::CustomResponse;
717 use crate::security_config::SecurityConfig;
718
719 fn decorator() -> SecurityDecorator {
721 SecurityDecorator::new(SecurityConfig::default())
722 }
723
724 #[test]
725 fn first_touch_seeds_the_reference_default_and_bumps_the_revision() {
726 let mut decorator = decorator();
727 assert_eq!(decorator.route_config_revision(), 0);
728 let config = decorator.ensure_route_config("GET /login");
729 assert!(
730 config.enable_suspicious_detection,
731 "the config's enable_penetration_detection default is true"
732 );
733
734 let mut decorator = SecurityDecorator::new(SecurityConfig {
735 enable_penetration_detection: false,
736 ..SecurityConfig::default()
737 });
738 let config = decorator.ensure_route_config("GET /login");
739 assert!(!config.enable_suspicious_detection);
740 }
741
742 #[test]
743 fn ensure_is_idempotent_per_route() {
744 let mut decorator = decorator();
745 decorator.ensure_route_config("GET /login").rate_limit = Some(5);
746 let again = decorator.ensure_route_config("GET /login");
747 assert_eq!(again.rate_limit, Some(5));
748 assert!(decorator.get_route_config("GET /signup").is_none());
749 assert!(decorator.route_ids().eq(["GET /login"]));
750 }
751
752 #[test]
753 fn require_ip_sets_both_sides_and_ignores_empties() {
754 let mut decorator = decorator();
755 decorator.require_ip(
756 "GET /login",
757 Some(vec![String::from("10.0.0.0/8")]),
758 Some(vec![String::from("203.0.113.9")]),
759 );
760 let route = decorator.get_route_config("GET /login").expect("route");
761 assert_eq!(
762 route.ip_whitelist.as_deref(),
763 Some([String::from("10.0.0.0/8")].as_slice())
764 );
765 assert_eq!(
766 route.ip_blacklist.as_deref(),
767 Some([String::from("203.0.113.9")].as_slice())
768 );
769
770 decorator.require_ip("GET /signup", None, Some(Vec::new()));
771 let route = decorator.get_route_config("GET /signup").expect("route");
772 assert_eq!(route.ip_whitelist, None);
773 assert_eq!(route.ip_blacklist, None);
774 }
775
776 #[test]
777 fn country_decorators_uppercase() {
778 let mut decorator = decorator();
779 decorator
780 .block_countries("GET /eu", &["br", "de"])
781 .allow_countries("GET /eu", &["us"]);
782 let route = decorator.get_route_config("GET /eu").expect("route");
783 assert_eq!(
784 route.blocked_countries.as_deref(),
785 Some([String::from("BR"), String::from("DE")].as_slice())
786 );
787 assert_eq!(
788 route.whitelist_countries.as_deref(),
789 Some([String::from("US")].as_slice())
790 );
791 }
792
793 #[test]
794 fn block_clouds_blocks_everything_or_filters_unknowns() {
795 let mut decorator = decorator();
796 let ignored = decorator.block_clouds("GET /api", None);
797 assert!(ignored.is_empty());
798 let route = decorator.get_route_config("GET /api").expect("route");
799 assert_eq!(
800 route.block_cloud_providers.len(),
801 VALID_CLOUD_PROVIDERS.len()
802 );
803
804 let ignored = decorator.block_clouds(
805 "GET /api2",
806 Some(vec![
807 String::from("AWS"),
808 String::from("NotACloud"),
809 String::from("AWS:!prod"),
810 ]),
811 );
812 assert_eq!(ignored, [String::from("NotACloud")]);
813 let route = decorator.get_route_config("GET /api2").expect("route");
814 assert!(route.block_cloud_providers.contains("AWS"));
817 assert!(route.block_cloud_providers.contains("AWS:!prod"));
818 assert_eq!(route.block_cloud_providers.len(), 2);
819 }
820
821 #[test]
822 fn bypass_keeps_only_the_reference_names_and_unions() {
823 let mut decorator = decorator();
824 let ignored =
825 decorator.bypass("GET /internal", &["ip", "rate_limit", "all", "not_a_check"]);
826 assert_eq!(ignored, [String::from("not_a_check")]);
827 let ignored2 = decorator.bypass("GET /internal", &["ip_ban", "mystery"]);
828 assert_eq!(ignored2, [String::from("mystery")]);
829 let route = decorator.get_route_config("GET /internal").expect("route");
830 assert!(route.bypassed_checks.contains("all"));
831 assert!(route.bypassed_checks.contains("ip"));
832 assert!(route.bypassed_checks.contains("ip_ban"));
833 assert!(route.bypassed_checks.contains("rate_limit"));
834 }
835
836 #[test]
837 fn the_bypass_vocabulary_is_the_reference_set() {
838 assert_eq!(
839 VALID_BYPASS_CHECKS,
840 ["all", "ip_ban", "ip", "clouds", "rate_limit", "penetration"]
841 );
842 assert_eq!(
843 VALID_CLOUD_PROVIDERS,
844 ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"]
845 );
846 }
847
848 #[test]
849 fn time_window_and_suspicious_detection_set_their_knobs() {
850 let mut decorator = decorator();
851 decorator
852 .time_window("GET /night", "22:00", "06:00", "UTC")
853 .suspicious_detection("GET /night", false);
854 let route = decorator.get_route_config("GET /night").expect("route");
855 let restrictions = route.time_restrictions.as_ref().expect("restrictions");
856 assert_eq!(restrictions.get("start").map(String::as_str), Some("22:00"));
857 assert_eq!(restrictions.get("end").map(String::as_str), Some("06:00"));
858 assert_eq!(
859 restrictions.get("timezone").map(String::as_str),
860 Some("UTC")
861 );
862 assert!(!route.enable_suspicious_detection);
863 }
864
865 #[test]
866 fn the_honeypot_validator_answers_403_on_a_filled_trap() {
867 let mut decorator = decorator();
868 decorator.honeypot_detection("POST /form", &["website", "bot_field"]);
869 let route = decorator.get_route_config("POST /form").expect("route");
870 assert_eq!(route.custom_validators.len(), 1);
871 let validator = route.custom_validators[0].clone();
872
873 let context = CustomRequestContext {
875 method: "POST",
876 path: "/form",
877 client_ip: None,
878 body: Some("name=renn&website=https://spam.example"),
879 };
880 assert_eq!(
881 validator(&context),
882 Some(ValidatorAnswer::Response(CustomResponse {
883 status: Some(403),
884 body: Some(String::from("Forbidden")),
885 }))
886 );
887
888 let context = CustomRequestContext {
891 method: "POST",
892 path: "/form",
893 client_ip: None,
894 body: Some("name=renn&website="),
895 };
896 assert!(validator(&context).is_none());
897 let context = CustomRequestContext {
898 method: "POST",
899 path: "/form",
900 client_ip: None,
901 body: Some("website=https%3A%2F%2Fspam.example&name=renn"),
902 };
903 assert_eq!(
904 validator(&context),
905 Some(ValidatorAnswer::Response(CustomResponse {
906 status: Some(403),
907 body: Some(String::from("Forbidden")),
908 }))
909 );
910 let context = CustomRequestContext {
914 method: "POST",
915 path: "/form",
916 client_ip: None,
917 body: Some("website=%zz&name=renn"),
918 };
919 assert!(validator(&context).is_some());
920
921 let context = CustomRequestContext {
923 method: "POST",
924 path: "/form",
925 client_ip: None,
926 body: Some(r#"{"name":"renn","bot_field":"gotcha"}"#),
927 };
928 assert!(validator(&context).is_some());
929
930 let context = CustomRequestContext {
933 method: "POST",
934 path: "/form",
935 client_ip: None,
936 body: Some(r#"{"name":"renn","bot_field":""}"#),
937 };
938 assert!(validator(&context).is_none());
939
940 let context = CustomRequestContext {
942 method: "POST",
943 path: "/form",
944 client_ip: None,
945 body: Some("not json"),
946 };
947 assert!(validator(&context).is_none());
948
949 let context = CustomRequestContext {
951 method: "GET",
952 path: "/form",
953 client_ip: None,
954 body: Some("website=x"),
955 };
956 assert!(validator(&context).is_none());
957
958 let context = CustomRequestContext {
960 method: "POST",
961 path: "/form",
962 client_ip: None,
963 body: None,
964 };
965 assert!(validator(&context).is_none());
966 }
967
968 #[test]
969 fn the_auth_trio_enforces_the_reference_mutual_exclusions() {
970 let mut first = decorator();
971 first
972 .require_auth("GET /private", "bearer", None)
973 .expect("no conflict");
974 let error = first
975 .require_authorization_header("GET /private", "bearer")
976 .unwrap_err();
977 assert!(
978 error
979 .message
980 .contains("require_authorization_header cannot be combined with require_auth"),
981 "{error}"
982 );
983
984 let mut second = decorator();
985 second
986 .require_authorization_header("GET /public", "basic")
987 .expect("no conflict");
988 let error = second
989 .api_key_auth("GET /public", "X-API-Key", None)
990 .unwrap_err();
991 assert!(
992 error
993 .message
994 .contains("api_key_auth cannot be combined with require_authorization_header"),
995 "{error}"
996 );
997 let error = second
998 .require_auth("GET /public", "bearer", None)
999 .unwrap_err();
1000 assert!(
1001 error
1002 .message
1003 .contains("require_auth cannot be combined with require_authorization_header"),
1004 "{error}"
1005 );
1006 }
1007
1008 #[test]
1009 fn api_key_auth_sets_the_reference_fields() {
1010 let mut decorator = decorator();
1011 decorator
1012 .api_key_auth("GET /partner", "X-API-Key", None)
1013 .expect("no conflict");
1014 let route = decorator.get_route_config("GET /partner").expect("route");
1015 assert!(route.api_key_required);
1016 assert_eq!(
1017 route.required_headers.get("X-API-Key").map(String::as_str),
1018 Some("required")
1019 );
1020 assert_eq!(route.api_key_header.as_deref(), Some("X-API-Key"));
1021 }
1022
1023 #[test]
1024 fn require_headers_merges_and_require_https_flags() {
1025 let mut decorator = decorator();
1026 decorator
1027 .require_https("GET /strict")
1028 .require_headers(
1029 "GET /strict",
1030 BTreeMap::from([(String::from("x-tenant"), String::from("required"))]),
1031 )
1032 .require_headers(
1033 "GET /strict",
1034 BTreeMap::from([(String::from("x-region"), String::from("eu"))]),
1035 );
1036 let route = decorator.get_route_config("GET /strict").expect("route");
1037 assert!(route.require_https);
1038 assert_eq!(route.required_headers.len(), 2);
1039 }
1040
1041 #[test]
1042 fn the_behavioral_factories_build_the_reference_rules() {
1043 let mut decorator = decorator();
1044 decorator
1045 .usage_monitor("GET /quota", 100, 3600, "ban")
1046 .suspicious_frequency("GET /quota", 0.5, 300, "alert");
1047 let route = decorator.get_route_config("GET /quota").expect("route");
1048 assert_eq!(route.behavior_rules.len(), 2);
1049 assert_eq!(route.behavior_rules[0].rule_type, "usage");
1050 assert_eq!(route.behavior_rules[0].threshold, 100);
1051 assert_eq!(route.behavior_rules[1].rule_type, "frequency");
1052 assert_eq!(route.behavior_rules[1].threshold, 150);
1054
1055 decorator
1056 .return_monitor("GET /quota", "status:500", 3, 86_400, "ban")
1057 .expect("compilable pattern");
1058 let route = decorator.get_route_config("GET /quota").expect("route");
1059 assert_eq!(route.behavior_rules.len(), 3);
1060 assert_eq!(route.behavior_rules[2].rule_type, "return_pattern");
1061
1062 let error = decorator
1063 .return_monitor("GET /quota", "([", 3, 60, "ban")
1064 .unwrap_err();
1065 assert!(error.message.contains("does not compile"), "{error}");
1066
1067 let rules = vec![BehaviorRule {
1068 rule_type: String::from("return_pattern"),
1069 threshold: 2,
1070 window: 60,
1071 pattern: String::from("(["),
1072 action: String::from("ban"),
1073 ban_duration: None,
1074 correlate_with_detection: false,
1075 }];
1076 let error = decorator
1077 .behavior_analysis("GET /quota", rules)
1078 .unwrap_err();
1079 assert!(error.message.contains("does not compile"), "{error}");
1080
1081 let rules = vec![BehaviorRule {
1082 rule_type: String::from("usage"),
1083 threshold: 5,
1084 window: 60,
1085 pattern: String::new(),
1086 action: String::from("log"),
1087 ban_duration: None,
1088 correlate_with_detection: false,
1089 }];
1090 decorator
1091 .behavior_analysis("GET /quota", rules)
1092 .expect("usage rules carry no pattern");
1093 let route = decorator.get_route_config("GET /quota").expect("route");
1094 assert_eq!(route.behavior_rules.len(), 4);
1095 }
1096
1097 #[test]
1098 fn the_content_filtering_factories_set_their_knobs() {
1099 let mut decorator = decorator();
1100 decorator
1101 .block_user_agents("POST /upload", &["sqlmap", "havij"])
1102 .content_type_filter("POST /upload", &["application/json"])
1103 .max_request_size("POST /upload", 1_048_576)
1104 .require_referrer("POST /upload", &["https://app.test"]);
1105 let route = decorator.get_route_config("POST /upload").expect("route");
1106 assert_eq!(route.blocked_user_agents, ["sqlmap", "havij"]);
1107 assert_eq!(
1108 route.allowed_content_types.as_deref(),
1109 Some([String::from("application/json")].as_slice())
1110 );
1111 assert_eq!(route.max_request_size, Some(1_048_576));
1112 assert_eq!(
1113 route.require_referrer.as_deref(),
1114 Some([String::from("https://app.test")].as_slice())
1115 );
1116 }
1117
1118 #[test]
1119 fn detection_exclusion_sets_only_the_present_knobs() {
1120 let mut decorator = decorator();
1121 decorator.detection_exclusion(
1122 "POST /scan-me",
1123 Some(BTreeSet::from([String::from("x-api-key")])),
1124 None,
1125 None,
1126 Some(BTreeSet::from([String::from("xss")])),
1127 Some(false),
1128 );
1129 let route = decorator.get_route_config("POST /scan-me").expect("route");
1130 assert_eq!(
1131 route.excluded_detection_headers,
1132 Some(BTreeSet::from([String::from("x-api-key")]))
1133 );
1134 assert_eq!(route.excluded_detection_params, None);
1135 assert_eq!(route.excluded_detection_body_fields, None);
1136 assert_eq!(
1137 route.enabled_detection_categories,
1138 Some(BTreeSet::from([String::from("xss")]))
1139 );
1140 assert_eq!(route.detection_scan_body, Some(false));
1141 }
1142
1143 #[test]
1144 fn the_rate_limiting_factories_set_their_tiers() {
1145 let mut decorator = decorator();
1146 decorator.rate_limit("POST /login", 5, 60).geo_rate_limit(
1147 "POST /login",
1148 BTreeMap::from([(String::from("BR"), (10, 30))]),
1149 );
1150 let route = decorator.get_route_config("POST /login").expect("route");
1151 assert_eq!(route.rate_limit, Some(5));
1152 assert_eq!(route.rate_limit_window, Some(60));
1153 let limits = route.geo_rate_limits.as_ref().expect("limits");
1154 assert_eq!(limits.get("BR"), Some(&(10, 30)));
1155
1156 assert!(route.rate_limits().expect("valid tier").is_some());
1159 }
1160
1161 #[test]
1162 fn custom_validation_appends_the_validator() {
1163 let mut decorator = decorator();
1164 let validator: CustomValidatorFn = Arc::new(|_context: &CustomRequestContext<'_>| None);
1165 decorator.custom_validation("GET /checked", validator);
1166 let route = decorator.get_route_config("GET /checked").expect("route");
1167 assert_eq!(route.custom_validators.len(), 1);
1168 }
1169
1170 #[test]
1171 fn detection_exclusion_sets_the_params_and_body_field_knobs() {
1172 let mut decorator = decorator();
1173 decorator.detection_exclusion(
1174 "POST /scan-me",
1175 None,
1176 Some(BTreeSet::from([String::from("token")])),
1177 Some(BTreeSet::from([String::from("password")])),
1178 None,
1179 None,
1180 );
1181 let route = decorator.get_route_config("POST /scan-me").expect("route");
1182 assert_eq!(
1183 route.excluded_detection_params,
1184 Some(BTreeSet::from([String::from("token")]))
1185 );
1186 assert_eq!(
1187 route.excluded_detection_body_fields,
1188 Some(BTreeSet::from([String::from("password")]))
1189 );
1190 }
1191
1192 #[test]
1193 fn the_error_display_and_the_decorator_debug_render() {
1194 let error = conflict("test message");
1195 assert_eq!(error.to_string(), "test message");
1196 assert!(std::error::Error::source(&error).is_none());
1197
1198 let mut decorator = decorator();
1199 decorator.rate_limit("GET /login", 5, 60);
1200 let rendered = format!("{decorator:?}");
1201 assert!(rendered.starts_with("SecurityDecorator"), "{rendered}");
1202 assert!(rendered.contains("routes: 1"), "{rendered}");
1203 assert!(rendered.contains("revision: "), "{rendered}");
1204 }
1205
1206 #[test]
1207 fn every_mutation_bumps_the_revision() {
1208 let mut decorator = decorator();
1209 let before = decorator.route_config_revision();
1210 decorator.rate_limit("GET /login", 5, 60);
1211 decorator.require_https("GET /login");
1212 decorator.bypass("GET /login", &["ip"]);
1215 let after = decorator.route_config_revision();
1216 assert_eq!(after - before, 4);
1219 }
1220}