Skip to main content

max / makenotwork

pom: config hardening — env precedence, route validation, secret redaction - Env tokens now take precedence over the config file (POM_API_TOKEN / POM_POSTMARK_TOKEN), matching the documented behavior; the old code applied env only when the config value was absent, so a stale pom.toml token silently won over an env rotation. - expected_routes now requires a [health] config (error at load, not a silent no-op) — the route base URL is derived from it. - Config/AlertConfig/PeerConfig/ServeConfig get manual Debug impls that redact the token fields (show presence, not value), closing the latent secret-leak footgun on any future log/panic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 17:18 UTC
Commit: 20095e9e033af6a606c3a85e964691851cd3502b
Parent: 025bd83
1 file changed, +104 insertions, -13 deletions
M pom/src/config.rs +104 -13
@@ -26,7 +26,9 @@ pub struct Config {
26 26 pub alerts: Option<AlertConfig>,
27 27 }
28 28
29 - #[derive(Debug, Clone, Deserialize)]
29 + // Manual Debug (below) redacts the token — keep field lists in sync when adding
30 + // fields. Secrets in a derived Debug are a latent leak on any future log/panic.
31 + #[derive(Clone, Deserialize)]
30 32 pub struct AlertConfig {
31 33 /// Postmark server API token. Can also be set via `POM_POSTMARK_TOKEN` env var.
32 34 pub postmark_token: Option<String>,
@@ -42,6 +44,18 @@ pub struct AlertConfig {
42 44 pub wam_url: Option<String>,
43 45 }
44 46
47 + impl std::fmt::Debug for AlertConfig {
48 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 + f.debug_struct("AlertConfig")
50 + .field("postmark_token", &self.postmark_token.as_ref().map(|_| "***"))
51 + .field("to", &self.to)
52 + .field("from", &self.from)
53 + .field("cooldown_secs", &self.cooldown_secs)
54 + .field("wam_url", &self.wam_url)
55 + .finish()
56 + }
57 + }
58 +
45 59 #[derive(Debug, Clone, Default, Deserialize)]
46 60 pub struct InstanceConfig {
47 61 /// Human-readable instance name. Falls back to OS hostname if unset.
@@ -50,7 +64,7 @@ pub struct InstanceConfig {
50 64 pub id: Option<String>,
51 65 }
52 66
53 - #[derive(Debug, Clone, Deserialize)]
67 + #[derive(Clone, Deserialize)]
54 68 pub struct PeerConfig {
55 69 /// Network address of the peer (host:port).
56 70 pub address: String,
@@ -64,7 +78,19 @@ pub struct PeerConfig {
64 78 pub token: Option<String>,
65 79 }
66 80
67 - #[derive(Debug, Clone, Deserialize)]
81 + impl std::fmt::Debug for PeerConfig {
82 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 + f.debug_struct("PeerConfig")
84 + .field("address", &self.address)
85 + .field("on_missing", &self.on_missing)
86 + .field("grace_count", &self.grace_count)
87 + .field("token", &self.token.as_ref().map(|_| "***"))
88 + .finish()
89 + }
90 + }
91 +
92 + // Manual Debug (below) redacts `api_token`; keep field lists in sync.
93 + #[derive(Clone, Deserialize)]
68 94 pub struct ServeConfig {
69 95 /// Seconds between health check cycles for all targets.
70 96 #[serde(default = "default_serve_interval")]
@@ -107,6 +133,25 @@ pub struct ServeConfig {
107 133 pub confirmations: u32,
108 134 }
109 135
136 + impl std::fmt::Debug for ServeConfig {
137 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 + f.debug_struct("ServeConfig")
139 + .field("interval_secs", &self.interval_secs)
140 + .field("prune_days", &self.prune_days)
141 + .field("listen", &self.listen)
142 + .field("peer_heartbeat_secs", &self.peer_heartbeat_secs)
143 + .field("tls_check_interval_secs", &self.tls_check_interval_secs)
144 + .field("route_check_interval_secs", &self.route_check_interval_secs)
145 + .field("dns_check_interval_secs", &self.dns_check_interval_secs)
146 + .field("cors_check_interval_secs", &self.cors_check_interval_secs)
147 + .field("whois_check_interval_secs", &self.whois_check_interval_secs)
148 + .field("api_token", &self.api_token.as_ref().map(|_| "***"))
149 + .field("dashboard", &self.dashboard)
150 + .field("confirmations", &self.confirmations)
151 + .finish()
152 + }
153 + }
154 +
110 155 fn default_confirmations() -> u32 {
111 156 2
112 157 }
@@ -428,18 +473,16 @@ impl Config {
428 473 let contents = std::fs::read_to_string(&config_path)?;
429 474 let mut config: Config = toml::from_str(&contents)?;
430 475
431 - // Allow postmark_token from environment variable (preferred over config file)
432 - if let Some(ref mut alerts) = config.alerts
433 - && alerts.postmark_token.is_none()
434 - && let Ok(token) = std::env::var("POM_POSTMARK_TOKEN")
476 + // Environment variables take precedence over the config file, so a token
477 + // can be rotated without editing pom.toml. This is the documented
478 + // behavior; the earlier code applied env only when the config value was
479 + // absent, so a stale pom.toml token silently won over the env rotation.
480 + if let Ok(token) = std::env::var("POM_POSTMARK_TOKEN")
481 + && let Some(ref mut alerts) = config.alerts
435 482 {
436 483 alerts.postmark_token = Some(token);
437 484 }
438 -
439 - // Allow api_token from environment variable (preferred over config file)
440 - if config.serve.api_token.is_none()
441 - && let Ok(token) = std::env::var("POM_API_TOKEN")
442 - {
485 + if let Ok(token) = std::env::var("POM_API_TOKEN") {
443 486 config.serve.api_token = Some(token);
444 487 }
445 488
@@ -454,8 +497,16 @@ impl Config {
454 497 }
455 498 }
456 499
457 - // Validate expected_routes paths start with '/'
500 + // Validate expected_routes: they need `health` for base-URL derivation,
501 + // so a target that lists routes but has no `[health]` would silently never
502 + // check them. Fail loudly instead of no-op'ing. Each path must be rooted.
458 503 for (name, target) in &config.targets {
504 + if !target.expected_routes.is_empty() && target.health.is_none() {
505 + return Err(PomError::Config(format!(
506 + "target {name}: expected_routes requires a [health] config \
507 + (the route base URL is derived from it)"
508 + )));
509 + }
459 510 for route in &target.expected_routes {
460 511 if !route.starts_with('/') {
461 512 return Err(PomError::Config(format!(
@@ -1150,4 +1201,44 @@ url = "https://example.com"
1150 1201 "expected Config error rejecting bad route; got {result:?}"
1151 1202 );
1152 1203 }
1204 +
1205 + #[test]
1206 + fn config_load_rejects_expected_routes_without_health() {
1207 + // expected_routes needs [health] for base-URL derivation — a target that
1208 + // lists routes but has no health config would silently never check them.
1209 + let toml = r#"
1210 + [serve]
1211 + [targets.noroutes]
1212 + label = "NoHealth"
1213 + expected_routes = ["/status"]
1214 + "#;
1215 + let tmp = std::env::temp_dir().join(format!("pom_test_nh_{}.toml", std::process::id()));
1216 + std::fs::write(&tmp, toml).unwrap();
1217 + let result = Config::load(Some(tmp.as_path()));
1218 + let _ = std::fs::remove_file(&tmp);
1219 + assert!(
1220 + matches!(result, Err(PomError::Config(_))),
1221 + "expected_routes without [health] must be rejected; got {result:?}"
1222 + );
1223 + }
1224 +
1225 + #[test]
1226 + fn debug_redacts_secrets() {
1227 + let alerts = AlertConfig {
1228 + postmark_token: Some("super-secret-token".to_string()),
1229 + to: "a@b.c".to_string(),
1230 + from: "PoM".to_string(),
1231 + cooldown_secs: 300,
1232 + wam_url: None,
1233 + };
1234 + let rendered = format!("{alerts:?}");
1235 + assert!(!rendered.contains("super-secret-token"), "token must be redacted in Debug");
1236 + assert!(rendered.contains("***"), "redaction marker expected");
1237 +
1238 + let serve = ServeConfig {
1239 + api_token: Some("api-secret-xyz".to_string()),
1240 + ..ServeConfig::default()
1241 + };
1242 + assert!(!format!("{serve:?}").contains("api-secret-xyz"), "api_token must be redacted");
1243 + }
1153 1244 }