Skip to main content

synapse_pingora/
config.rs

1//! Configuration loading and validation for Synapse-Pingora.
2//!
3//! This module handles YAML configuration parsing with security validations
4//! including file size limits, path validation, and schema verification.
5
6use crate::shadow::ShadowMirrorConfig;
7use crate::trap::TrapConfig;
8use crate::vhost::SiteConfig;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::fmt;
13use std::fs;
14use std::path::Path;
15use tracing::{debug, info, warn};
16
17/// Maximum configuration file size (10MB).
18const MAX_CONFIG_SIZE: u64 = 10 * 1024 * 1024;
19
20/// Global server configuration.
21#[derive(Clone, Serialize, Deserialize, JsonSchema)]
22pub struct GlobalConfig {
23    /// HTTP listen address (default: 0.0.0.0:80)
24    #[serde(default = "default_http_addr")]
25    pub http_addr: String,
26    /// HTTPS listen address (default: 0.0.0.0:443)
27    #[serde(default = "default_https_addr")]
28    pub https_addr: String,
29    /// Number of worker threads (0 = auto-detect)
30    #[serde(default)]
31    pub workers: usize,
32    /// Graceful shutdown timeout in seconds
33    #[serde(default = "default_shutdown_timeout")]
34    pub shutdown_timeout_secs: u64,
35    /// Global WAF threshold (0-100)
36    #[serde(default = "default_waf_threshold")]
37    pub waf_threshold: u8,
38    /// Whether WAF is globally enabled
39    #[serde(default = "default_true")]
40    pub waf_enabled: bool,
41    /// Log level (trace, debug, info, warn, error)
42    #[serde(default = "default_log_level")]
43    pub log_level: String,
44    /// API key for the admin server (if unset, a secure random key is generated at startup)
45    #[serde(default)]
46    pub admin_api_key: Option<String>,
47    /// Honeypot trap endpoint configuration
48    #[serde(default)]
49    pub trap_config: Option<TrapConfig>,
50    /// WAF regex evaluation timeout in milliseconds (prevents ReDoS attacks).
51    /// Default: 100ms. Maximum: 500ms (capped to prevent disabling protection).
52    #[serde(default = "default_waf_regex_timeout_ms")]
53    pub waf_regex_timeout_ms: u64,
54}
55
56fn default_waf_regex_timeout_ms() -> u64 {
57    100 // 100ms default, matching issue requirement
58}
59
60fn default_http_addr() -> String {
61    "0.0.0.0:80".to_string()
62}
63
64fn default_https_addr() -> String {
65    "0.0.0.0:443".to_string()
66}
67
68fn default_shutdown_timeout() -> u64 {
69    30
70}
71
72fn default_waf_threshold() -> u8 {
73    70
74}
75
76fn default_true() -> bool {
77    true
78}
79
80fn default_log_level() -> String {
81    "info".to_string()
82}
83
84impl fmt::Debug for GlobalConfig {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("GlobalConfig")
87            .field("http_addr", &self.http_addr)
88            .field("https_addr", &self.https_addr)
89            .field("workers", &self.workers)
90            .field("shutdown_timeout_secs", &self.shutdown_timeout_secs)
91            .field("waf_threshold", &self.waf_threshold)
92            .field("waf_enabled", &self.waf_enabled)
93            .field("log_level", &self.log_level)
94            .field(
95                "admin_api_key",
96                &self.admin_api_key.as_ref().map(|_| "[REDACTED]"),
97            )
98            .field("trap_config", &self.trap_config)
99            .field("waf_regex_timeout_ms", &self.waf_regex_timeout_ms)
100            .finish()
101    }
102}
103
104impl Default for GlobalConfig {
105    fn default() -> Self {
106        Self {
107            http_addr: default_http_addr(),
108            https_addr: default_https_addr(),
109            workers: 0,
110            shutdown_timeout_secs: default_shutdown_timeout(),
111            waf_threshold: default_waf_threshold(),
112            waf_enabled: true,
113            log_level: default_log_level(),
114            admin_api_key: None,
115            trap_config: None,
116            waf_regex_timeout_ms: default_waf_regex_timeout_ms(),
117        }
118    }
119}
120
121/// Rate limiting configuration.
122#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct RateLimitConfig {
124    /// Requests per second limit
125    pub rps: u32,
126    /// Whether rate limiting is enabled
127    #[serde(default = "default_true")]
128    pub enabled: bool,
129    /// Burst capacity (defaults to rps * 2)
130    pub burst: Option<u32>,
131}
132
133impl Default for RateLimitConfig {
134    fn default() -> Self {
135        Self {
136            rps: 10000,
137            enabled: true,
138            burst: None,
139        }
140    }
141}
142
143/// Upstream backend configuration.
144#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
145pub struct UpstreamConfig {
146    /// Backend host
147    pub host: String,
148    /// Backend port
149    pub port: u16,
150    /// Weight for load balancing (default: 1)
151    #[serde(default = "default_weight")]
152    pub weight: u32,
153    /// Whether this backend is healthy
154    #[serde(skip)]
155    pub healthy: bool,
156}
157
158fn default_weight() -> u32 {
159    1
160}
161
162/// TLS configuration for a site.
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164pub struct TlsConfig {
165    /// Path to certificate file (PEM format)
166    pub cert_path: String,
167    /// Path to private key file (PEM format)
168    pub key_path: String,
169    /// Minimum TLS version (1.2 or 1.3)
170    #[serde(default = "default_min_tls")]
171    pub min_version: String,
172}
173
174fn default_min_tls() -> String {
175    "1.2".to_string()
176}
177
178/// Access control configuration for a site.
179#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
180pub struct AccessControlConfig {
181    /// List of CIDR ranges to allow
182    #[serde(default)]
183    pub allow: Vec<String>,
184    /// List of CIDR ranges to deny
185    #[serde(default)]
186    pub deny: Vec<String>,
187    /// Default action if no rule matches (allow or deny)
188    #[serde(default)]
189    pub default_action: String,
190}
191
192/// Header manipulation configuration.
193#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
194pub struct HeaderConfig {
195    /// Headers to add/set/remove on the request
196    #[serde(default)]
197    pub request: HeaderOps,
198    /// Headers to add/set/remove on the response
199    #[serde(default)]
200    pub response: HeaderOps,
201}
202
203/// Header operations (add, set, remove).
204#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
205pub struct HeaderOps {
206    /// Headers to add (appends if already exists)
207    #[serde(default)]
208    pub add: std::collections::HashMap<String, String>,
209    /// Headers to set (replaces if already exists)
210    #[serde(default)]
211    pub set: std::collections::HashMap<String, String>,
212    /// Headers to remove
213    #[serde(default)]
214    pub remove: Vec<String>,
215}
216
217/// Site-specific WAF configuration.
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
219pub struct SiteWafConfig {
220    /// Whether WAF is enabled for this site
221    #[serde(default = "default_true")]
222    pub enabled: bool,
223    /// Risk threshold (0-100)
224    pub threshold: Option<u8>,
225    /// Rule overrides (rule_id -> action)
226    #[serde(default)]
227    pub rule_overrides: std::collections::HashMap<String, String>,
228}
229
230impl Default for SiteWafConfig {
231    fn default() -> Self {
232        Self {
233            enabled: true,
234            threshold: None,
235            rule_overrides: std::collections::HashMap::new(),
236        }
237    }
238}
239
240/// Site configuration from YAML.
241#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
242pub struct SiteYamlConfig {
243    /// Hostname or wildcard pattern
244    pub hostname: String,
245    /// Upstream backends
246    pub upstreams: Vec<UpstreamConfig>,
247    /// TLS configuration (optional)
248    pub tls: Option<TlsConfig>,
249    /// WAF configuration (optional)
250    pub waf: Option<SiteWafConfig>,
251    /// Rate limiting configuration (optional)
252    pub rate_limit: Option<RateLimitConfig>,
253    /// Access control (optional)
254    pub access_control: Option<AccessControlConfig>,
255    /// Header manipulation (optional)
256    pub headers: Option<HeaderConfig>,
257    /// Shadow mirroring configuration (optional)
258    #[serde(default)]
259    pub shadow_mirror: Option<ShadowMirrorConfig>,
260}
261
262/// Profiler configuration for endpoint behavior learning.
263#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
264pub struct ProfilerConfig {
265    /// Whether profiling is enabled
266    #[serde(default = "default_true")]
267    pub enabled: bool,
268    /// Maximum number of endpoint profiles to maintain
269    #[serde(default = "default_max_profiles")]
270    pub max_profiles: usize,
271    /// Maximum number of learned schemas to maintain
272    #[serde(default = "default_max_schemas")]
273    pub max_schemas: usize,
274    /// Minimum samples required before validating against profile
275    #[serde(default = "default_min_samples")]
276    pub min_samples_for_validation: u32,
277
278    // ========================================================================
279    // Anomaly Detection Thresholds
280    // ========================================================================
281    /// Z-score threshold for payload size anomaly detection (default: 3.0)
282    #[serde(default = "default_payload_z_threshold")]
283    pub payload_z_threshold: f64,
284
285    /// Z-score threshold for parameter value anomaly detection (default: 4.0)
286    #[serde(default = "default_param_z_threshold")]
287    pub param_z_threshold: f64,
288
289    /// Z-score threshold for response size anomaly detection (default: 4.0)
290    #[serde(default = "default_response_z_threshold")]
291    pub response_z_threshold: f64,
292
293    /// Minimum standard deviation for z-score calculation (avoids div/0) (default: 0.01)
294    #[serde(default = "default_min_stddev")]
295    pub min_stddev: f64,
296
297    /// Ratio threshold for type-based anomaly detection (default: 0.9)
298    /// If >90% of values are numeric, flag non-numeric as anomaly
299    #[serde(default = "default_type_ratio_threshold")]
300    pub type_ratio_threshold: f64,
301
302    // ========================================================================
303    // Security Controls
304    // ========================================================================
305    /// Maximum number of type categories per parameter (prevents memory exhaustion)
306    #[serde(default = "default_max_type_counts")]
307    pub max_type_counts: usize,
308
309    /// Redact PII values in anomaly descriptions (default: true)
310    #[serde(default = "default_true")]
311    pub redact_pii: bool,
312
313    /// Freeze baseline after this many samples (prevents model poisoning)
314    /// Set to 0 to disable (continuous learning). Default: 0 (disabled)
315    #[serde(default)]
316    pub freeze_after_samples: u32,
317}
318
319fn default_max_profiles() -> usize {
320    1000
321}
322
323fn default_max_schemas() -> usize {
324    500
325}
326
327fn default_min_samples() -> u32 {
328    100
329}
330
331fn default_payload_z_threshold() -> f64 {
332    3.0
333}
334
335fn default_param_z_threshold() -> f64 {
336    4.0
337}
338
339fn default_response_z_threshold() -> f64 {
340    4.0
341}
342
343fn default_min_stddev() -> f64 {
344    0.01
345}
346
347fn default_type_ratio_threshold() -> f64 {
348    0.9
349}
350
351fn default_max_type_counts() -> usize {
352    10
353}
354
355impl Default for ProfilerConfig {
356    fn default() -> Self {
357        Self {
358            enabled: true,
359            max_profiles: default_max_profiles(),
360            max_schemas: default_max_schemas(),
361            min_samples_for_validation: default_min_samples(),
362            payload_z_threshold: default_payload_z_threshold(),
363            param_z_threshold: default_param_z_threshold(),
364            response_z_threshold: default_response_z_threshold(),
365            min_stddev: default_min_stddev(),
366            type_ratio_threshold: default_type_ratio_threshold(),
367            max_type_counts: default_max_type_counts(),
368            redact_pii: true,
369            freeze_after_samples: 0,
370        }
371    }
372}
373
374/// Complete configuration file structure.
375#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
376pub struct ConfigFile {
377    /// Global server settings
378    #[serde(default)]
379    pub server: GlobalConfig,
380    /// Site configurations
381    pub sites: Vec<SiteYamlConfig>,
382    /// Global rate limiting
383    #[serde(default)]
384    pub rate_limit: RateLimitConfig,
385    /// Profiler configuration
386    #[serde(default)]
387    pub profiler: ProfilerConfig,
388}
389
390/// Errors that can occur during configuration loading.
391#[derive(Debug, thiserror::Error)]
392pub enum ConfigError {
393    #[error("configuration file not found: {path} (check the path or mount the file into the container)")]
394    NotFound { path: String },
395
396    #[error("configuration file too large: {size} bytes (max {max} bytes). Reduce size or split the configuration")]
397    FileTooLarge { size: u64, max: u64 },
398
399    #[error("failed to read configuration: {0}")]
400    IoError(#[from] std::io::Error),
401
402    #[error("failed to parse configuration: {0}")]
403    ParseError(#[from] serde_yaml::Error),
404
405    #[error("validation error: {0}")]
406    ValidationError(String),
407
408    #[error("TLS certificate not found: {path} (set tls.cert_path to a valid PEM file)")]
409    CertNotFound { path: String },
410
411    #[error("TLS key not found: {path} (set tls.key_path to a valid PEM file)")]
412    KeyNotFound { path: String },
413
414    #[error("duplicate hostname: {hostname} (hostnames must be unique; consider a wildcard like '*.example.com')")]
415    DuplicateHostname { hostname: String },
416
417    #[error("invalid TLS version: {version} (set min_version to '1.2' or '1.3')")]
418    InvalidTlsVersion { version: String },
419
420    #[error("path traversal detected in: {path} (remove '..' or encoded traversal sequences)")]
421    PathTraversal { path: String },
422}
423
424/// Check if a path contains path traversal sequences.
425///
426/// Detects:
427/// - Literal `..` sequences
428/// - URL-encoded variants: `%2e%2e`, `%2E%2E`, mixed case
429/// - Double-URL-encoded: `%252e%252e`
430/// - Backslash variants: `..\`, `..\\`
431fn contains_path_traversal(path: &str) -> bool {
432    // Check literal parent directory reference
433    if path.contains("..") {
434        return true;
435    }
436
437    // Check URL-encoded variants (case-insensitive)
438    let path_lower = path.to_lowercase();
439    if path_lower.contains("%2e%2e") || path_lower.contains("%2e.") || path_lower.contains(".%2e") {
440        return true;
441    }
442
443    // Check double-URL-encoded
444    if path_lower.contains("%252e") {
445        return true;
446    }
447
448    // Check for null bytes (path truncation attack)
449    if path.contains('\0') || path_lower.contains("%00") {
450        return true;
451    }
452
453    false
454}
455
456/// Configuration loader with security validations.
457pub struct ConfigLoader;
458
459impl ConfigLoader {
460    /// Loads configuration from a YAML file.
461    ///
462    /// # Security
463    /// - Enforces 10MB file size limit
464    /// - Validates TLS certificate/key paths exist
465    /// - Checks for path traversal attempts
466    /// - Validates hostnames for duplicates
467    pub fn load<P: AsRef<Path>>(path: P) -> Result<ConfigFile, ConfigError> {
468        let path = path.as_ref();
469        info!("Loading configuration from: {}", path.display());
470
471        // Check file exists
472        if !path.exists() {
473            return Err(ConfigError::NotFound {
474                path: path.display().to_string(),
475            });
476        }
477
478        // Check file size (security: prevent memory exhaustion)
479        let metadata = fs::metadata(path)?;
480        if metadata.len() > MAX_CONFIG_SIZE {
481            return Err(ConfigError::FileTooLarge {
482                size: metadata.len(),
483                max: MAX_CONFIG_SIZE,
484            });
485        }
486
487        // Read and parse
488        let contents = fs::read_to_string(path)?;
489        let config: ConfigFile = serde_yaml::from_str(&contents)?;
490
491        // Validate
492        Self::validate(&config)?;
493
494        info!("Loaded configuration with {} sites", config.sites.len());
495        Ok(config)
496    }
497
498    /// Validates the configuration.
499    fn validate(config: &ConfigFile) -> Result<(), ConfigError> {
500        let mut hostnames = HashSet::new();
501
502        for site in &config.sites {
503            // Check for duplicate hostnames
504            let normalized = site.hostname.to_lowercase();
505            if !hostnames.insert(normalized.clone()) {
506                return Err(ConfigError::DuplicateHostname {
507                    hostname: site.hostname.clone(),
508                });
509            }
510
511            // Validate upstreams
512            if site.upstreams.is_empty() {
513                return Err(ConfigError::ValidationError(format!(
514                    "site '{}' has no upstreams configured; add at least one upstream with host and port",
515                    site.hostname
516                )));
517            }
518
519            // Validate TLS configuration
520            if let Some(tls) = &site.tls {
521                Self::validate_tls(tls)?;
522            }
523
524            // Validate WAF configuration
525            if let Some(waf) = &site.waf {
526                // Warn if WAF is explicitly disabled (SYNAPSE-SEC-011)
527                if !waf.enabled {
528                    warn!(
529                        site = %site.hostname,
530                        "WAF protection DISABLED for site - backend may be exposed to attacks"
531                    );
532                }
533                // Validate threshold (must be 1-100, 0 effectively disables protection)
534                if let Some(threshold) = waf.threshold {
535                    if threshold == 0 {
536                        return Err(ConfigError::ValidationError(format!(
537                            "site '{}' has WAF threshold of 0, which effectively disables protection. \
538                             Use waf.enabled: false to disable the WAF, or set threshold between 1-100",
539                            site.hostname
540                        )));
541                    }
542                    if threshold > 100 {
543                        return Err(ConfigError::ValidationError(format!(
544                            "site '{}' has invalid WAF threshold {} (must be 1-100); \
545                             use waf.enabled: false to disable or set a valid threshold",
546                            site.hostname, threshold
547                        )));
548                    }
549                }
550            }
551
552            // Validate rate limit
553            if let Some(rl) = &site.rate_limit {
554                if rl.rps == 0 && rl.enabled {
555                    warn!(
556                        "Site '{}' has rate limiting enabled with 0 RPS; set rps > 0 or disable rate limiting",
557                        site.hostname
558                    );
559                }
560                // P1-RUST-002: Bounds checking for RPS
561                if rl.rps > 1_000_000 {
562                    return Err(ConfigError::ValidationError(format!(
563                        "site '{}' has extreme RPS limit {} (max 1,000,000)",
564                        site.hostname, rl.rps
565                    )));
566                }
567            }
568
569            // Validate shadow mirroring config
570            if let Some(shadow) = &site.shadow_mirror {
571                if let Err(e) = shadow.validate() {
572                    return Err(ConfigError::ValidationError(format!(
573                        "site '{}' has invalid shadow_mirror config: {}. Fix shadow_mirror settings or remove the block",
574                        site.hostname,
575                        e
576                    )));
577                }
578            }
579        }
580
581        // P1-RUST-002: Global bounds checking
582        if config.server.workers > 1024 {
583            return Err(ConfigError::ValidationError(format!(
584                "extreme worker count {} (max 1024)",
585                config.server.workers
586            )));
587        }
588
589        if config.server.shutdown_timeout_secs > 3600 {
590            return Err(ConfigError::ValidationError(format!(
591                "extreme shutdown timeout {}s (max 3600)",
592                config.server.shutdown_timeout_secs
593            )));
594        }
595
596        if config.server.waf_regex_timeout_ms > 500 {
597            return Err(ConfigError::ValidationError(format!(
598                "extreme WAF regex timeout {}ms (max 500)",
599                config.server.waf_regex_timeout_ms
600            )));
601        }
602
603        // Warn if global WAF is disabled (SYNAPSE-SEC-011)
604        if !config.server.waf_enabled {
605            warn!(
606                "Global WAF protection DISABLED - all sites may be exposed to attacks unless individually configured"
607            );
608        }
609
610        // Validate global WAF threshold (must be 1-100, 0 effectively disables protection)
611        if config.server.waf_threshold == 0 {
612            return Err(ConfigError::ValidationError(
613                "global WAF threshold of 0 effectively disables protection. \
614                 Use waf_enabled: false to disable globally, or set waf_threshold between 1-100"
615                    .to_string(),
616            ));
617        }
618        if config.server.waf_threshold > 100 {
619            return Err(ConfigError::ValidationError(format!(
620                "global WAF threshold {} is invalid (must be 1-100); set waf_threshold between 1-100",
621                config.server.waf_threshold
622            )));
623        }
624
625        Ok(())
626    }
627
628    /// Validates TLS configuration paths.
629    fn validate_tls(tls: &TlsConfig) -> Result<(), ConfigError> {
630        // Check for path traversal (including URL-encoded variants)
631        if contains_path_traversal(&tls.cert_path) {
632            return Err(ConfigError::PathTraversal {
633                path: tls.cert_path.clone(),
634            });
635        }
636        if contains_path_traversal(&tls.key_path) {
637            return Err(ConfigError::PathTraversal {
638                path: tls.key_path.clone(),
639            });
640        }
641
642        // Check cert exists
643        if !Path::new(&tls.cert_path).exists() {
644            return Err(ConfigError::CertNotFound {
645                path: tls.cert_path.clone(),
646            });
647        }
648
649        // Check key exists
650        if !Path::new(&tls.key_path).exists() {
651            return Err(ConfigError::KeyNotFound {
652                path: tls.key_path.clone(),
653            });
654        }
655
656        // Validate TLS version
657        match tls.min_version.as_str() {
658            "1.2" | "1.3" => {}
659            _ => {
660                return Err(ConfigError::InvalidTlsVersion {
661                    version: tls.min_version.clone(),
662                });
663            }
664        }
665
666        debug!(
667            "Validated TLS config: cert={}, key=[REDACTED]",
668            tls.cert_path
669        );
670        Ok(())
671    }
672
673    /// Converts YAML site configs to internal SiteConfig format.
674    pub fn to_site_configs(config: &ConfigFile) -> Vec<SiteConfig> {
675        config
676            .sites
677            .iter()
678            .map(|site| SiteConfig {
679                hostname: site.hostname.clone(),
680                upstreams: site
681                    .upstreams
682                    .iter()
683                    .map(|u| format!("{}:{}", u.host, u.port))
684                    .collect(),
685                tls_enabled: site.tls.is_some(),
686                tls_cert: site.tls.as_ref().map(|t| t.cert_path.clone()),
687                tls_key: site.tls.as_ref().map(|t| t.key_path.clone()),
688                waf_threshold: site.waf.as_ref().and_then(|w| w.threshold),
689                waf_enabled: site.waf.as_ref().map(|w| w.enabled).unwrap_or(true),
690                access_control: site.access_control.clone(),
691                headers: site.headers.as_ref().map(|headers| headers.compile()),
692                shadow_mirror: site.shadow_mirror.clone(),
693            })
694            .collect()
695    }
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701    use std::io::Write;
702    use tempfile::NamedTempFile;
703
704    fn create_temp_config(content: &str) -> NamedTempFile {
705        let mut file = NamedTempFile::new().unwrap();
706        file.write_all(content.as_bytes()).unwrap();
707        file
708    }
709
710    #[test]
711    fn test_load_minimal_config() {
712        let yaml = r#"
713sites:
714  - hostname: example.com
715    upstreams:
716      - host: 127.0.0.1
717        port: 8080
718"#;
719        let file = create_temp_config(yaml);
720        let config = ConfigLoader::load(file.path()).unwrap();
721        assert_eq!(config.sites.len(), 1);
722        assert_eq!(config.sites[0].hostname, "example.com");
723    }
724
725    #[test]
726    fn test_load_full_config() {
727        let yaml = r#"
728server:
729  http_addr: "0.0.0.0:8080"
730  https_addr: "0.0.0.0:8443"
731  workers: 4
732  waf_threshold: 80
733  log_level: debug
734
735rate_limit:
736  rps: 5000
737  enabled: true
738
739sites:
740  - hostname: example.com
741    upstreams:
742      - host: 127.0.0.1
743        port: 8080
744        weight: 2
745    waf:
746      enabled: true
747      threshold: 60
748"#;
749        let file = create_temp_config(yaml);
750        let config = ConfigLoader::load(file.path()).unwrap();
751
752        assert_eq!(config.server.http_addr, "0.0.0.0:8080");
753        assert_eq!(config.server.workers, 4);
754        assert_eq!(config.rate_limit.rps, 5000);
755        assert_eq!(config.sites[0].waf.as_ref().unwrap().threshold, Some(60));
756    }
757
758    #[test]
759    fn test_duplicate_hostname() {
760        let yaml = r#"
761sites:
762  - hostname: example.com
763    upstreams:
764      - host: 127.0.0.1
765        port: 8080
766  - hostname: example.com
767    upstreams:
768      - host: 127.0.0.1
769        port: 8081
770"#;
771        let file = create_temp_config(yaml);
772        let result = ConfigLoader::load(file.path());
773        assert!(matches!(result, Err(ConfigError::DuplicateHostname { .. })));
774    }
775
776    #[test]
777    fn test_no_upstreams() {
778        let yaml = r#"
779sites:
780  - hostname: example.com
781    upstreams: []
782"#;
783        let file = create_temp_config(yaml);
784        let result = ConfigLoader::load(file.path());
785        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
786    }
787
788    #[test]
789    fn test_invalid_waf_threshold() {
790        let yaml = r#"
791sites:
792  - hostname: example.com
793    upstreams:
794      - host: 127.0.0.1
795        port: 8080
796    waf:
797      threshold: 150
798"#;
799        let file = create_temp_config(yaml);
800        let result = ConfigLoader::load(file.path());
801        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
802    }
803
804    #[test]
805    fn test_file_not_found() {
806        let result = ConfigLoader::load("/nonexistent/config.yaml");
807        assert!(matches!(result, Err(ConfigError::NotFound { .. })));
808    }
809
810    #[test]
811    fn test_default_values() {
812        let config = GlobalConfig::default();
813        assert_eq!(config.http_addr, "0.0.0.0:80");
814        assert_eq!(config.https_addr, "0.0.0.0:443");
815        assert_eq!(config.waf_threshold, 70);
816        assert!(config.waf_enabled);
817        assert_eq!(config.waf_regex_timeout_ms, 100); // Default 100ms
818    }
819
820    #[test]
821    fn test_debug_redacts_admin_api_key() {
822        let mut config = GlobalConfig::default();
823        config.admin_api_key = Some("super-secret-key-12345".to_string());
824
825        let debug_output = format!("{:?}", config);
826
827        // Must NOT contain the actual secret
828        assert!(!debug_output.contains("super-secret-key-12345"));
829        // Must contain the redaction marker
830        assert!(debug_output.contains("[REDACTED]"));
831        // Non-secret fields should still be visible
832        assert!(debug_output.contains("0.0.0.0:80"));
833    }
834
835    #[test]
836    fn test_debug_shows_none_when_no_key() {
837        let config = GlobalConfig::default();
838        let debug_output = format!("{:?}", config);
839
840        // When no key is set, should show None
841        assert!(debug_output.contains("None"));
842        assert!(!debug_output.contains("[REDACTED]"));
843    }
844
845    #[test]
846    fn test_waf_regex_timeout_config() {
847        let yaml = r#"
848server:
849  waf_regex_timeout_ms: 200
850sites:
851  - hostname: example.com
852    upstreams:
853      - host: 127.0.0.1
854        port: 8080
855"#;
856        let file = create_temp_config(yaml);
857        let config = ConfigLoader::load(file.path()).unwrap();
858        assert_eq!(config.server.waf_regex_timeout_ms, 200);
859    }
860
861    #[test]
862    fn test_waf_regex_timeout_default() {
863        let yaml = r#"
864sites:
865  - hostname: example.com
866    upstreams:
867      - host: 127.0.0.1
868        port: 8080
869"#;
870        let file = create_temp_config(yaml);
871        let config = ConfigLoader::load(file.path()).unwrap();
872        // Should use default of 100ms when not specified
873        assert_eq!(config.server.waf_regex_timeout_ms, 100);
874    }
875
876    #[test]
877    fn test_to_site_configs() {
878        let yaml = r#"
879sites:
880  - hostname: example.com
881    upstreams:
882      - host: 127.0.0.1
883        port: 8080
884    waf:
885      enabled: true
886      threshold: 80
887"#;
888        let file = create_temp_config(yaml);
889        let config = ConfigLoader::load(file.path()).unwrap();
890        let sites = ConfigLoader::to_site_configs(&config);
891
892        assert_eq!(sites.len(), 1);
893        assert_eq!(sites[0].hostname, "example.com");
894        assert_eq!(sites[0].waf_threshold, Some(80));
895        assert!(sites[0].waf_enabled);
896    }
897
898    #[test]
899    fn test_yaml_with_unknown_fields_passes() {
900        // serde_yaml default behavior: unknown fields are silently ignored
901        // unless deny_unknown_fields is set. Our config structs do not use it,
902        // so unknown fields should be accepted without error.
903        let yaml = r#"
904server:
905  http_addr: "0.0.0.0:9090"
906  unknown_field: "should be ignored"
907  another_mystery: 42
908sites:
909  - hostname: example.com
910    upstreams:
911      - host: 127.0.0.1
912        port: 8080
913    extra_site_field: true
914"#;
915        let file = create_temp_config(yaml);
916        let config = ConfigLoader::load(file.path()).unwrap();
917        assert_eq!(config.server.http_addr, "0.0.0.0:9090");
918        assert_eq!(config.sites.len(), 1);
919        assert_eq!(config.sites[0].hostname, "example.com");
920    }
921
922    #[test]
923    fn test_yaml_with_unknown_top_level_field_passes() {
924        // Unknown top-level keys should also be silently ignored
925        let yaml = r#"
926some_future_feature:
927  enabled: true
928sites:
929  - hostname: example.com
930    upstreams:
931      - host: 127.0.0.1
932        port: 8080
933"#;
934        let file = create_temp_config(yaml);
935        let config = ConfigLoader::load(file.path()).unwrap();
936        assert_eq!(config.sites.len(), 1);
937    }
938
939    #[test]
940    fn test_path_traversal_detection() {
941        use super::contains_path_traversal;
942
943        // Literal parent directory references
944        assert!(contains_path_traversal(".."));
945        assert!(contains_path_traversal("../etc/passwd"));
946        assert!(contains_path_traversal("/path/../secret"));
947        assert!(contains_path_traversal("path/to/../../root"));
948
949        // URL-encoded variants
950        assert!(contains_path_traversal("%2e%2e"));
951        assert!(contains_path_traversal("%2E%2E"));
952        assert!(contains_path_traversal("%2e."));
953        assert!(contains_path_traversal(".%2e"));
954
955        // Double-URL-encoded
956        assert!(contains_path_traversal("%252e%252e"));
957        assert!(contains_path_traversal("path/%252e%252e/file"));
958
959        // Null byte injection
960        assert!(contains_path_traversal("\x00"));
961        assert!(contains_path_traversal("path\x00/file"));
962        assert!(contains_path_traversal("%00"));
963        assert!(contains_path_traversal("path/%00/file"));
964
965        // Clean paths
966        assert!(!contains_path_traversal("/path/to/file"));
967        assert!(!contains_path_traversal("certs/server.pem"));
968        assert!(!contains_path_traversal("/etc/nginx/ssl/cert.pem"));
969        assert!(!contains_path_traversal("./relative/path")); // Single dot is OK
970    }
971}