Skip to main content

max / makenotwork

7.4 KB · 183 lines History Blame Raw
1 //! Deploy-config lint (test-only).
2 //!
3 //! Audit Run #9 found a forgeable `CF-Connecting-IP`: the custom-domain `:443`
4 //! Caddy block proxied the whole app without Cloudflare mTLS and without
5 //! sanitizing the client's IP headers, so a request reaching it could spoof the
6 //! source IP the app trusts for rate-limiting, lockouts, and audit logs. The
7 //! root failure mode was "convention enforced per-call-site" — every site block
8 //! that proxies the app must declare a safe IP-trust posture, and nothing
9 //! stopped a new block from forgetting.
10 //!
11 //! This lint makes that forgetting a build failure. Every top-level Caddy block
12 //! that reverse-proxies the app (`localhost:3000`) must EITHER:
13 //! - `import cloudflare_tls` — the request can only arrive via Cloudflare
14 //! mTLS, which sets `CF-Connecting-IP` to the true client; or
15 //! - set `CF-Connecting-IP` itself via `header_up CF-Connecting-IP <value>`
16 //! — Caddy overwrites any client-supplied value with a trusted one.
17 //!
18 //! A block that does neither would let a client forge `CF-Connecting-IP`, so the
19 //! lint fails and names the block.
20
21 /// The app's reverse-proxy upstream port. Matched host-agnostically (on a
22 /// `reverse_proxy` line) so `localhost:3000`, `127.0.0.1:3000`, or a bare
23 /// `:3000` upstream are all recognized — a host rewrite can't slip a new
24 /// app-proxy block past the lint.
25 const APP_UPSTREAM: &str = ":3000";
26
27 /// The Caddyfile, embedded at compile time relative to the crate root so the
28 /// test does not depend on the working directory.
29 const CADDYFILE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/deploy/Caddyfile"));
30
31 /// Split a Caddyfile into its top-level blocks as `(header, body)` pairs, where
32 /// `body` is the full text between the block's outermost braces (nested blocks
33 /// included). Line comments (`#` to end of line) are stripped first.
34 fn top_level_blocks(src: &str) -> Vec<(String, String)> {
35 let cleaned: String = src
36 .lines()
37 .map(|l| match l.find('#') {
38 Some(i) => &l[..i],
39 None => l,
40 })
41 .collect::<Vec<_>>()
42 .join("\n");
43
44 let chars: Vec<char> = cleaned.chars().collect();
45 let mut blocks = Vec::new();
46 let mut depth: i32 = 0;
47 let mut seg_start = 0usize; // start of the current top-level header text
48 let mut body_start = 0usize;
49 let mut header = String::new();
50
51 for (i, &c) in chars.iter().enumerate() {
52 match c {
53 '{' => {
54 if depth == 0 {
55 header = chars[seg_start..i].iter().collect::<String>().trim().to_string();
56 body_start = i + 1;
57 }
58 depth += 1;
59 }
60 '}' => {
61 depth -= 1;
62 if depth == 0 {
63 let body: String = chars[body_start..i].iter().collect();
64 blocks.push((header.clone(), body));
65 seg_start = i + 1;
66 }
67 }
68 _ => {}
69 }
70 }
71
72 blocks
73 }
74
75 /// Whether a block body actually reverse-proxies the app. Matches a
76 /// `reverse_proxy ... localhost:3000` directive line — NOT an incidental mention
77 /// of the upstream (e.g. the `on_demand_tls ask http://localhost:3000/...` URL
78 /// in the global options block, which is not a proxy).
79 fn proxies_app(body: &str) -> bool {
80 body.lines()
81 .any(|line| line.contains("reverse_proxy") && line.contains(APP_UPSTREAM))
82 }
83
84 /// Whether a block body declares a safe IP-trust posture (see module docs).
85 fn has_safe_ip_posture(body: &str) -> bool {
86 let imports_mtls = body.contains("import cloudflare_tls");
87 let sets_cf_ip = body.lines().any(|line| {
88 let toks: Vec<&str> = line.split_whitespace().collect();
89 // Set form: `header_up CF-Connecting-IP <value>`. The delete form
90 // (`header_up -CF-Connecting-IP`) does NOT count — it removes without
91 // replacing, leaving the fallback exposed.
92 toks.len() >= 3
93 && toks[0] == "header_up"
94 && toks[1].eq_ignore_ascii_case("CF-Connecting-IP")
95 });
96 imports_mtls || sets_cf_ip
97 }
98
99 #[cfg(test)]
100 mod tests {
101 use super::*;
102
103 #[test]
104 fn every_app_proxy_block_declares_safe_ip_posture() {
105 let blocks = top_level_blocks(CADDYFILE);
106 let mut proxying = 0usize;
107
108 for (header, body) in &blocks {
109 if !proxies_app(body) {
110 continue;
111 }
112 proxying += 1;
113 assert!(
114 has_safe_ip_posture(body),
115 "Caddy block `{}` reverse-proxies the app ({APP_UPSTREAM}) but neither \
116 `import cloudflare_tls` (mTLS) nor sets `CF-Connecting-IP` via `header_up`. \
117 It would trust a client-forged source IP — defeating rate limits, lockouts, \
118 and audit-log IP attribution. Add one of the two postures (see \
119 src/deploy_lint.rs).",
120 if header.is_empty() { "<catch-all>" } else { header }
121 );
122 }
123
124 // Guard against a silently-matching parser (file moved/renamed, upstream
125 // port changed): there must be at least one app-proxy block to check.
126 assert!(
127 proxying >= 1,
128 "deploy lint found no Caddy block proxying {APP_UPSTREAM}; the parser or the \
129 Caddyfile layout changed — fix the lint, do not delete it."
130 );
131 }
132
133 #[test]
134 fn parser_splits_blocks_and_skips_snippets_and_globals() {
135 let src = "{\n\tglobal\n}\n\n(snippet) {\n\timport nothing\n}\n\nexample.com {\n\treverse_proxy localhost:3000\n}\n";
136 let blocks = top_level_blocks(src);
137 let headers: Vec<&str> = blocks.iter().map(|(h, _)| h.as_str()).collect();
138 assert!(headers.contains(&""), "global options block (empty header) parsed");
139 assert!(headers.contains(&"(snippet)"));
140 assert!(headers.contains(&"example.com"));
141 }
142
143 #[test]
144 fn proxies_app_ignores_non_proxy_mentions() {
145 // The global options block references the upstream in an ask URL, not a
146 // reverse_proxy — it must not be treated as serving the app.
147 assert!(!proxies_app(
148 "on_demand_tls {\n\task http://localhost:3000/api/domains/caddy-ask\n}\n"
149 ));
150 assert!(proxies_app("reverse_proxy localhost:3000\n"));
151 assert!(proxies_app("reverse_proxy localhost:3000 {\n\theader_up X 1\n}\n"));
152 // Host-agnostic: a 127.0.0.1 (or bare-port) rewrite is still detected.
153 assert!(proxies_app("reverse_proxy 127.0.0.1:3000\n"));
154 assert!(!proxies_app("reverse_proxy localhost:3400\n"));
155 }
156
157 #[test]
158 fn posture_accepts_mtls_import() {
159 assert!(has_safe_ip_posture("import cloudflare_tls\nreverse_proxy localhost:3000\n"));
160 }
161
162 #[test]
163 fn posture_accepts_header_up_set() {
164 assert!(has_safe_ip_posture(
165 "reverse_proxy localhost:3000 {\n\theader_up CF-Connecting-IP {http.request.remote.host}\n}\n"
166 ));
167 }
168
169 #[test]
170 fn posture_rejects_bare_proxy() {
171 assert!(!has_safe_ip_posture("reverse_proxy localhost:3000\n"));
172 }
173
174 #[test]
175 fn posture_rejects_delete_only_header() {
176 // Deleting without setting leaves the app on its (peer-IP) fallback with
177 // no trusted CF-Connecting-IP — not a substitute for the set form.
178 assert!(!has_safe_ip_posture(
179 "reverse_proxy localhost:3000 {\n\theader_up -CF-Connecting-IP\n}\n"
180 ));
181 }
182 }
183