//! Deploy-config lint (test-only). //! //! Audit Run #9 found a forgeable `CF-Connecting-IP`: the custom-domain `:443` //! Caddy block proxied the whole app without Cloudflare mTLS and without //! sanitizing the client's IP headers, so a request reaching it could spoof the //! source IP the app trusts for rate-limiting, lockouts, and audit logs. The //! root failure mode was "convention enforced per-call-site" — every site block //! that proxies the app must declare a safe IP-trust posture, and nothing //! stopped a new block from forgetting. //! //! This lint makes that forgetting a build failure. Every top-level Caddy block //! that reverse-proxies the app (`localhost:3000`) must EITHER: //! - `import cloudflare_tls` — the request can only arrive via Cloudflare //! mTLS, which sets `CF-Connecting-IP` to the true client; or //! - set `CF-Connecting-IP` itself via `header_up CF-Connecting-IP ` //! — Caddy overwrites any client-supplied value with a trusted one. //! //! A block that does neither would let a client forge `CF-Connecting-IP`, so the //! lint fails and names the block. /// The app's reverse-proxy upstream port. Matched host-agnostically (on a /// `reverse_proxy` line) so `localhost:3000`, `127.0.0.1:3000`, or a bare /// `:3000` upstream are all recognized — a host rewrite can't slip a new /// app-proxy block past the lint. const APP_UPSTREAM: &str = ":3000"; /// The Caddyfile, embedded at compile time relative to the crate root so the /// test does not depend on the working directory. const CADDYFILE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/deploy/Caddyfile")); /// Split a Caddyfile into its top-level blocks as `(header, body)` pairs, where /// `body` is the full text between the block's outermost braces (nested blocks /// included). Line comments (`#` to end of line) are stripped first. fn top_level_blocks(src: &str) -> Vec<(String, String)> { let cleaned: String = src .lines() .map(|l| match l.find('#') { Some(i) => &l[..i], None => l, }) .collect::>() .join("\n"); let chars: Vec = cleaned.chars().collect(); let mut blocks = Vec::new(); let mut depth: i32 = 0; let mut seg_start = 0usize; // start of the current top-level header text let mut body_start = 0usize; let mut header = String::new(); for (i, &c) in chars.iter().enumerate() { match c { '{' => { if depth == 0 { header = chars[seg_start..i].iter().collect::().trim().to_string(); body_start = i + 1; } depth += 1; } '}' => { depth -= 1; if depth == 0 { let body: String = chars[body_start..i].iter().collect(); blocks.push((header.clone(), body)); seg_start = i + 1; } } _ => {} } } blocks } /// Whether a block body actually reverse-proxies the app. Matches a /// `reverse_proxy ... localhost:3000` directive line — NOT an incidental mention /// of the upstream (e.g. the `on_demand_tls ask http://localhost:3000/...` URL /// in the global options block, which is not a proxy). fn proxies_app(body: &str) -> bool { body.lines() .any(|line| line.contains("reverse_proxy") && line.contains(APP_UPSTREAM)) } /// Whether a block body declares a safe IP-trust posture (see module docs). fn has_safe_ip_posture(body: &str) -> bool { let imports_mtls = body.contains("import cloudflare_tls"); let sets_cf_ip = body.lines().any(|line| { let toks: Vec<&str> = line.split_whitespace().collect(); // Set form: `header_up CF-Connecting-IP `. The delete form // (`header_up -CF-Connecting-IP`) does NOT count — it removes without // replacing, leaving the fallback exposed. toks.len() >= 3 && toks[0] == "header_up" && toks[1].eq_ignore_ascii_case("CF-Connecting-IP") }); imports_mtls || sets_cf_ip } #[cfg(test)] mod tests { use super::*; #[test] fn every_app_proxy_block_declares_safe_ip_posture() { let blocks = top_level_blocks(CADDYFILE); let mut proxying = 0usize; for (header, body) in &blocks { if !proxies_app(body) { continue; } proxying += 1; assert!( has_safe_ip_posture(body), "Caddy block `{}` reverse-proxies the app ({APP_UPSTREAM}) but neither \ `import cloudflare_tls` (mTLS) nor sets `CF-Connecting-IP` via `header_up`. \ It would trust a client-forged source IP — defeating rate limits, lockouts, \ and audit-log IP attribution. Add one of the two postures (see \ src/deploy_lint.rs).", if header.is_empty() { "" } else { header } ); } // Guard against a silently-matching parser (file moved/renamed, upstream // port changed): there must be at least one app-proxy block to check. assert!( proxying >= 1, "deploy lint found no Caddy block proxying {APP_UPSTREAM}; the parser or the \ Caddyfile layout changed — fix the lint, do not delete it." ); } #[test] fn parser_splits_blocks_and_skips_snippets_and_globals() { let src = "{\n\tglobal\n}\n\n(snippet) {\n\timport nothing\n}\n\nexample.com {\n\treverse_proxy localhost:3000\n}\n"; let blocks = top_level_blocks(src); let headers: Vec<&str> = blocks.iter().map(|(h, _)| h.as_str()).collect(); assert!(headers.contains(&""), "global options block (empty header) parsed"); assert!(headers.contains(&"(snippet)")); assert!(headers.contains(&"example.com")); } #[test] fn proxies_app_ignores_non_proxy_mentions() { // The global options block references the upstream in an ask URL, not a // reverse_proxy — it must not be treated as serving the app. assert!(!proxies_app( "on_demand_tls {\n\task http://localhost:3000/api/domains/caddy-ask\n}\n" )); assert!(proxies_app("reverse_proxy localhost:3000\n")); assert!(proxies_app("reverse_proxy localhost:3000 {\n\theader_up X 1\n}\n")); // Host-agnostic: a 127.0.0.1 (or bare-port) rewrite is still detected. assert!(proxies_app("reverse_proxy 127.0.0.1:3000\n")); assert!(!proxies_app("reverse_proxy localhost:3400\n")); } #[test] fn posture_accepts_mtls_import() { assert!(has_safe_ip_posture("import cloudflare_tls\nreverse_proxy localhost:3000\n")); } #[test] fn posture_accepts_header_up_set() { assert!(has_safe_ip_posture( "reverse_proxy localhost:3000 {\n\theader_up CF-Connecting-IP {http.request.remote.host}\n}\n" )); } #[test] fn posture_rejects_bare_proxy() { assert!(!has_safe_ip_posture("reverse_proxy localhost:3000\n")); } #[test] fn posture_rejects_delete_only_header() { // Deleting without setting leaves the app on its (peer-IP) fallback with // no trusted CF-Connecting-IP — not a substitute for the set form. assert!(!has_safe_ip_posture( "reverse_proxy localhost:3000 {\n\theader_up -CF-Connecting-IP\n}\n" )); } }