Skip to main content

max / makenotwork

pom: scrub terminal control chars from remote strings; don't panic CORS task ANSI/terminal injection: display.rs interpolated untrusted remote strings (health version/uptime/error, whois/DNS/TLS errors, staleness reason, and every peer-controlled format_mesh field) straight into terminal output. A monitored host returning "1.0<ESC>[2J FAKE ALL-CLEAR" — or any TCP service on an ssh_banner target — could clear or rewrite the operator's terminal on pom status/mesh (fuzz-2026-07-06). Route every such value through scrub(), which drops C0/C1 controls (incl. ESC) and DEL at the display sink. cors.rs built its reqwest client with .build().unwrap(), so a client-build failure panicked the per-target CORS task and silently killed CORS monitoring with no alert (HIGH). Degrade gracefully to a default client like the health probe does. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-07 16:02 UTC
Commit: a74a90a672c7560ef17fe88bb4672c436623c76a
Parent: 5117237
2 files changed, +63 insertions, -16 deletions
@@ -9,11 +9,18 @@ use crate::types::CorsCheckResult;
9 9 /// Returns one `CorsCheckResult` per `CorsCheck` in the input.
10 10 #[instrument(skip_all)]
11 11 pub async fn check_cors(target: &str, checks: &[CorsCheck]) -> Vec<CorsCheckResult> {
12 + // Never `.unwrap()` the client build: a build failure would panic this
13 + // per-target CORS task and silently kill CORS monitoring for the target with
14 + // no alert that the check died (fuzz-2026-07-06 HIGH). Degrade gracefully to
15 + // a default client, mirroring the health probe.
12 16 let client = reqwest::Client::builder()
13 17 .timeout(std::time::Duration::from_secs(10))
14 18 .redirect(reqwest::redirect::Policy::none())
15 19 .build()
16 - .unwrap();
20 + .unwrap_or_else(|e| {
21 + tracing::warn!(error = %e, "cors: client build failed, using default client");
22 + reqwest::Client::new()
23 + });
17 24
18 25 let mut results = Vec::with_capacity(checks.len());
19 26 for check in checks {
M pom/src/display.rs +55 -15
@@ -8,6 +8,19 @@ use std::fmt::Write;
8 8 use crate::db::{DnsCheckRow, IncidentRow, PruneResult, RouteCheckRow, TlsCheckRow, WhoisCheckRow};
9 9 use crate::types::{DnsCheckResult, HealthSnapshot, LatencyStats, TestRun, TestStaleness, WhoisResult};
10 10
11 + /// Strip terminal control characters from an untrusted remote string before it
12 + /// is written to the operator's terminal.
13 + ///
14 + /// A monitored host's health `version`/`uptime`/`error`, a whois/DNS error, a
15 + /// mesh peer's fields, or *any* TCP service on an ssh_banner target can embed raw
16 + /// ANSI escape sequences (ESC/CSI) that clear or rewrite the screen — e.g. a fake
17 + /// all-clear — the moment they're printed on `pom status`/`mesh` (fuzz-2026-07-06
18 + /// ANSI/terminal injection). Every such value passes through here at the display
19 + /// sink. Keeps printable UTF-8; drops C0/C1 controls (incl. ESC, CR, LF) and DEL.
20 + fn scrub(s: &str) -> String {
21 + s.chars().filter(|c| !c.is_control()).collect()
22 + }
23 +
11 24 /// Format a single health snapshot as a human-readable line.
12 25 pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
13 26 let mut out = String::new();
@@ -15,15 +28,15 @@ pub fn format_health_snapshot(s: &HealthSnapshot) -> String {
15 28 write!(out, " ({}ms)", s.response_time_ms).unwrap();
16 29 if let Some(details) = &s.details {
17 30 if let Some(v) = &details.version {
18 - write!(out, " v{v}").unwrap();
31 + write!(out, " v{}", scrub(v)).unwrap();
19 32 }
20 33 if let Some(u) = &details.uptime {
21 - write!(out, " up {u}").unwrap();
34 + write!(out, " up {}", scrub(u)).unwrap();
22 35 }
23 36 }
24 37 writeln!(out).unwrap();
25 38 if let Some(err) = &s.error {
26 - writeln!(out, " {err}").unwrap();
39 + writeln!(out, " {}", scrub(err)).unwrap();
27 40 }
28 41 out
29 42 }
@@ -82,7 +95,7 @@ pub fn format_status_target(
82 95 if let Some(d) = &h.details
83 96 && let Some(v) = &d.version
84 97 {
85 - write!(out, " v{v}").unwrap();
98 + write!(out, " v{}", scrub(v)).unwrap();
86 99 }
87 100 writeln!(out).unwrap();
88 101 } else {
@@ -100,7 +113,7 @@ pub fn format_status_target(
100 113
101 114 if let Some(t) = tls {
102 115 if let Some(ref err) = t.error {
103 - writeln!(out, " TLS: [ERR] {} \u{2014} {err}", t.host).unwrap();
116 + writeln!(out, " TLS: [ERR] {} \u{2014} {}", t.host, scrub(err)).unwrap();
104 117 } else if t.days_remaining <= 0 {
105 118 writeln!(out, " TLS: [ERR] {} \u{2014} EXPIRED (expired {})", t.host, t.not_after).unwrap();
106 119 } else if t.days_remaining <= 14 {
@@ -138,7 +151,7 @@ pub fn format_status_target(
138 151
139 152 if let Some(w) = whois {
140 153 if let Some(ref err) = w.error {
141 - writeln!(out, " WHOIS: [ERR] {} \u{2014} {err}", w.domain).unwrap();
154 + writeln!(out, " WHOIS: [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap();
142 155 } else if let Some(days) = w.days_remaining {
143 156 if days <= 0 {
144 157 writeln!(out, " WHOIS: [ERR] {} \u{2014} EXPIRED", w.domain).unwrap();
@@ -168,7 +181,7 @@ pub fn format_status_target(
168 181 && s.stale
169 182 && let Some(reason) = &s.reason
170 183 {
171 - writeln!(out, " Tests: STALE \u{2014} {reason}").unwrap();
184 + writeln!(out, " Tests: STALE \u{2014} {}", scrub(reason)).unwrap();
172 185 }
173 186
174 187 if let Some(inc) = incident {
@@ -253,7 +266,7 @@ pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[Whois
253 266 writeln!(out, "DNS Records:").unwrap();
254 267 for r in dns_results {
255 268 if let Some(ref err) = r.error {
256 - writeln!(out, " [ERR] {} {} \u{2014} {err}", r.name, r.record_type).unwrap();
269 + writeln!(out, " [ERR] {} {} \u{2014} {}", r.name, r.record_type, scrub(err)).unwrap();
257 270 } else if r.matches {
258 271 writeln!(out, " [OK] {} {} \u{2014} {:?}", r.name, r.record_type, r.actual).unwrap();
259 272 } else {
@@ -269,7 +282,7 @@ pub fn format_dns_results(dns_results: &[DnsCheckResult], whois_results: &[Whois
269 282 writeln!(out, "WHOIS:").unwrap();
270 283 for w in whois_results {
271 284 if let Some(ref err) = w.error {
272 - writeln!(out, " [ERR] {} \u{2014} {err}", w.domain).unwrap();
285 + writeln!(out, " [ERR] {} \u{2014} {}", w.domain, scrub(err)).unwrap();
273 286 } else {
274 287 let days_str = w.days_remaining
275 288 .map(|d| format!("{d}d remaining"))
@@ -310,9 +323,9 @@ pub fn format_mesh(data: &serde_json::Value) -> String {
310 323 .and_then(|v| v.as_str())
311 324 .unwrap_or("?");
312 325
313 - writeln!(out, "=== {name} ===").unwrap();
314 - writeln!(out, " ID: {id}").unwrap();
315 - writeln!(out, " Version: {version}").unwrap();
326 + writeln!(out, "=== {} ===", scrub(name)).unwrap();
327 + writeln!(out, " ID: {}", scrub(id)).unwrap();
328 + writeln!(out, " Version: {}", scrub(version)).unwrap();
316 329
317 330 // Targets
318 331 if let Some(targets) = instance_data.get("targets").and_then(|v| v.as_object()) {
@@ -325,7 +338,7 @@ pub fn format_mesh(data: &serde_json::Value) -> String {
325 338 .get("response_time_ms")
326 339 .and_then(|v| v.as_i64());
327 340 let ms_str = ms.map(|m| format!(" ({m}ms)")).unwrap_or_default();
328 - writeln!(out, " Target {target_name}: {status}{ms_str}").unwrap();
341 + writeln!(out, " Target {}: {}{ms_str}", scrub(target_name), scrub(status)).unwrap();
329 342 }
330 343 }
331 344
@@ -341,13 +354,13 @@ pub fn format_mesh(data: &serde_json::Value) -> String {
341 354 .and_then(|v| v.as_u64())
342 355 .map(|ms| format!(" ({ms}ms)"))
343 356 .unwrap_or_default();
344 - writeln!(out, " Peer {peer_name}: {status}{latency}").unwrap();
357 + writeln!(out, " Peer {}: {}{latency}", scrub(peer_name), scrub(status)).unwrap();
345 358 }
346 359 }
347 360
348 361 // Error fallback
349 362 if let Some(err) = instance_data.get("error").and_then(|v| v.as_str()) {
350 - writeln!(out, " ({err})").unwrap();
363 + writeln!(out, " ({})", scrub(err)).unwrap();
351 364 }
352 365
353 366 writeln!(out).unwrap();
@@ -360,6 +373,33 @@ mod tests {
360 373 use super::*;
361 374 use crate::types::*;
362 375
376 + #[test]
377 + fn scrub_strips_ansi_and_control_chars() {
378 + // A monitored host trying to clear/rewrite the operator's terminal.
379 + let hostile = "1.0\u{1b}[2J\u{1b}[1;1H FAKE ALL-CLEAR\r\n";
380 + let cleaned = scrub(hostile);
381 + assert!(!cleaned.contains('\u{1b}'), "ESC must be stripped");
382 + assert!(!cleaned.contains('\r') && !cleaned.contains('\n'));
383 + assert_eq!(cleaned, "1.0[2J[1;1H FAKE ALL-CLEAR");
384 + // Printable Unicode (e.g. the em-dash the display uses) is preserved.
385 + assert_eq!(scrub("v1.2 \u{2014} ok"), "v1.2 \u{2014} ok");
386 + }
387 +
388 + #[test]
389 + fn health_snapshot_scrubs_hostile_error() {
390 + let s = HealthSnapshot {
391 + id: None,
392 + target: "mnw".to_string(),
393 + status: HealthStatus::Error,
394 + checked_at: "2026-07-07T00:00:00+00:00".to_string(),
395 + response_time_ms: 5,
396 + details: None,
397 + error: Some("boom\u{1b}[2Jcleared".to_string()),
398 + };
399 + let out = format_health_snapshot(&s);
400 + assert!(!out.contains('\u{1b}'), "ESC from a remote error must not reach the terminal");
401 + }
402 +
363 403 // --- format_health_snapshot ---
364 404
365 405 #[test]