Skip to main content

max / makenotwork

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