Skip to main content

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}