praxis_core/config/validate/
rules.rs1use 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#[expect(
29 clippy::multiple_inherent_impl,
30 reason = "validation is split into a dedicated module"
31)]
32impl Config {
33 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
72fn 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
96fn 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
133fn 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
145fn 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
160fn 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
187fn 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
210fn 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
223const MAX_THREADS: usize = 1_024;
229
230fn 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#[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}