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 #[allow(clippy::too_many_arguments)]
562 pub fn detection_exclusion(
563 &mut self,
564 route_id: &str,
565 headers: Option<BTreeSet<String>>,
566 params: Option<BTreeSet<String>>,
567 body_fields: Option<BTreeSet<String>>,
568 categories: Option<BTreeSet<String>>,
569 scan_body: Option<bool>,
570 semantic_headers: Option<BTreeSet<String>>,
571 ) -> &mut Self {
572 let present = [
573 headers.is_some(),
574 semantic_headers.is_some(),
575 params.is_some(),
576 body_fields.is_some(),
577 categories.is_some(),
578 scan_body.is_some(),
579 ]
580 .into_iter()
581 .filter(|present| *present)
582 .count();
583 let route = self.ensure_route_config(route_id);
584 if let Some(headers) = headers {
585 route.excluded_detection_headers = Some(headers);
586 }
587 if let Some(semantic_headers) = semantic_headers {
588 route.excluded_semantic_detection_headers = Some(semantic_headers);
589 }
590 if let Some(params) = params {
591 route.excluded_detection_params = Some(params);
592 }
593 if let Some(body_fields) = body_fields {
594 route.excluded_detection_body_fields = Some(body_fields);
595 }
596 if let Some(categories) = categories {
597 route.enabled_detection_categories = Some(categories);
598 }
599 if let Some(scan_body) = scan_body {
600 route.detection_scan_body = Some(scan_body);
601 }
602 self.bump_n(present);
603 self
604 }
605
606 pub fn rate_limit(&mut self, route_id: &str, requests: u32, window: u64) -> &mut Self {
608 let route = self.ensure_route_config(route_id);
609 route.rate_limit = Some(requests);
610 route.rate_limit_window = Some(window);
611 self.bump_n(2);
612 self
613 }
614
615 pub fn geo_rate_limit(
617 &mut self,
618 route_id: &str,
619 limits: BTreeMap<String, (u32, u64)>,
620 ) -> &mut Self {
621 self.ensure_route_config(route_id).geo_rate_limits = Some(limits);
622 self.bump();
623 self
624 }
625}
626
627fn validate_return_pattern(pattern: &str) -> Result<(), DecoratorError> {
632 crate::compiler::compile(pattern).map_err(|error| DecoratorError {
633 message: format!("return_pattern does not compile ({error})"),
634 })?;
635 Ok(())
636}
637
638fn percent_decode(component: &str) -> String {
641 let plus_decoded = component.replace('+', " ");
642 let bytes = plus_decoded.as_bytes();
643 let mut out = Vec::with_capacity(bytes.len());
644 let mut index = 0;
645 while index < bytes.len() {
646 if bytes[index] == b'%'
647 && index + 2 < bytes.len()
648 && let Ok(byte) = u8::from_str_radix(
649 std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""),
650 16,
651 )
652 {
653 out.push(byte);
654 index += 3;
655 } else {
656 out.push(bytes[index]);
657 index += 1;
658 }
659 }
660 String::from_utf8_lossy(&out).into_owned()
661}
662
663fn honeypot_validator(
667 trap_fields: &[String],
668 context: &CustomRequestContext<'_>,
669) -> Option<ValidatorAnswer> {
670 if context.method != "POST" && context.method != "PUT" && context.method != "PATCH" {
671 return None;
672 }
673 let body = context.body?;
674 let filled = |data: &BTreeMap<String, String>| {
675 trap_fields
676 .iter()
677 .any(|field| data.get(field).is_some_and(|value| !value.is_empty()))
678 };
679
680 let mut form: BTreeMap<String, String> = BTreeMap::new();
682 for pair in body.split('&').filter(|pair| !pair.is_empty()) {
683 let (name, value) = match pair.split_once('=') {
684 Some((name, value)) => (name, value),
685 None => (pair, ""),
686 };
687 form.entry(percent_decode(name))
688 .or_insert_with(|| percent_decode(value));
689 }
690 if filled(&form) {
691 return Some(ValidatorAnswer::Response(
692 crate::custom_checks::CustomResponse {
693 status: Some(403),
694 body: Some(String::from("Forbidden")),
695 },
696 ));
697 }
698
699 if let Ok(value) = serde_json::from_str::<serde_json::Value>(body)
702 && let Some(object) = value.as_object()
703 {
704 let json_fields: BTreeMap<String, String> = object
705 .iter()
706 .filter_map(|(key, value)| value.as_str().map(|value| (key.clone(), value.to_owned())))
707 .collect();
708 if filled(&json_fields) {
709 return Some(ValidatorAnswer::Response(
710 crate::custom_checks::CustomResponse {
711 status: Some(403),
712 body: Some(String::from("Forbidden")),
713 },
714 ));
715 }
716 }
717 None
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::custom_checks::CustomResponse;
724 use crate::security_config::SecurityConfig;
725
726 fn decorator() -> SecurityDecorator {
728 SecurityDecorator::new(SecurityConfig::default())
729 }
730
731 #[test]
732 fn first_touch_seeds_the_reference_default_and_bumps_the_revision() {
733 let mut decorator = decorator();
734 assert_eq!(decorator.route_config_revision(), 0);
735 let config = decorator.ensure_route_config("GET /login");
736 assert!(
737 config.enable_suspicious_detection,
738 "the config's enable_penetration_detection default is true"
739 );
740
741 let mut decorator = SecurityDecorator::new(SecurityConfig {
742 enable_penetration_detection: false,
743 ..SecurityConfig::default()
744 });
745 let config = decorator.ensure_route_config("GET /login");
746 assert!(!config.enable_suspicious_detection);
747 }
748
749 #[test]
750 fn ensure_is_idempotent_per_route() {
751 let mut decorator = decorator();
752 decorator.ensure_route_config("GET /login").rate_limit = Some(5);
753 let again = decorator.ensure_route_config("GET /login");
754 assert_eq!(again.rate_limit, Some(5));
755 assert!(decorator.get_route_config("GET /signup").is_none());
756 assert!(decorator.route_ids().eq(["GET /login"]));
757 }
758
759 #[test]
760 fn require_ip_sets_both_sides_and_ignores_empties() {
761 let mut decorator = decorator();
762 decorator.require_ip(
763 "GET /login",
764 Some(vec![String::from("10.0.0.0/8")]),
765 Some(vec![String::from("203.0.113.9")]),
766 );
767 let route = decorator.get_route_config("GET /login").expect("route");
768 assert_eq!(
769 route.ip_whitelist.as_deref(),
770 Some([String::from("10.0.0.0/8")].as_slice())
771 );
772 assert_eq!(
773 route.ip_blacklist.as_deref(),
774 Some([String::from("203.0.113.9")].as_slice())
775 );
776
777 decorator.require_ip("GET /signup", None, Some(Vec::new()));
778 let route = decorator.get_route_config("GET /signup").expect("route");
779 assert_eq!(route.ip_whitelist, None);
780 assert_eq!(route.ip_blacklist, None);
781 }
782
783 #[test]
784 fn country_decorators_uppercase() {
785 let mut decorator = decorator();
786 decorator
787 .block_countries("GET /eu", &["br", "de"])
788 .allow_countries("GET /eu", &["us"]);
789 let route = decorator.get_route_config("GET /eu").expect("route");
790 assert_eq!(
791 route.blocked_countries.as_deref(),
792 Some([String::from("BR"), String::from("DE")].as_slice())
793 );
794 assert_eq!(
795 route.whitelist_countries.as_deref(),
796 Some([String::from("US")].as_slice())
797 );
798 }
799
800 #[test]
801 fn block_clouds_blocks_everything_or_filters_unknowns() {
802 let mut decorator = decorator();
803 let ignored = decorator.block_clouds("GET /api", None);
804 assert!(ignored.is_empty());
805 let route = decorator.get_route_config("GET /api").expect("route");
806 assert_eq!(
807 route.block_cloud_providers.len(),
808 VALID_CLOUD_PROVIDERS.len()
809 );
810
811 let ignored = decorator.block_clouds(
812 "GET /api2",
813 Some(vec![
814 String::from("AWS"),
815 String::from("NotACloud"),
816 String::from("AWS:!prod"),
817 ]),
818 );
819 assert_eq!(ignored, [String::from("NotACloud")]);
820 let route = decorator.get_route_config("GET /api2").expect("route");
821 assert!(route.block_cloud_providers.contains("AWS"));
824 assert!(route.block_cloud_providers.contains("AWS:!prod"));
825 assert_eq!(route.block_cloud_providers.len(), 2);
826 }
827
828 #[test]
829 fn bypass_keeps_only_the_reference_names_and_unions() {
830 let mut decorator = decorator();
831 let ignored =
832 decorator.bypass("GET /internal", &["ip", "rate_limit", "all", "not_a_check"]);
833 assert_eq!(ignored, [String::from("not_a_check")]);
834 let ignored2 = decorator.bypass("GET /internal", &["ip_ban", "mystery"]);
835 assert_eq!(ignored2, [String::from("mystery")]);
836 let route = decorator.get_route_config("GET /internal").expect("route");
837 assert!(route.bypassed_checks.contains("all"));
838 assert!(route.bypassed_checks.contains("ip"));
839 assert!(route.bypassed_checks.contains("ip_ban"));
840 assert!(route.bypassed_checks.contains("rate_limit"));
841 }
842
843 #[test]
844 fn the_bypass_vocabulary_is_the_reference_set() {
845 assert_eq!(
846 VALID_BYPASS_CHECKS,
847 ["all", "ip_ban", "ip", "clouds", "rate_limit", "penetration"]
848 );
849 assert_eq!(
850 VALID_CLOUD_PROVIDERS,
851 ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"]
852 );
853 }
854
855 #[test]
856 fn time_window_and_suspicious_detection_set_their_knobs() {
857 let mut decorator = decorator();
858 decorator
859 .time_window("GET /night", "22:00", "06:00", "UTC")
860 .suspicious_detection("GET /night", false);
861 let route = decorator.get_route_config("GET /night").expect("route");
862 let restrictions = route.time_restrictions.as_ref().expect("restrictions");
863 assert_eq!(restrictions.get("start").map(String::as_str), Some("22:00"));
864 assert_eq!(restrictions.get("end").map(String::as_str), Some("06:00"));
865 assert_eq!(
866 restrictions.get("timezone").map(String::as_str),
867 Some("UTC")
868 );
869 assert!(!route.enable_suspicious_detection);
870 }
871
872 #[test]
873 fn the_honeypot_validator_answers_403_on_a_filled_trap() {
874 let mut decorator = decorator();
875 decorator.honeypot_detection("POST /form", &["website", "bot_field"]);
876 let route = decorator.get_route_config("POST /form").expect("route");
877 assert_eq!(route.custom_validators.len(), 1);
878 let validator = route.custom_validators[0].clone();
879
880 let context = CustomRequestContext {
882 method: "POST",
883 path: "/form",
884 client_ip: None,
885 body: Some("name=renn&website=https://spam.example"),
886 };
887 assert_eq!(
888 validator(&context),
889 Some(ValidatorAnswer::Response(CustomResponse {
890 status: Some(403),
891 body: Some(String::from("Forbidden")),
892 }))
893 );
894
895 let context = CustomRequestContext {
898 method: "POST",
899 path: "/form",
900 client_ip: None,
901 body: Some("name=renn&website="),
902 };
903 assert!(validator(&context).is_none());
904 let context = CustomRequestContext {
905 method: "POST",
906 path: "/form",
907 client_ip: None,
908 body: Some("website=https%3A%2F%2Fspam.example&name=renn"),
909 };
910 assert_eq!(
911 validator(&context),
912 Some(ValidatorAnswer::Response(CustomResponse {
913 status: Some(403),
914 body: Some(String::from("Forbidden")),
915 }))
916 );
917 let context = CustomRequestContext {
921 method: "POST",
922 path: "/form",
923 client_ip: None,
924 body: Some("website=%zz&name=renn"),
925 };
926 assert!(validator(&context).is_some());
927
928 let context = CustomRequestContext {
930 method: "POST",
931 path: "/form",
932 client_ip: None,
933 body: Some(r#"{"name":"renn","bot_field":"gotcha"}"#),
934 };
935 assert!(validator(&context).is_some());
936
937 let context = CustomRequestContext {
940 method: "POST",
941 path: "/form",
942 client_ip: None,
943 body: Some(r#"{"name":"renn","bot_field":""}"#),
944 };
945 assert!(validator(&context).is_none());
946
947 let context = CustomRequestContext {
949 method: "POST",
950 path: "/form",
951 client_ip: None,
952 body: Some("not json"),
953 };
954 assert!(validator(&context).is_none());
955
956 let context = CustomRequestContext {
958 method: "GET",
959 path: "/form",
960 client_ip: None,
961 body: Some("website=x"),
962 };
963 assert!(validator(&context).is_none());
964
965 let context = CustomRequestContext {
967 method: "POST",
968 path: "/form",
969 client_ip: None,
970 body: None,
971 };
972 assert!(validator(&context).is_none());
973 }
974
975 #[test]
976 fn the_auth_trio_enforces_the_reference_mutual_exclusions() {
977 let mut first = decorator();
978 first
979 .require_auth("GET /private", "bearer", None)
980 .expect("no conflict");
981 let error = first
982 .require_authorization_header("GET /private", "bearer")
983 .unwrap_err();
984 assert!(
985 error
986 .message
987 .contains("require_authorization_header cannot be combined with require_auth"),
988 "{error}"
989 );
990
991 let mut second = decorator();
992 second
993 .require_authorization_header("GET /public", "basic")
994 .expect("no conflict");
995 let error = second
996 .api_key_auth("GET /public", "X-API-Key", None)
997 .unwrap_err();
998 assert!(
999 error
1000 .message
1001 .contains("api_key_auth cannot be combined with require_authorization_header"),
1002 "{error}"
1003 );
1004 let error = second
1005 .require_auth("GET /public", "bearer", None)
1006 .unwrap_err();
1007 assert!(
1008 error
1009 .message
1010 .contains("require_auth cannot be combined with require_authorization_header"),
1011 "{error}"
1012 );
1013 }
1014
1015 #[test]
1016 fn api_key_auth_sets_the_reference_fields() {
1017 let mut decorator = decorator();
1018 decorator
1019 .api_key_auth("GET /partner", "X-API-Key", None)
1020 .expect("no conflict");
1021 let route = decorator.get_route_config("GET /partner").expect("route");
1022 assert!(route.api_key_required);
1023 assert_eq!(
1024 route.required_headers.get("X-API-Key").map(String::as_str),
1025 Some("required")
1026 );
1027 assert_eq!(route.api_key_header.as_deref(), Some("X-API-Key"));
1028 }
1029
1030 #[test]
1031 fn require_headers_merges_and_require_https_flags() {
1032 let mut decorator = decorator();
1033 decorator
1034 .require_https("GET /strict")
1035 .require_headers(
1036 "GET /strict",
1037 BTreeMap::from([(String::from("x-tenant"), String::from("required"))]),
1038 )
1039 .require_headers(
1040 "GET /strict",
1041 BTreeMap::from([(String::from("x-region"), String::from("eu"))]),
1042 );
1043 let route = decorator.get_route_config("GET /strict").expect("route");
1044 assert!(route.require_https);
1045 assert_eq!(route.required_headers.len(), 2);
1046 }
1047
1048 #[test]
1049 fn the_behavioral_factories_build_the_reference_rules() {
1050 let mut decorator = decorator();
1051 decorator
1052 .usage_monitor("GET /quota", 100, 3600, "ban")
1053 .suspicious_frequency("GET /quota", 0.5, 300, "alert");
1054 let route = decorator.get_route_config("GET /quota").expect("route");
1055 assert_eq!(route.behavior_rules.len(), 2);
1056 assert_eq!(route.behavior_rules[0].rule_type, "usage");
1057 assert_eq!(route.behavior_rules[0].threshold, 100);
1058 assert_eq!(route.behavior_rules[1].rule_type, "frequency");
1059 assert_eq!(route.behavior_rules[1].threshold, 150);
1061
1062 decorator
1063 .return_monitor("GET /quota", "status:500", 3, 86_400, "ban")
1064 .expect("compilable pattern");
1065 let route = decorator.get_route_config("GET /quota").expect("route");
1066 assert_eq!(route.behavior_rules.len(), 3);
1067 assert_eq!(route.behavior_rules[2].rule_type, "return_pattern");
1068
1069 let error = decorator
1070 .return_monitor("GET /quota", "([", 3, 60, "ban")
1071 .unwrap_err();
1072 assert!(error.message.contains("does not compile"), "{error}");
1073
1074 let rules = vec![BehaviorRule {
1075 rule_type: String::from("return_pattern"),
1076 threshold: 2,
1077 window: 60,
1078 pattern: String::from("(["),
1079 action: String::from("ban"),
1080 ban_duration: None,
1081 correlate_with_detection: false,
1082 }];
1083 let error = decorator
1084 .behavior_analysis("GET /quota", rules)
1085 .unwrap_err();
1086 assert!(error.message.contains("does not compile"), "{error}");
1087
1088 let rules = vec![BehaviorRule {
1089 rule_type: String::from("usage"),
1090 threshold: 5,
1091 window: 60,
1092 pattern: String::new(),
1093 action: String::from("log"),
1094 ban_duration: None,
1095 correlate_with_detection: false,
1096 }];
1097 decorator
1098 .behavior_analysis("GET /quota", rules)
1099 .expect("usage rules carry no pattern");
1100 let route = decorator.get_route_config("GET /quota").expect("route");
1101 assert_eq!(route.behavior_rules.len(), 4);
1102 }
1103
1104 #[test]
1105 fn the_content_filtering_factories_set_their_knobs() {
1106 let mut decorator = decorator();
1107 decorator
1108 .block_user_agents("POST /upload", &["sqlmap", "havij"])
1109 .content_type_filter("POST /upload", &["application/json"])
1110 .max_request_size("POST /upload", 1_048_576)
1111 .require_referrer("POST /upload", &["https://app.test"]);
1112 let route = decorator.get_route_config("POST /upload").expect("route");
1113 assert_eq!(route.blocked_user_agents, ["sqlmap", "havij"]);
1114 assert_eq!(
1115 route.allowed_content_types.as_deref(),
1116 Some([String::from("application/json")].as_slice())
1117 );
1118 assert_eq!(route.max_request_size, Some(1_048_576));
1119 assert_eq!(
1120 route.require_referrer.as_deref(),
1121 Some([String::from("https://app.test")].as_slice())
1122 );
1123 }
1124
1125 #[test]
1126 fn detection_exclusion_sets_only_the_present_knobs() {
1127 let mut decorator = decorator();
1128 decorator.detection_exclusion(
1129 "POST /scan-me",
1130 Some(BTreeSet::from([String::from("x-api-key")])),
1131 None,
1132 None,
1133 Some(BTreeSet::from([String::from("xss")])),
1134 Some(false),
1135 Some(BTreeSet::from([String::from("cookie")])),
1136 );
1137 let route = decorator.get_route_config("POST /scan-me").expect("route");
1138 assert_eq!(
1139 route.excluded_semantic_detection_headers,
1140 Some(BTreeSet::from([String::from("cookie")]))
1141 );
1142 assert_eq!(
1143 route.excluded_detection_headers,
1144 Some(BTreeSet::from([String::from("x-api-key")]))
1145 );
1146 assert_eq!(route.excluded_detection_params, None);
1147 assert_eq!(route.excluded_detection_body_fields, None);
1148 assert_eq!(
1149 route.enabled_detection_categories,
1150 Some(BTreeSet::from([String::from("xss")]))
1151 );
1152 assert_eq!(route.detection_scan_body, Some(false));
1153 }
1154
1155 #[test]
1156 fn the_rate_limiting_factories_set_their_tiers() {
1157 let mut decorator = decorator();
1158 decorator.rate_limit("POST /login", 5, 60).geo_rate_limit(
1159 "POST /login",
1160 BTreeMap::from([(String::from("BR"), (10, 30))]),
1161 );
1162 let route = decorator.get_route_config("POST /login").expect("route");
1163 assert_eq!(route.rate_limit, Some(5));
1164 assert_eq!(route.rate_limit_window, Some(60));
1165 let limits = route.geo_rate_limits.as_ref().expect("limits");
1166 assert_eq!(limits.get("BR"), Some(&(10, 30)));
1167
1168 assert!(route.rate_limits().expect("valid tier").is_some());
1171 }
1172
1173 #[test]
1174 fn custom_validation_appends_the_validator() {
1175 let mut decorator = decorator();
1176 let validator: CustomValidatorFn = Arc::new(|_context: &CustomRequestContext<'_>| None);
1177 decorator.custom_validation("GET /checked", validator);
1178 let route = decorator.get_route_config("GET /checked").expect("route");
1179 assert_eq!(route.custom_validators.len(), 1);
1180 }
1181
1182 #[test]
1183 fn detection_exclusion_sets_the_params_and_body_field_knobs() {
1184 let mut decorator = decorator();
1185 decorator.detection_exclusion(
1186 "POST /scan-me",
1187 None,
1188 Some(BTreeSet::from([String::from("token")])),
1189 Some(BTreeSet::from([String::from("password")])),
1190 None,
1191 None,
1192 None,
1193 );
1194 let route = decorator.get_route_config("POST /scan-me").expect("route");
1195 assert_eq!(
1196 route.excluded_detection_params,
1197 Some(BTreeSet::from([String::from("token")]))
1198 );
1199 assert_eq!(
1200 route.excluded_detection_body_fields,
1201 Some(BTreeSet::from([String::from("password")]))
1202 );
1203 }
1204
1205 #[test]
1206 fn the_error_display_and_the_decorator_debug_render() {
1207 let error = conflict("test message");
1208 assert_eq!(error.to_string(), "test message");
1209 assert!(std::error::Error::source(&error).is_none());
1210
1211 let mut decorator = decorator();
1212 decorator.rate_limit("GET /login", 5, 60);
1213 let rendered = format!("{decorator:?}");
1214 assert!(rendered.starts_with("SecurityDecorator"), "{rendered}");
1215 assert!(rendered.contains("routes: 1"), "{rendered}");
1216 assert!(rendered.contains("revision: "), "{rendered}");
1217 }
1218
1219 #[test]
1220 fn every_mutation_bumps_the_revision() {
1221 let mut decorator = decorator();
1222 let before = decorator.route_config_revision();
1223 decorator.rate_limit("GET /login", 5, 60);
1224 decorator.require_https("GET /login");
1225 decorator.bypass("GET /login", &["ip"]);
1228 let after = decorator.route_config_revision();
1229 assert_eq!(after - before, 4);
1232 }
1233}