guard_core_engine/https_enforcement.rs
1//! The HTTPS enforcement gate: the reference pipeline's third check.
2//!
3//! This is the Rust family's port of
4//! `guard_core/core/checks/implementations/https_enforcement.py`. One call,
5//! [`decide`], decides whether a request that reached the engine over plain
6//! HTTP must be redirected to its `https` form:
7//!
8//! ```text
9//! route require_https set: the route verdict wins (Some(false) disables)
10//! otherwise: the global enforce_https knob decides
11//! not required: pass
12//! request already https: pass
13//! trusted-proxy upgrade: X-Forwarded-Proto: https counts as https, but
14//! only when trust_x_forwarded_proto is on, the
15//! trusted-proxy list is non-empty, the connecting
16//! IP is on it, and the header is present
17//! violation: Redirect (the reference's
18//! create_https_redirect: a 301 to the https URL)
19//! ```
20//!
21//! ## Details that mirror the reference exactly
22//!
23//! - The route config's `require_https` wins whenever a route config exists,
24//! even when it is `false` (the reference's
25//! `route_config.require_https if route_config else enforce_https`), so
26//! the route input is [`Option<bool>`] and `Some(false)` opts a route out
27//! of a globally enforced HTTPS.
28//! - The proxy-upgrade arm is the reference `_is_request_https`: the header
29//! alone never decides - a forged `X-Forwarded-Proto` from a client the
30//! deployment does not trust is ignored, exactly as in the reference. The
31//! header comparison is case-insensitive on the value (`forwarded.lower()
32//! == "https"`); a bare-IP proxy entry compares exactly, a CIDR entry
33//! parses the connecting address (the reference raises on an unparseable
34//! address into the pipeline's error handling; this port reads an
35//! unparseable address as not trusted, which keeps the seam total).
36//! - The verdict never says what to send: the reference's redirect comes
37//! from the response factory (`301`, `Location` = the request URL with
38//! the scheme replaced by `https`, then the response modifier). The
39//! stage that wires this seam composes the target URL - the engine only
40//! decides that the request must go there.
41//!
42//! # Example
43//!
44//! ```
45//! use guard_core_engine::https_enforcement::{decide, HttpsEnforcementConfig, HttpsRequest, HttpsVerdict};
46//!
47//! let config = HttpsEnforcementConfig::new(true, false, Vec::<String>::new()).expect("valid");
48//!
49//! // Plain HTTP under a global enforcement is a redirect.
50//! let request = HttpsRequest { url_scheme: "http", client_host: None, x_forwarded_proto: None, route_require_https: None };
51//! assert!(matches!(decide(&request, &config), HttpsVerdict::Redirect { .. }));
52//!
53//! // HTTPS passes, and a route can opt out of the global arm.
54//! let request = HttpsRequest { url_scheme: "https", client_host: None, x_forwarded_proto: None, route_require_https: None };
55//! assert_eq!(decide(&request, &config), HttpsVerdict::Allowed);
56//! let request = HttpsRequest { url_scheme: "http", client_host: None, x_forwarded_proto: None, route_require_https: Some(false) };
57//! assert_eq!(decide(&request, &config), HttpsVerdict::Allowed);
58//! ```
59
60use std::net::IpAddr;
61use std::str::FromStr;
62
63use crate::ip_gate::{IpGateError, canonical, parse_network_entry};
64
65/// The check's stable name (`check_name`).
66pub const HTTPS_ENFORCEMENT_CHECK_NAME: &str = "https_enforcement";
67
68/// The redirect status the reference's `create_https_redirect` answers with
69/// (`create_redirect_response(https_url, 301)`).
70pub const HTTPS_REDIRECT_STATUS: u16 = 301;
71
72/// The enforcement knobs (`SecurityConfig.enforce_https`,
73/// `.trust_x_forwarded_proto`, `.trusted_proxies`).
74#[derive(Debug, Clone, Default, PartialEq, Eq)]
75pub struct HttpsEnforcementConfig {
76 /// `enforce_https`: the global arm, used when no route config exists.
77 pub enforce_https: bool,
78 /// `trust_x_forwarded_proto`: whether the upgrade header is believed at
79 /// all (never without a trusted connecting IP).
80 pub trust_x_forwarded_proto: bool,
81 /// `trusted_proxies` as written: bare IPs compare exactly, CIDR ranges
82 /// parse the connecting address.
83 trusted_proxies_raw: Vec<String>,
84}
85
86impl HttpsEnforcementConfig {
87 /// Parse the trusted-proxy list, failing closed on an invalid entry
88 /// (the error names the list `trusted_proxies`, like the IP gate's
89 /// config errors).
90 ///
91 /// # Errors
92 ///
93 /// [`IpGateError`] naming the first entry that is neither a valid IP
94 /// nor a valid CIDR range.
95 pub fn new<I>(
96 enforce_https: bool,
97 trust_x_forwarded_proto: bool,
98 trusted_proxies: I,
99 ) -> Result<Self, IpGateError>
100 where
101 I: IntoIterator,
102 I::Item: AsRef<str>,
103 {
104 let mut proxies = Vec::new();
105 for entry in trusted_proxies {
106 let entry = entry.as_ref();
107 if parse_network_entry(entry).is_none() {
108 return Err(IpGateError {
109 list: "trusted_proxies",
110 entry: entry.to_owned(),
111 });
112 }
113 proxies.push(entry.to_owned());
114 }
115 Ok(Self {
116 enforce_https,
117 trust_x_forwarded_proto,
118 trusted_proxies_raw: proxies,
119 })
120 }
121
122 /// Whether `connecting` sits on a trusted proxy, the reference
123 /// `_is_trusted_proxy`: a bare entry compares exactly as written, a
124 /// CIDR entry parses the connecting address (an unparseable address
125 /// never matches, keeping the seam total where the reference raises).
126 fn is_trusted_proxy(&self, connecting: &str) -> bool {
127 for entry in &self.trusted_proxies_raw {
128 if !entry.contains('/') {
129 if connecting == entry {
130 return true;
131 }
132 } else if let Ok(addr) = IpAddr::from_str(connecting) {
133 // `parse_network_entry` accepted it at construction.
134 if let Some(network) = parse_network_entry(entry)
135 && (network.contains(canonical(addr)) || network.contains(addr))
136 {
137 return true;
138 }
139 }
140 }
141 false
142 }
143}
144
145/// The request facts the decision reads (`request.url_scheme`,
146/// `request.client_host`, the `X-Forwarded-Proto` header, and the route
147/// config's `require_https` when a route config exists).
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct HttpsRequest<'a> {
150 /// `request.url_scheme` (`http` / `https`).
151 pub url_scheme: &'a str,
152 /// `request.client_host`: the connecting address, when known.
153 pub client_host: Option<&'a str>,
154 /// The `X-Forwarded-Proto` header value, when present.
155 pub x_forwarded_proto: Option<&'a str>,
156 /// The route config's `require_https`; `None` means no route config
157 /// (the global knob decides).
158 pub route_require_https: Option<bool>,
159}
160
161/// What [`decide`] decided.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum HttpsVerdict {
164 /// The request stays on its scheme (already https, or not required).
165 Allowed,
166 /// The request must be redirected to its https form (the reference's
167 /// `create_https_redirect` shape: a [`HTTPS_REDIRECT_STATUS`] to the
168 /// scheme-upgraded URL). `route_scoped` splits the reference's two
169 /// event paths (`decorator_violation` when a route required HTTPS,
170 /// `https_enforced` for the global arm).
171 Redirect {
172 /// Whether a route config's `require_https` triggered the check.
173 route_scoped: bool,
174 },
175}
176
177/// Whether the request counts as HTTPS, the reference `_is_request_https`:
178/// the scheme decides, plus the trusted-proxy upgrade arm.
179fn is_request_https(request: &HttpsRequest<'_>, config: &HttpsEnforcementConfig) -> bool {
180 let mut is_https = request.url_scheme == "https";
181 if !is_https
182 && config.trust_x_forwarded_proto
183 && !config.trusted_proxies_raw.is_empty()
184 && let Some(host) = request.client_host
185 && config.is_trusted_proxy(host)
186 && let Some(forwarded) = request.x_forwarded_proto
187 {
188 is_https = forwarded.to_lowercase() == "https";
189 }
190 is_https
191}
192
193/// The reference check body: the route's `require_https` wins when a route
194/// config exists, else the global knob; a required plain-HTTP request is a
195/// redirect verdict.
196#[must_use]
197pub fn decide(request: &HttpsRequest<'_>, config: &HttpsEnforcementConfig) -> HttpsVerdict {
198 let https_required = request.route_require_https.unwrap_or(config.enforce_https);
199 if !https_required || is_request_https(request, config) {
200 return HttpsVerdict::Allowed;
201 }
202 HttpsVerdict::Redirect {
203 route_scoped: request.route_require_https.is_some(),
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 fn request<'a>(
212 scheme: &'a str,
213 client_host: Option<&'a str>,
214 xfp: Option<&'a str>,
215 route: Option<bool>,
216 ) -> HttpsRequest<'a> {
217 HttpsRequest {
218 url_scheme: scheme,
219 client_host,
220 x_forwarded_proto: xfp,
221 route_require_https: route,
222 }
223 }
224
225 #[test]
226 fn global_enforcement_redirects_plain_http() {
227 let config = HttpsEnforcementConfig::new(true, false, Vec::<String>::new()).unwrap();
228 assert_eq!(
229 decide(&request("http", None, None, None), &config),
230 HttpsVerdict::Redirect {
231 route_scoped: false
232 }
233 );
234 assert_eq!(
235 decide(&request("https", None, None, None), &config),
236 HttpsVerdict::Allowed
237 );
238 // Off globally, no route: pass.
239 let off = HttpsEnforcementConfig::new(false, false, Vec::<String>::new()).unwrap();
240 assert_eq!(
241 decide(&request("http", None, None, None), &off),
242 HttpsVerdict::Allowed
243 );
244 }
245
246 #[test]
247 fn route_require_https_wins_both_ways() {
248 let config = HttpsEnforcementConfig::new(true, false, Vec::<String>::new()).unwrap();
249 // A route that opts out beats the global arm.
250 assert_eq!(
251 decide(&request("http", None, None, Some(false)), &config),
252 HttpsVerdict::Allowed
253 );
254 // A route that requires it on a globally-off engine still redirects,
255 // and the verdict knows it was route-scoped.
256 let off = HttpsEnforcementConfig::new(false, false, Vec::<String>::new()).unwrap();
257 assert_eq!(
258 decide(&request("http", None, None, Some(true)), &off),
259 HttpsVerdict::Redirect { route_scoped: true }
260 );
261 }
262
263 #[test]
264 fn forwarded_proto_never_decides_without_trust() {
265 let config = HttpsEnforcementConfig::new(true, false, ["10.0.0.1"]).unwrap();
266 assert_eq!(
267 decide(
268 &request("http", Some("10.0.0.1"), Some("https"), None),
269 &config
270 ),
271 HttpsVerdict::Redirect {
272 route_scoped: false
273 },
274 "trust_x_forwarded_proto is off: the header is ignored"
275 );
276 }
277
278 #[test]
279 fn forwarded_proto_never_decides_from_an_untrusted_client() {
280 let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
281 assert_eq!(
282 decide(
283 &request("http", Some("203.0.113.9"), Some("https"), None),
284 &config
285 ),
286 HttpsVerdict::Redirect {
287 route_scoped: false
288 },
289 "a client outside the trusted proxies cannot upgrade itself"
290 );
291 assert_eq!(
292 decide(&request("http", None, Some("https"), None), &config),
293 HttpsVerdict::Redirect {
294 route_scoped: false
295 },
296 "no connecting address: no upgrade"
297 );
298 }
299
300 #[test]
301 fn forwarded_proto_upgrades_from_a_trusted_proxy() {
302 let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8", "192.0.2.40"]).unwrap();
303 assert_eq!(
304 decide(
305 &request("http", Some("10.1.2.3"), Some("HTTPS"), None),
306 &config
307 ),
308 HttpsVerdict::Allowed,
309 "the value comparison is case-insensitive"
310 );
311 assert_eq!(
312 decide(
313 &request("http", Some("192.0.2.40"), Some("https"), None),
314 &config
315 ),
316 HttpsVerdict::Allowed,
317 "a bare-IP proxy entry matches exactly"
318 );
319 // A non-https forwarded value from a trusted proxy stays a violation.
320 assert_eq!(
321 decide(
322 &request("http", Some("10.1.2.3"), Some("http"), None),
323 &config
324 ),
325 HttpsVerdict::Redirect {
326 route_scoped: false
327 }
328 );
329 }
330
331 #[test]
332 fn config_fails_closed_on_a_bad_proxy_entry() {
333 let error = HttpsEnforcementConfig::new(true, true, ["not-an-ip"]).unwrap_err();
334 assert_eq!(error.list, "trusted_proxies");
335 assert_eq!(error.entry, "not-an-ip");
336 }
337
338 #[test]
339 fn families_never_cross_on_the_proxy_match() {
340 let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
341 assert_eq!(
342 decide(
343 &request("http", Some("::ffff:10.1.2.3"), Some("https"), None),
344 &config
345 ),
346 HttpsVerdict::Allowed,
347 "a v4-mapped connecting address matches its IPv4 proxy entry"
348 );
349 assert_eq!(
350 decide(
351 &request("http", Some("2001:db8::1"), Some("https"), None),
352 &config
353 ),
354 HttpsVerdict::Redirect {
355 route_scoped: false
356 }
357 );
358 }
359
360 #[test]
361 fn a_mismatched_bare_entry_falls_through_and_junk_addresses_never_match() {
362 // A bare entry the connecting address does not equal is skipped in
363 // favor of the next entry.
364 let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.1", "10.0.0.0/8"]).unwrap();
365 assert_eq!(
366 decide(
367 &request("http", Some("10.1.2.3"), Some("https"), None),
368 &config
369 ),
370 HttpsVerdict::Allowed,
371 "the CIDR entry answers after the bare entry missed"
372 );
373 // A CIDR entry with an unparseable connecting address never matches
374 // (the seam stays total where the reference raises).
375 let config = HttpsEnforcementConfig::new(true, true, ["10.0.0.0/8"]).unwrap();
376 assert_eq!(
377 decide(&request("http", Some("junk"), Some("https"), None), &config),
378 HttpsVerdict::Redirect {
379 route_scoped: false
380 },
381 "junk cannot upgrade itself"
382 );
383 }
384}