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 five detection knobs; every
559    /// `None` leaves that knob untouched.
560    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    /// `@rate_limit`: the route's request count and window.
600    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    /// `@geo_rate_limit`: the route's per-country limits.
609    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
620/// The reference `_validate_return_pattern_body_scan`'s compile half: the
621/// pattern must compile under the engine's compiler (the body-scan
622/// constraints are the config's scan ceilings, which the scanner applies
623/// at run time).
624fn 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
631/// `urllib.parse.unquote_plus` for one component: `%XX` runs and `+`
632/// (form-encoding's space) decode; malformed escapes stay literal.
633fn 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
656/// The reference `honeypot_detection` validator: POST/PUT/PATCH only, the
657/// trap fields probed in the form-encoded and JSON bodies, a filled trap
658/// answers `403 "Forbidden"`.
659fn 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    // The form view: `parse_qsl`-decoded first-value pairs.
674    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    // The JSON view: top-level string fields (unparsable JSON skips, the
693    // reference logs and moves on).
694    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    /// A fresh decorator over the reference-default config.
720    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        // The reference keeps the provider strings verbatim (the
815        // region-scoped `AWS:!prod` form rides along with its base).
816        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        // A POST with a filled trap in the form body: 403 Forbidden.
874        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        // An empty trap: allowed. A percent-encoded trap value decodes
889        // before the emptiness check (the reference's parse_qsl view).
890        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        // A malformed escape stays literal: `%zz` reads as the literal
911        // three characters, which fills the trap (a filled value either
912        // way - the decode path itself is what the case pins).
913        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        // A filled trap in JSON: 403.
922        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        // An all-empty JSON trap set: allowed (the filled() false arm of
931        // the JSON view).
932        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        // Unparsable JSON skips (the reference logs and moves on).
941        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        // GET never probes.
950        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        // No body (the fairing lane): nothing to probe.
959        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        // int(0.5 * 300) = 150.
1053        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        // The carrier's own bridge: the tier view validates and hands the
1157        // engine's RouteRateLimits.
1158        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        // The reference's `bypassed_checks` is a plain set (untracked):
1213        // the union does not bump the revision.
1214        decorator.bypass("GET /login", &["ip"]);
1215        let after = decorator.route_config_revision();
1216        // Creation (1) + the two `rate_limit` assignments (2) + the
1217        // `require_https` assignment (1).
1218        assert_eq!(after - before, 4);
1219    }
1220}