Skip to main content

praxis_core/config/validate/
rules.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Top-level configuration validation orchestration.
5
6use std::{
7    collections::HashSet,
8    path::{Component, Path},
9};
10
11use tracing::warn;
12
13use super::{
14    branch_chain::validate_branch_chains,
15    cluster::validate_clusters,
16    filter_chain::validate_filter_chains,
17    listener::{validate_listener_names, validate_listeners},
18};
19use crate::{
20    config::{ABSOLUTE_MAX_BODY_BYTES, BodyLimitsConfig, Config, InsecureOptions, ProtocolKind},
21    errors::ProxyError,
22};
23
24// -----------------------------------------------------------------------------
25// Config Validation
26// -----------------------------------------------------------------------------
27
28#[expect(
29    clippy::multiple_inherent_impl,
30    reason = "validation is split into a dedicated module"
31)]
32impl Config {
33    /// Validate config constraints.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ProxyError::Config`] if any constraint is violated.
38    ///
39    /// ```
40    /// use praxis_core::config::Config;
41    ///
42    /// let err = Config::from_yaml("listeners: []\n").unwrap_err();
43    /// assert!(err.to_string().contains("at least one listener"));
44    /// ```
45    pub fn validate(&mut self) -> Result<(), ProxyError> {
46        warn_active_insecure_options(&self.insecure_options);
47        validate_listeners(&mut self.listeners)?;
48        validate_listener_names(&self.listeners)?;
49        validate_filter_chains(&self.filter_chains, &self.listeners)?;
50        validate_branch_chains(&self.filter_chains)?;
51        validate_admin_address(self.admin.address.as_deref(), self.insecure_options.allow_public_admin)?;
52
53        for listener in &self.listeners {
54            if listener.protocol != ProtocolKind::Tcp && listener.filter_chains.is_empty() {
55                return Err(ProxyError::Config(format!(
56                    "listener '{}': at least one filter chain required for HTTP listeners",
57                    listener.name
58                )));
59            }
60        }
61
62        validate_body_limits(&self.body_limits, self.insecure_options.allow_unbounded_body)?;
63        validate_cluster_names(&self.clusters)?;
64        validate_clusters(&self.clusters, &self.insecure_options)?;
65        validate_upstream_ca_file(self.runtime.upstream_ca_file.as_deref())?;
66        validate_runtime_threads(self.runtime.threads)?;
67
68        Ok(())
69    }
70}
71
72// -----------------------------------------------------------------------------
73// Insecure Options Warning
74// -----------------------------------------------------------------------------
75
76/// Emit a warning for each active insecure option flag.
77fn warn_active_insecure_options(opts: &InsecureOptions) {
78    let flags = [
79        ("allow_open_security_filters", opts.allow_open_security_filters),
80        ("allow_private_endpoints", opts.allow_private_endpoints),
81        ("allow_private_health_checks", opts.allow_private_health_checks),
82        ("allow_public_admin", opts.allow_public_admin),
83        ("allow_root", opts.allow_root),
84        ("allow_tls_without_sni", opts.allow_tls_without_sni),
85        ("allow_unbounded_body", opts.allow_unbounded_body),
86        ("csrf_log_only", opts.csrf_log_only),
87        ("skip_pipeline_validation", opts.skip_pipeline_validation),
88    ];
89    for (name, active) in flags {
90        if active {
91            warn!(flag = name, "insecure_options flag is active");
92        }
93    }
94}
95
96// -----------------------------------------------------------------------------
97// Body Limits Validation
98// -----------------------------------------------------------------------------
99
100/// Require both body limits unless the operator opts out.
101fn validate_body_limits(limits: &BodyLimitsConfig, allow_unbounded: bool) -> Result<(), ProxyError> {
102    validate_body_limit_ceiling("max_request_bytes", limits.max_request_bytes)?;
103    validate_body_limit_ceiling("max_response_bytes", limits.max_response_bytes)?;
104
105    let missing_request = limits.max_request_bytes.is_none();
106    let missing_response = limits.max_response_bytes.is_none();
107
108    if !missing_request && !missing_response {
109        return Ok(());
110    }
111
112    if allow_unbounded {
113        warn!(
114            max_request_bytes = ?limits.max_request_bytes,
115            max_response_bytes = ?limits.max_response_bytes,
116            "body limits not fully configured; allowed by insecure_options.allow_unbounded_body"
117        );
118        return Ok(());
119    }
120
121    Err(ProxyError::Config(format!(
122        "body_limits.max_request_bytes ({}) and body_limits.max_response_bytes ({}) \
123         must both be set; use insecure_options.allow_unbounded_body: true to override",
124        limits
125            .max_request_bytes
126            .map_or_else(|| "none".to_owned(), |v| v.to_string()),
127        limits
128            .max_response_bytes
129            .map_or_else(|| "none".to_owned(), |v| v.to_string()),
130    )))
131}
132
133/// Reject a body limit that exceeds the absolute ceiling.
134fn validate_body_limit_ceiling(field: &str, value: Option<usize>) -> Result<(), ProxyError> {
135    if let Some(v) = value
136        && v > ABSOLUTE_MAX_BODY_BYTES
137    {
138        return Err(ProxyError::Config(format!(
139            "body_limits.{field} ({v} bytes) exceeds maximum ({ABSOLUTE_MAX_BODY_BYTES} bytes / 64 MiB)"
140        )));
141    }
142    Ok(())
143}
144
145// -----------------------------------------------------------------------------
146// Cluster Name Validation
147// -----------------------------------------------------------------------------
148
149/// Reject duplicate cluster names.
150fn validate_cluster_names(clusters: &[crate::config::Cluster]) -> Result<(), ProxyError> {
151    let mut seen = HashSet::new();
152    for cluster in clusters {
153        if !seen.insert(&cluster.name) {
154            return Err(ProxyError::Config(format!("duplicate cluster name '{}'", cluster.name)));
155        }
156    }
157    Ok(())
158}
159
160// -----------------------------------------------------------------------------
161// Admin Address Validation
162// -----------------------------------------------------------------------------
163
164/// Reject admin addresses that bind to all interfaces unless explicitly allowed.
165fn validate_admin_address(addr: Option<&str>, allow_public: bool) -> Result<(), ProxyError> {
166    let Some(addr) = addr else { return Ok(()) };
167    let socket_addr: std::net::SocketAddr = addr
168        .parse()
169        .map_err(|_parse_err| ProxyError::Config(format!("invalid admin_address '{addr}'")))?;
170    if socket_addr.ip().is_unspecified() {
171        if allow_public {
172            warn!(
173                admin_address = %addr,
174                "admin endpoint binds to all interfaces; allowed by insecure_options.allow_public_admin"
175            );
176        } else {
177            return Err(ProxyError::Config(format!(
178                "admin endpoint '{addr}' binds to all interfaces; \
179                 bind to 127.0.0.1 or a management network, or set \
180                 insecure_options.allow_public_admin: true to allow"
181            )));
182        }
183    }
184    Ok(())
185}
186
187// -----------------------------------------------------------------------------
188// Upstream CA File Validation
189// -----------------------------------------------------------------------------
190
191/// Reject `upstream_ca_file` paths that contain directory traversal or do not exist.
192fn validate_upstream_ca_file(ca_file: Option<&str>) -> Result<(), ProxyError> {
193    let Some(path) = ca_file else { return Ok(()) };
194
195    if Path::new(path).components().any(|c| matches!(c, Component::ParentDir)) {
196        return Err(ProxyError::Config(format!(
197            "upstream_ca_file must not contain path traversal (..): {path}"
198        )));
199    }
200
201    if !Path::new(path).exists() {
202        return Err(ProxyError::Config(format!("upstream_ca_file does not exist: {path}")));
203    }
204
205    warn_if_symlink(path);
206
207    Ok(())
208}
209
210/// Emit a warning when a path is a symlink.
211fn warn_if_symlink(path: &str) {
212    let p = Path::new(path);
213    if p.is_symlink() {
214        let target = std::fs::canonicalize(p).map_or_else(|_| "unknown".to_owned(), |c| c.display().to_string());
215        warn!(
216            path = path,
217            target = %target,
218            "file is a symlink"
219        );
220    }
221}
222
223// -----------------------------------------------------------------------------
224// Runtime Validation
225// -----------------------------------------------------------------------------
226
227/// Maximum allowed worker threads per service.
228const MAX_THREADS: usize = 1_024;
229
230/// Reject unreasonable thread counts.
231fn validate_runtime_threads(threads: usize) -> Result<(), ProxyError> {
232    if threads > MAX_THREADS {
233        return Err(ProxyError::Config(format!(
234            "runtime.threads must be <= {MAX_THREADS}, got {threads}"
235        )));
236    }
237    Ok(())
238}
239
240// -----------------------------------------------------------------------------
241// Tests
242// -----------------------------------------------------------------------------
243
244#[cfg(test)]
245#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
246#[allow(
247    clippy::unwrap_used,
248    clippy::expect_used,
249    clippy::indexing_slicing,
250    clippy::needless_raw_strings,
251    clippy::needless_raw_string_hashes,
252    clippy::too_many_lines,
253    reason = "tests use unwrap/expect/indexing/raw strings for brevity"
254)]
255mod tests {
256    use crate::config::{Config, DEFAULT_MAX_BODY_BYTES, ProtocolKind};
257
258    #[test]
259    fn reject_invalid_admin_address() {
260        let yaml = r#"
261listeners:
262  - name: web
263    address: "0.0.0.0:8080"
264    filter_chains: [main]
265admin:
266  address: "not-valid"
267filter_chains:
268  - name: main
269    filters:
270      - filter: static_response
271        status: 200
272"#;
273        let err = Config::from_yaml(yaml).unwrap_err();
274        assert!(err.to_string().contains("invalid admin_address"), "got: {err}");
275    }
276
277    #[test]
278    fn accept_valid_admin_address() {
279        let yaml = r#"
280listeners:
281  - name: web
282    address: "0.0.0.0:8080"
283    filter_chains: [main]
284admin:
285  address: "127.0.0.1:9901"
286filter_chains:
287  - name: main
288    filters:
289      - filter: static_response
290        status: 200
291"#;
292        let config = Config::from_yaml(yaml).unwrap();
293        assert_eq!(config.admin.address.as_deref(), Some("127.0.0.1:9901"));
294    }
295
296    #[test]
297    fn reject_public_admin_address() {
298        let yaml = r#"
299listeners:
300  - name: web
301    address: "0.0.0.0:8080"
302    filter_chains: [main]
303admin:
304  address: "0.0.0.0:9901"
305filter_chains:
306  - name: main
307    filters:
308      - filter: static_response
309        status: 200
310"#;
311        let err = Config::from_yaml(yaml).unwrap_err();
312        assert!(
313            err.to_string().contains("binds to all interfaces"),
314            "should reject public admin: {err}"
315        );
316    }
317
318    #[test]
319    fn allow_public_admin_with_override() {
320        let yaml = r#"
321listeners:
322  - name: web
323    address: "0.0.0.0:8080"
324    filter_chains: [main]
325admin:
326  address: "0.0.0.0:9901"
327insecure_options:
328  allow_public_admin: true
329filter_chains:
330  - name: main
331    filters:
332      - filter: static_response
333        status: 200
334"#;
335        let config = Config::from_yaml(yaml).unwrap();
336        assert_eq!(
337            config.admin.address.as_deref(),
338            Some("0.0.0.0:9901"),
339            "allow_public_admin should permit public admin binding"
340        );
341    }
342
343    #[test]
344    fn reject_upstream_ca_file_traversal() {
345        let yaml = r#"
346listeners:
347  - name: web
348    address: "0.0.0.0:8080"
349    filter_chains: [main]
350runtime:
351  upstream_ca_file: /etc/../../tmp/evil-ca.pem
352filter_chains:
353  - name: main
354    filters:
355      - filter: static_response
356        status: 200
357"#;
358        let err = Config::from_yaml(yaml).unwrap_err();
359        assert!(
360            err.to_string().contains("path traversal"),
361            "should reject traversal: {err}"
362        );
363    }
364
365    #[test]
366    fn reject_upstream_ca_file_missing() {
367        let yaml = r#"
368listeners:
369  - name: web
370    address: "0.0.0.0:8080"
371    filter_chains: [main]
372runtime:
373  upstream_ca_file: nonexistent/ca.pem
374filter_chains:
375  - name: main
376    filters:
377      - filter: static_response
378        status: 200
379"#;
380        let err = Config::from_yaml(yaml).unwrap_err();
381        assert!(
382            err.to_string().contains("does not exist"),
383            "should reject missing file: {err}"
384        );
385    }
386
387    #[test]
388    fn accept_upstream_ca_file_when_file_exists() {
389        let dir = std::env::temp_dir().join("praxis-ca-test");
390        std::fs::create_dir_all(&dir).unwrap();
391        let ca_path = dir.join("test-ca.pem").to_string_lossy().into_owned();
392        std::fs::write(
393            &ca_path,
394            "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
395        )
396        .unwrap();
397
398        let yaml = format!(
399            r#"
400listeners:
401  - name: web
402    address: "0.0.0.0:8080"
403    filter_chains: [main]
404runtime:
405  upstream_ca_file: {ca_path}
406filter_chains:
407  - name: main
408    filters:
409      - filter: static_response
410        status: 200
411"#
412        );
413        let config = Config::from_yaml(&yaml).unwrap();
414        assert_eq!(
415            config.runtime.upstream_ca_file.as_deref(),
416            Some(ca_path.as_str()),
417            "upstream_ca_file should be accepted"
418        );
419
420        drop(std::fs::remove_dir_all(&dir));
421    }
422
423    #[test]
424    fn reject_no_filter_chains_for_http() {
425        let yaml = r#"
426listeners:
427  - name: web
428    address: "0.0.0.0:80"
429"#;
430        let err = Config::from_yaml(yaml).unwrap_err();
431        assert!(
432            err.to_string().contains("at least one filter chain"),
433            "should reject HTTP listener without chains: {err}"
434        );
435    }
436
437    #[test]
438    fn reject_http_listener_without_chains_when_sibling_has_chains() {
439        let yaml = r#"
440listeners:
441  - name: db
442    address: "0.0.0.0:5432"
443    protocol: tcp
444    upstream: "10.0.0.1:5432"
445    filter_chains: [tcp_chain]
446  - name: web
447    address: "0.0.0.0:8080"
448filter_chains:
449  - name: tcp_chain
450    filters:
451      - filter: static_response
452        status: 200
453"#;
454        let err = Config::from_yaml(yaml).unwrap_err();
455        assert!(
456            err.to_string().contains("listener 'web'"),
457            "should name the HTTP listener without chains: {err}"
458        );
459    }
460
461    #[test]
462    fn tcp_only_config_needs_no_pipeline() {
463        let yaml = r#"
464listeners:
465  - name: db
466    address: "0.0.0.0:5432"
467    protocol: tcp
468    upstream: "10.0.0.1:5432"
469"#;
470        let config = Config::from_yaml(yaml).unwrap();
471        assert_eq!(
472            config.listeners[0].protocol,
473            ProtocolKind::Tcp,
474            "protocol should be Tcp"
475        );
476    }
477
478    #[test]
479    fn reject_duplicate_cluster_names() {
480        let yaml = r#"
481listeners:
482  - name: web
483    address: "0.0.0.0:80"
484    filter_chains: [main]
485filter_chains:
486  - name: main
487    filters:
488      - filter: static_response
489        status: 200
490clusters:
491  - name: backend
492    endpoints: ["10.0.0.1:80"]
493  - name: backend
494    endpoints: ["10.0.0.2:80"]
495"#;
496        let err = Config::from_yaml(yaml).unwrap_err();
497        assert!(
498            err.to_string().contains("duplicate cluster name 'backend'"),
499            "should reject duplicate cluster names: {err}"
500        );
501    }
502
503    #[test]
504    fn reject_empty_listener_name() {
505        let yaml = r#"
506listeners:
507  - name: ""
508    address: "0.0.0.0:8080"
509    filter_chains: [main]
510filter_chains:
511  - name: main
512    filters:
513      - filter: static_response
514        status: 200
515"#;
516        let err = Config::from_yaml(yaml).unwrap_err();
517        assert!(
518            err.to_string().contains("name must not be empty"),
519            "should reject empty listener name: {err}"
520        );
521    }
522
523    #[test]
524    fn reject_excessive_threads() {
525        let yaml = r#"
526listeners:
527  - name: web
528    address: "0.0.0.0:8080"
529    filter_chains: [main]
530runtime:
531  threads: 10000
532filter_chains:
533  - name: main
534    filters:
535      - filter: static_response
536        status: 200
537"#;
538        let err = Config::from_yaml(yaml).unwrap_err();
539        assert!(
540            err.to_string().contains("threads must be <= 1024"),
541            "should reject excessive threads: {err}"
542        );
543    }
544
545    #[test]
546    fn accept_valid_threads() {
547        let yaml = r#"
548listeners:
549  - name: web
550    address: "0.0.0.0:8080"
551    filter_chains: [main]
552runtime:
553  threads: 16
554filter_chains:
555  - name: main
556    filters:
557      - filter: static_response
558        status: 200
559"#;
560        Config::from_yaml(yaml).unwrap();
561    }
562
563    #[test]
564    fn accept_threads_at_max() {
565        let yaml = r#"
566listeners:
567  - name: web
568    address: "0.0.0.0:8080"
569    filter_chains: [main]
570runtime:
571  threads: 1024
572filter_chains:
573  - name: main
574    filters:
575      - filter: static_response
576        status: 200
577"#;
578        Config::from_yaml(yaml).unwrap();
579    }
580
581    #[test]
582    fn reject_invalid_yaml() {
583        let err = Config::from_yaml("not: [valid: yaml: {{").unwrap_err();
584        assert!(err.to_string().contains("invalid YAML"));
585    }
586
587    #[test]
588    fn reject_null_body_limits() {
589        let yaml = r#"
590listeners:
591  - name: web
592    address: "0.0.0.0:8080"
593    filter_chains: [main]
594body_limits:
595  max_request_bytes: null
596filter_chains:
597  - name: main
598    filters:
599      - filter: static_response
600        status: 200
601"#;
602        let err = Config::from_yaml(yaml).unwrap_err();
603        assert!(
604            err.to_string().contains("allow_unbounded_body"),
605            "should reject null body limits: {err}"
606        );
607    }
608
609    #[test]
610    fn reject_body_limits_exceeding_ceiling() {
611        let yaml = r#"
612listeners:
613  - name: web
614    address: "0.0.0.0:8080"
615    filter_chains: [main]
616body_limits:
617  max_request_bytes: 100000000
618filter_chains:
619  - name: main
620    filters:
621      - filter: static_response
622        status: 200
623"#;
624        let err = Config::from_yaml(yaml).unwrap_err();
625        assert!(
626            err.to_string().contains("exceeds maximum"),
627            "body limit above 64 MiB should be rejected: {err}"
628        );
629    }
630
631    #[test]
632    fn accept_body_limits_at_ceiling() {
633        let yaml = r#"
634listeners:
635  - name: web
636    address: "0.0.0.0:8080"
637    filter_chains: [main]
638body_limits:
639  max_request_bytes: 67108864
640filter_chains:
641  - name: main
642    filters:
643      - filter: static_response
644        status: 200
645"#;
646        Config::from_yaml(yaml).unwrap();
647    }
648
649    #[test]
650    fn accept_null_body_limits_with_insecure_flag() {
651        let yaml = r#"
652listeners:
653  - name: web
654    address: "0.0.0.0:8080"
655    filter_chains: [main]
656body_limits:
657  max_request_bytes: null
658  max_response_bytes: null
659insecure_options:
660  allow_unbounded_body: true
661filter_chains:
662  - name: main
663    filters:
664      - filter: static_response
665        status: 200
666"#;
667        Config::from_yaml(yaml).unwrap();
668    }
669
670    #[test]
671    fn accept_default_body_limits() {
672        let yaml = r#"
673listeners:
674  - name: web
675    address: "0.0.0.0:8080"
676    filter_chains: [main]
677filter_chains:
678  - name: main
679    filters:
680      - filter: static_response
681        status: 200
682"#;
683        let config = Config::from_yaml(yaml).unwrap();
684        assert_eq!(
685            config.body_limits.max_request_bytes,
686            Some(DEFAULT_MAX_BODY_BYTES),
687            "default body limit should be 10 MiB"
688        );
689    }
690
691    #[test]
692    fn accept_valid_unique_listener_names() {
693        let yaml = r#"
694listeners:
695  - name: web
696    address: "0.0.0.0:8080"
697    filter_chains: [main]
698  - name: api
699    address: "0.0.0.0:9090"
700    filter_chains: [main]
701filter_chains:
702  - name: main
703    filters:
704      - filter: static_response
705        status: 200
706"#;
707        let config = Config::from_yaml(yaml);
708        assert!(
709            config.is_ok(),
710            "unique listener names should be accepted: {:?}",
711            config.err()
712        );
713    }
714
715    #[test]
716    fn accept_valid_unique_cluster_names() {
717        let yaml = r#"
718listeners:
719  - name: web
720    address: "0.0.0.0:8080"
721    filter_chains: [main]
722filter_chains:
723  - name: main
724    filters:
725      - filter: static_response
726        status: 200
727clusters:
728  - name: backend_a
729    endpoints: ["10.0.0.1:80"]
730  - name: backend_b
731    endpoints: ["10.0.0.2:80"]
732"#;
733        let config = Config::from_yaml(yaml);
734        assert!(
735            config.is_ok(),
736            "unique cluster names should be accepted: {:?}",
737            config.err()
738        );
739    }
740}