Skip to main content

max / makenotwork

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