Skip to main content

guard_core_engine/
decorators.rs

1//! The reference decorator system (`guard_core/decorators/*.py`): the
2//! [`SecurityDecorator`] registry and its mixin method surface, ported
3//! method for method.
4//!
5//! Python applies decorators to route functions and stamps a route id on
6//! the callable; Rust has no runtime function attributes, so the idiom is
7//! a named route id: every mixin method takes the route id the
8//! application chose (the reference derives it from
9//! `module.qualname`, the stand-in is the caller's stable name, by
10//! convention `"<METHOD> <path>"`), and the registry hands the
11//! [`RouteConfig`] carrier to the adapters' `with_route_configs`
12//! resolvers. Semantics are the reference's, exactly:
13//!
14//! - first touch creates the route's [`RouteConfig`] with
15//!   `enable_suspicious_detection` seeded from the config's
16//!   `enable_penetration_detection` and bumps the revision
17//!   (`BaseSecurityDecorator._ensure_route_config`),
18//! - every mutation is revision-tracked
19//!   (`RouteConfigRevision`),
20//! - `bypass` keeps only the reference
21//!   [`VALID_BYPASS_CHECKS`] names and drops the rest
22//!   (the unknowns are returned to the caller, the reference logs them),
23//! - `block_clouds` with no argument blocks every
24//!   [`VALID_CLOUD_PROVIDERS`] entry; named entries outside the
25//!   reference set are ignored and returned,
26//! - `require_auth`/`api_key_auth` conflict with
27//!   `require_authorization_header` and fail closed
28//!   ([`DecoratorError`]),
29//! - `return_monitor`/`behavior_analysis` validate every
30//!   `return_pattern` compiles (the reference
31//!   `_validate_return_pattern_body_scan`'s compile half),
32//! - `honeypot_detection` registers the reference's honeypot validator
33//!   (form and JSON trap-field probe over the buffered body, POST/PUT/
34//!   PATCH only, `403 "Forbidden"`).
35
36use 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
44/// The reference `VALID_BYPASS_CHECKS`: the names [`SecurityDecorator::
45/// bypass`] keeps.
46///
47/// The pipeline's coarse query vocabulary: the reference consults
48/// `should_bypass_check` exactly at the ip gate (`ip`), the ban arm
49/// (`ip_ban`), the cloud-provider check (`clouds`), the rate limiter
50/// (`rate_limit`), and the penetration scan (`penetration`), plus the
51/// `"all"` wildcard.
52pub const VALID_BYPASS_CHECKS: [&str; 6] =
53    ["all", "ip_ban", "ip", "clouds", "rate_limit", "penetration"];
54
55/// The reference `VALID_CLOUD_PROVIDERS`
56/// (`CloudProvider = Literal["AWS", "GCP", "Azure", "DigitalOcean",
57/// "Linode", "Vultr"]`).
58pub const VALID_CLOUD_PROVIDERS: [&str; 6] =
59    ["AWS", "GCP", "Azure", "DigitalOcean", "Linode", "Vultr"];
60
61/// Why a decorator factory refused a value: the reference raises
62/// `ValueError` on the auth-conflict combinations and on an uncompilable
63/// `return_pattern`.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct DecoratorError {
66    /// The reference error message.
67    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/// The decorator registry (`SecurityDecorator`): the config it reads its
85/// defaults from, the per-route [`RouteConfig`]s, and the revision
86/// counter every mutation bumps.
87#[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    /// Build the decorator over the unified configuration (the reference
105    /// `BaseSecurityDecorator.__init__`).
106    #[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    /// The revision counter (`route_config_revision`): bumped on every
116    /// route creation and every mutation the reference tracks.
117    #[must_use]
118    pub const fn route_config_revision(&self) -> u64 {
119        self.revision
120    }
121
122    /// The route's config, if the route was decorated
123    /// (`get_route_config`).
124    #[must_use]
125    pub fn get_route_config(&self, route_id: &str) -> Option<&RouteConfig> {
126        self.route_configs.get(route_id)
127    }
128
129    /// Every decorated route id.
130    pub fn route_ids(&self) -> impl Iterator<Item = &str> {
131        self.route_configs.keys().map(String::as_str)
132    }
133
134    /// `_ensure_route_config`: first touch creates the route's config
135    /// with the reference default
136    /// (`enable_suspicious_detection = config.enable_penetration_detection`)
137    /// and bumps the revision; later touches return the same config.
138    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    /// The reference's revision granularity: every attribute assignment
155    /// on an initialized `RouteConfig` bumps (the pydantic-free
156    /// `__setattr__` hook), and every mutation through a tracked
157    /// container (`custom_validators`, `require_referrer`,
158    /// `allowed_content_types`, `blocked_user_agents`, `required_headers`,
159    /// `time_restrictions`, `geo_rate_limits`, `block_cloud_providers`)
160    /// bumps. Plain-list appends (`behavior_rules`, `ip_*` after
161    /// assignment) and the plain `bypassed_checks` set do not.
162    const fn bump_n(&mut self, times: usize) {
163        self.revision += times as u64;
164    }
165
166    /// `@require_ip`: the route's IP allow/deny lists (entries that are
167    /// set replace; `None`/empty leaves the side untouched).
168    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    /// `@block_countries`: uppercased (the reference normalizes).
193    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    /// `@allow_countries`: uppercased (the reference normalizes).
206    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    /// `@block_clouds`: no providers blocks every
219    /// [`VALID_CLOUD_PROVIDERS`] entry; named entries outside the
220    /// reference set are ignored (the reference logs them) and returned
221    /// here, sorted.
222    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    /// `@bypass`: unions the names the reference
256    /// [`VALID_BYPASS_CHECKS`] set knows into the route's
257    /// `bypassed_checks`; unknown names are ignored (the reference logs
258    /// them) and returned here, sorted.
259    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    /// `@time_window`: the route's access window
273    /// (`start`/`end`/`timezone`, the reference dict shape).
274    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    /// `@suspicious_detection`: the route's penetration-detection toggle.
292    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    /// `@honeypot_detection`: registers the reference's honeypot
300    /// validator over the trap fields - a filled trap field in the
301    /// form-encoded or JSON body answers `403 "Forbidden"`; the check
302    /// applies to POST/PUT/PATCH requests with a buffered body only.
303    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    /// `@require_https`.
318    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    /// `@require_auth`: the route's authentication scheme and verifier.
325    ///
326    /// # Errors
327    ///
328    /// [`DecoratorError`] when the route already carries the
329    /// presence-only `require_authorization_header` (the reference's
330    /// mutually-exclusive combination).
331    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    /// `@api_key_auth`: the route's API-key header and verifier (the
350    /// header lands in `required_headers` with the reference's
351    /// `"required"` sentinel).
352    ///
353    /// # Errors
354    ///
355    /// [`DecoratorError`] on the
356    /// `require_authorization_header` conflict.
357    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    /// `@require_authorization_header`: presence-only.
380    ///
381    /// # Errors
382    ///
383    /// [`DecoratorError`] on the
384    /// `require_auth`/`api_key_auth` conflict.
385    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    /// `@require_headers`: the route's required header pairs (merged).
402    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    /// `@block_user_agents`: extends the route's blocked patterns.
415    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    /// `@usage_monitor`: a `usage` behavior rule
425    /// (`threshold = max_calls`).
426    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    /// `@return_monitor`: a `return_pattern` behavior rule.
447    ///
448    /// # Errors
449    ///
450    /// [`DecoratorError`] when the pattern does not compile (the
451    /// reference `_validate_return_pattern_body_scan`).
452    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    /// `@behavior_analysis`: extends the route's behavior rules,
475    /// validating every `return_pattern` rule's pattern.
476    ///
477    /// # Errors
478    ///
479    /// [`DecoratorError`] when a `return_pattern` rule carries a
480    /// pattern that does not compile.
481    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    /// `@suspicious_frequency`: a `frequency` behavior rule
497    /// (`threshold = max_frequency * window`, the reference's
498    /// `int()` truncation).
499    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        // the reference's int() truncation has the same behavior
508        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    /// `@content_type_filter`: the route's allowed content types.
523    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    /// `@max_request_size`: the route's body cap, in bytes.
531    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    /// `@require_referrer`: the route's allowed referrer domains.
538    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    /// `@custom_validation`: appends the route's validator.
550    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    /// `@detection_exclusion`: the route's six detection knobs; every
559    /// `None` leaves that knob untouched. `semantic_headers` adds header
560    /// names whose values skip the semantic analyzer only.
561    #[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    /// `@rate_limit`: the route's request count and window.
607    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    /// `@geo_rate_limit`: the route's per-country limits.
616    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
627/// The reference `_validate_return_pattern_body_scan`'s compile half: the
628/// pattern must compile under the engine's compiler (the body-scan
629/// constraints are the config's scan ceilings, which the scanner applies
630/// at run time).
631fn 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
638/// `urllib.parse.unquote_plus` for one component: `%XX` runs and `+`
639/// (form-encoding's space) decode; malformed escapes stay literal.
640fn 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
663/// The reference `honeypot_detection` validator: POST/PUT/PATCH only, the
664/// trap fields probed in the form-encoded and JSON bodies, a filled trap
665/// answers `403 "Forbidden"`.
666fn 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    // The form view: `parse_qsl`-decoded first-value pairs.
681    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    // The JSON view: top-level string fields (unparsable JSON skips, the
700    // reference logs and moves on).
701    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    /// A fresh decorator over the reference-default config.
727    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        // The reference keeps the provider strings verbatim (the
822        // region-scoped `AWS:!prod` form rides along with its base).
823        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        // A POST with a filled trap in the form body: 403 Forbidden.
881        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        // An empty trap: allowed. A percent-encoded trap value decodes
896        // before the emptiness check (the reference's parse_qsl view).
897        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        // A malformed escape stays literal: `%zz` reads as the literal
918        // three characters, which fills the trap (a filled value either
919        // way - the decode path itself is what the case pins).
920        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        // A filled trap in JSON: 403.
929        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        // An all-empty JSON trap set: allowed (the filled() false arm of
938        // the JSON view).
939        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        // Unparsable JSON skips (the reference logs and moves on).
948        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        // GET never probes.
957        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        // No body (the fairing lane): nothing to probe.
966        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        // int(0.5 * 300) = 150.
1060        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        // The carrier's own bridge: the tier view validates and hands the
1169        // engine's RouteRateLimits.
1170        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        // The reference's `bypassed_checks` is a plain set (untracked):
1226        // the union does not bump the revision.
1227        decorator.bypass("GET /login", &["ip"]);
1228        let after = decorator.route_config_revision();
1229        // Creation (1) + the two `rate_limit` assignments (2) + the
1230        // `require_https` assignment (1).
1231        assert_eq!(after - before, 4);
1232    }
1233}