Skip to main content

guard_core_rs/
https_enforcement.rs

1//! The HTTPS enforcement pipeline stage for tower stacks.
2//!
3//! One `tower::Layer` answering the reference engine's `https_enforcement`
4//! check (`guard_core/core/checks/implementations/https_enforcement.py`)
5//! before a request reaches the inner service.
6//!
7//! The decision core is [`guard_core_engine::https_enforcement`]; this
8//! stage wires it into the family's tower seams:
9//!
10//! ```text
11//! route require_https wins over the global enforce_https arm
12//! plain HTTP under a requirement:  301 redirect to the https URL
13//!                                  (the reference create_https_redirect)
14//! passive mode:                    pass through (log-only in the reference)
15//! ```
16//!
17//! The reference answers the redirect through its response factory and the
18//! response modifier, and never fires the block hook for this check
19//! (`https_enforcement` is in `ON_BLOCK_EXCLUDED_CHECK_NAMES`) - the stage
20//! mirrors the exclusion by construction (it carries no hook) and renders
21//! the plain `301` + `Location` shape.
22//!
23//! # Example
24//!
25//! ```
26//! use guard_core_rs::https_enforcement::{HttpsEnforcementStage, HttpsEnforcementStageConfig};
27//!
28//! let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
29//!     .enforce_https(true)
30//!     .build()
31//!     .expect("valid proxy list");
32//!
33//! // Plain HTTP under a global enforcement: a 301 to the https URL.
34//! let answer = stage
35//!     .decide("/", "http", None, None, "https://host.example/")
36//!     .expect("redirected");
37//! assert_eq!(answer.status, 301);
38//! assert_eq!(answer.location, "https://host.example/");
39//!
40//! // HTTPS passes.
41//! assert!(stage.decide("/", "https", None, None, "").is_none());
42//! ```
43
44use std::fmt;
45use std::future::Future;
46use std::pin::Pin;
47use std::sync::Arc;
48use std::task::{Context, Poll};
49
50use ::tower::Layer;
51use http::{Request, Response, StatusCode};
52
53use crate::event_types::{EVENT_DECORATOR_VIOLATION, EVENT_HTTPS_ENFORCED};
54use crate::events::{MIDDLEWARE_HANDLER_NAME, SecurityEvent, SecurityEventBus};
55use crate::redact::SensitiveNames;
56
57pub use guard_core_engine::https_enforcement::{
58    HTTPS_ENFORCEMENT_CHECK_NAME, HTTPS_REDIRECT_STATUS,
59};
60
61/// The stage's answer: the reference's `create_https_redirect` shape (a
62/// `301` to the scheme-upgraded URL).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct HttpsRedirectAnswer {
65    /// `301` (`HTTPS_REDIRECT_STATUS`).
66    pub status: u16,
67    /// The request URL with the scheme replaced by `https` (the reference's
68    /// `url_replace_scheme("https")`), composed by the caller.
69    pub location: String,
70}
71
72/// How the stage learns a path's `require_https` (the reference reads it
73/// from `request.state.route_config`). `None` means no route config: the
74/// global arm decides. `Some(false)` opts the route out.
75pub type RouteHttpsResolver = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
76
77/// The stage configuration (`SecurityConfig.enforce_https`,
78/// `.trust_x_forwarded_proto`, `.passive_mode`).
79#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub struct HttpsEnforcementStageConfig {
81    /// The global arm, used when the route resolver has no entry.
82    pub enforce_https: bool,
83    /// Whether `X-Forwarded-Proto` is believed (never without a trusted
84    /// connecting IP).
85    pub trust_x_forwarded_proto: bool,
86    /// `passive_mode`: the violation is observed, never redirected.
87    pub passive_mode: bool,
88}
89
90/// The HTTPS enforcement stage.
91#[derive(Clone)]
92pub struct HttpsEnforcementStage {
93    config: HttpsEnforcementStageConfig,
94    trusted_proxies: Vec<String>,
95    route_resolver: Option<RouteHttpsResolver>,
96    events: Option<Arc<SecurityEventBus>>,
97    sensitive: Arc<SensitiveNames>,
98}
99
100impl fmt::Debug for HttpsEnforcementStage {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.debug_struct("HttpsEnforcementStage")
103            .field("config", &self.config)
104            .field("trusted_proxies", &self.trusted_proxies.len())
105            .finish_non_exhaustive()
106    }
107}
108
109/// Builder for [`HttpsEnforcementStage`].
110#[derive(Default)]
111pub struct HttpsEnforcementStageBuilder {
112    config: HttpsEnforcementStageConfig,
113    trusted_proxies: Vec<String>,
114    route_resolver: Option<RouteHttpsResolver>,
115    events: Option<Arc<SecurityEventBus>>,
116    sensitive: SensitiveNames,
117}
118
119impl HttpsEnforcementStage {
120    /// Start a builder over `config`.
121    #[must_use]
122    pub fn builder(config: HttpsEnforcementStageConfig) -> HttpsEnforcementStageBuilder {
123        HttpsEnforcementStageBuilder {
124            config,
125            trusted_proxies: Vec::new(),
126            route_resolver: None,
127            events: None,
128            sensitive: SensitiveNames::default(),
129        }
130    }
131
132    /// One pass of the stage. `path` selects the route's `require_https`,
133    /// `url_scheme` / `client_host` / `x_forwarded_proto` are the request
134    /// facts the reference check reads, and `https_url` is the request URL
135    /// with the scheme already swapped to `https` (the redirect target the
136    /// reference's `url_replace_scheme` produces; the engine cannot see
137    /// the full URL). `None` passes; `Some` is the redirect answer.
138    ///
139    /// # Errors
140    ///
141    /// A [`guard_core_engine::ip_gate::IpGateError`] naming an invalid
142    /// trusted-proxy entry (config errors fail closed at the builder, so
143    /// this only surfaces through [`HttpsEnforcementStageBuilder::build`]).
144    #[must_use]
145    pub fn decide(
146        &self,
147        path: &str,
148        url_scheme: &str,
149        client_host: Option<&str>,
150        x_forwarded_proto: Option<&str>,
151        https_url: &str,
152    ) -> Option<HttpsRedirectAnswer> {
153        let route_require_https = self
154            .route_resolver
155            .as_ref()
156            .and_then(|resolve| resolve(path));
157        self.decide_route(
158            path,
159            url_scheme,
160            client_host,
161            x_forwarded_proto,
162            https_url,
163            route_require_https,
164        )
165    }
166
167    /// One pass of the stage with the route's `require_https` handed in
168    /// directly (the carrier lane: the adapter resolved the route's
169    /// `RouteConfig` itself, so no resolver seam needs to be installed on
170    /// the stage). Everything else - the global arm, the trust knobs, the
171    /// passive handling, the violation emission - matches
172    /// [`HttpsEnforcementStage::decide`]; `None` means the resolver seam
173    /// had no answer for the path.
174    #[must_use]
175    pub fn decide_route(
176        &self,
177        path: &str,
178        url_scheme: &str,
179        client_host: Option<&str>,
180        x_forwarded_proto: Option<&str>,
181        https_url: &str,
182        route_require_https: Option<bool>,
183    ) -> Option<HttpsRedirectAnswer> {
184        let engine_config = guard_core_engine::https_enforcement::HttpsEnforcementConfig::new(
185            self.config.enforce_https,
186            self.config.trust_x_forwarded_proto,
187            self.trusted_proxies.iter().map(String::as_str),
188        )
189        .expect("builder validated the proxy list");
190        let request = guard_core_engine::https_enforcement::HttpsRequest {
191            url_scheme,
192            client_host,
193            x_forwarded_proto,
194            route_require_https,
195        };
196        match guard_core_engine::https_enforcement::decide(&request, &engine_config) {
197            guard_core_engine::https_enforcement::HttpsVerdict::Allowed => None,
198            guard_core_engine::https_enforcement::HttpsVerdict::Redirect { route_scoped } => {
199                // The reference emits the violation event before its
200                // passive-mode branch, so passive mode observes it too.
201                self.observe_https_violation(
202                    route_scoped,
203                    url_scheme,
204                    client_host,
205                    path,
206                    https_url,
207                );
208                if self.config.passive_mode {
209                    None
210                } else {
211                    Some(HttpsRedirectAnswer {
212                        status: HTTPS_REDIRECT_STATUS,
213                        location: https_url.to_owned(),
214                    })
215                }
216            }
217        }
218    }
219
220    /// The violation emission (`send_https_violation_event`): a route's
221    /// `require_https` fires `decorator_violation`
222    /// (`decorator_type` `authentication`, `violation_type`
223    /// `require_https`), the global arm fires `https_enforced`; both
224    /// carry action `https_redirect`, `original_scheme`, and the
225    /// redacted scheme-upgraded URL as `redirect_url`.
226    fn observe_https_violation(
227        &self,
228        route_scoped: bool,
229        original_scheme: &str,
230        client_host: Option<&str>,
231        path: &str,
232        https_url: &str,
233    ) {
234        let Some(bus) = &self.events else {
235            return;
236        };
237        let mut event = if route_scoped {
238            let mut event = SecurityEvent::new(
239                EVENT_DECORATOR_VIOLATION,
240                client_host.unwrap_or_default(),
241                "https_redirect",
242                "Route requires HTTPS but request was HTTP",
243                MIDDLEWARE_HANDLER_NAME,
244            );
245            event.decorator_type = Some(String::from("authentication"));
246            event.metadata.insert(
247                String::from("decorator_type"),
248                serde_json::json!("authentication"),
249            );
250            event.metadata.insert(
251                String::from("violation_type"),
252                serde_json::json!("require_https"),
253            );
254            event
255        } else {
256            SecurityEvent::new(
257                EVENT_HTTPS_ENFORCED,
258                client_host.unwrap_or_default(),
259                "https_redirect",
260                "HTTP request redirected to HTTPS for security",
261                MIDDLEWARE_HANDLER_NAME,
262            )
263        };
264        event.metadata.insert(
265            String::from("original_scheme"),
266            serde_json::json!(original_scheme),
267        );
268        event.metadata.insert(
269            String::from("redirect_url"),
270            serde_json::json!(crate::redact::redact_url_for_display(
271                https_url,
272                &self.sensitive
273            )),
274        );
275        event.endpoint = Some(crate::redact::redact_url_for_display(path, &self.sensitive));
276        bus.send_event(&event);
277    }
278}
279
280impl HttpsEnforcementStageBuilder {
281    /// Set the global arm.
282    #[must_use]
283    pub const fn enforce_https(mut self, enforce_https: bool) -> Self {
284        self.config.enforce_https = enforce_https;
285        self
286    }
287
288    /// Add trusted-proxy entries (bare IPs or CIDR ranges); the build
289    /// fails closed on an invalid one.
290    #[must_use]
291    pub fn trusted_proxies<I>(mut self, entries: I) -> Self
292    where
293        I: IntoIterator,
294        I::Item: AsRef<str>,
295    {
296        self.trusted_proxies
297            .extend(entries.into_iter().map(|entry| entry.as_ref().to_owned()));
298        self
299    }
300
301    /// Install the route `require_https` resolver.
302    #[must_use]
303    pub fn route_resolver(mut self, resolver: RouteHttpsResolver) -> Self {
304        self.route_resolver = Some(resolver);
305        self
306    }
307
308    /// Install the middleware-event bus (`send_https_violation_event`).
309    #[must_use]
310    pub fn events(mut self, bus: Arc<SecurityEventBus>) -> Self {
311        self.events = Some(bus);
312        self
313    }
314
315    /// Set the merged sensitive-name sets for the `redirect_url` /
316    /// `endpoint` redaction.
317    #[must_use]
318    pub fn sensitive(mut self, sensitive: SensitiveNames) -> Self {
319        self.sensitive = sensitive;
320        self
321    }
322
323    /// Validate the trusted-proxy list and build the stage, failing closed.
324    ///
325    /// # Errors
326    ///
327    /// [`guard_core_engine::ip_gate::IpGateError`] naming the first
328    /// invalid trusted-proxy entry.
329    pub fn build(self) -> Result<HttpsEnforcementStage, guard_core_engine::ip_gate::IpGateError> {
330        guard_core_engine::https_enforcement::HttpsEnforcementConfig::new(
331            self.config.enforce_https,
332            self.config.trust_x_forwarded_proto,
333            self.trusted_proxies.iter().map(String::as_str),
334        )?;
335        Ok(HttpsEnforcementStage {
336            config: self.config,
337            trusted_proxies: self.trusted_proxies,
338            route_resolver: self.route_resolver,
339            events: self.events,
340            sensitive: Arc::new(self.sensitive),
341        })
342    }
343}
344
345/// The `tower::Layer` carrying [`HttpsEnforcementStage`].
346#[derive(Clone)]
347pub struct HttpsEnforcementStageLayer {
348    stage: HttpsEnforcementStage,
349}
350
351impl HttpsEnforcementStageLayer {
352    /// Carry `stage` into every service this layer wraps.
353    #[must_use]
354    pub const fn new(stage: HttpsEnforcementStage) -> Self {
355        Self { stage }
356    }
357}
358
359impl fmt::Debug for HttpsEnforcementStageLayer {
360    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361        f.debug_struct("HttpsEnforcementStageLayer")
362            .field("stage", &self.stage)
363            .finish()
364    }
365}
366
367impl<S> Layer<S> for HttpsEnforcementStageLayer {
368    type Service = HttpsEnforcementStageService<S>;
369
370    fn layer(&self, inner: S) -> Self::Service {
371        HttpsEnforcementStageService {
372            inner,
373            stage: self.stage.clone(),
374        }
375    }
376}
377
378/// The stage as a `tower::Service` around the inner service it wrapped.
379#[derive(Clone)]
380pub struct HttpsEnforcementStageService<S> {
381    inner: S,
382    stage: HttpsEnforcementStage,
383}
384
385/// The host half of an authority string (`host` or `host:port` or a
386/// bracketed IPv6 literal, optionally `user:pass@`-prefixed in a URI
387/// authority): the port is stripped and IPv6 brackets are removed, the
388/// bare-address shape the reference compares trusted-proxy entries
389/// against. A colon run that is not an all-digits port never splits, so
390/// a bracket-less IPv6 text survives whole.
391fn host_of_authority(value: &str) -> &str {
392    let host_port = value.rsplit('@').next().unwrap_or_default();
393    if let Some(rest) = host_port.strip_prefix('[') {
394        return rest.split(']').next().unwrap_or(rest);
395    }
396    match host_port.split_once(':') {
397        Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
398        _ => host_port,
399    }
400}
401
402impl<S, B, ResBody> ::tower::Service<Request<B>> for HttpsEnforcementStageService<S>
403where
404    S: ::tower::Service<Request<B>, Response = Response<ResBody>>,
405    S::Future: Send + 'static,
406    ResBody: From<&'static str> + Send + 'static,
407{
408    type Response = S::Response;
409    type Error = S::Error;
410    type Future = Pin<Box<dyn Future<Output = Result<S::Response, S::Error>> + Send>>;
411
412    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
413        self.inner.poll_ready(cx)
414    }
415
416    fn call(&mut self, request: Request<B>) -> Self::Future {
417        let scheme = request.uri().scheme_str().unwrap_or("http").to_owned();
418        let path = request.uri().path().to_owned();
419        let host_and_port = request
420            .uri()
421            .authority()
422            .map(|authority| authority.as_str().to_owned())
423            .or_else(|| {
424                request
425                    .headers()
426                    .get(http::header::HOST)
427                    .and_then(|value| value.to_str().ok())
428                    .map(ToOwned::to_owned)
429            });
430        // The connecting identity the trusted-proxy arm reads: the host
431        // half of the authority (or Host header), port stripped, IPv6
432        // brackets removed - the socket-host shape the reference's
433        // `request.client_host` carries.
434        let client_host = host_and_port
435            .as_deref()
436            .map(|value| host_of_authority(value).to_owned());
437        let forwarded = request
438            .headers()
439            .get("x-forwarded-proto")
440            .and_then(|value| value.to_str().ok())
441            .map(str::to_owned);
442        let authority = host_and_port.unwrap_or_default();
443        let query = request
444            .uri()
445            .query()
446            .map(|q| format!("?{q}"))
447            .unwrap_or_default();
448        let https_url = format!("https://{authority}{path}{query}");
449
450        let answer = self.stage.decide(
451            &path,
452            &scheme,
453            client_host.as_deref(),
454            forwarded.as_deref(),
455            &https_url,
456        );
457        let Some(redirect) = answer else {
458            return Box::pin(self.inner.call(request));
459        };
460        drop(request);
461        let status = StatusCode::from_u16(redirect.status).expect("reference status");
462        let mut response = Response::new(ResBody::from(""));
463        *response.status_mut() = status;
464        #[cfg(not(coverage))] // unreachable: the location composes only
465        // URI-validated bytes (the http crate rejects controls and DEL in
466        // authorities, paths, and queries, and the Host fallback passes
467        // through a header-value str check), all of which are valid
468        // header-value bytes, so from_str cannot reject it
469        if let Ok(value) = http::HeaderValue::from_str(&redirect.location) {
470            response.headers_mut().insert(http::header::LOCATION, value);
471        }
472        #[cfg(coverage)]
473        response.headers_mut().insert(
474            http::header::LOCATION,
475            http::HeaderValue::from_str(&redirect.location)
476                .expect("the composed location is a valid header value"),
477        );
478        Box::pin(async move { Ok(response) })
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use std::convert::Infallible;
486
487    use ::tower::Service;
488
489    fn stage() -> HttpsEnforcementStage {
490        HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
491            .enforce_https(true)
492            .build()
493            .expect("valid")
494    }
495
496    fn route_stage() -> HttpsEnforcementStage {
497        HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
498            .enforce_https(false)
499            .route_resolver(Arc::new(|path: &str| match path {
500                "/secure" => Some(true),
501                "/open" => Some(false),
502                _ => None,
503            }))
504            .build()
505            .expect("valid")
506    }
507
508    #[test]
509    fn decide_route_carries_the_direct_require_https_override() {
510        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
511            .build()
512            .expect("default config");
513        // The carrier lane: require_https = true on a plain-http request
514        // redirects exactly like the resolver-seam lane.
515        let answer = stage
516            .decide_route(
517                "/private",
518                "http",
519                None,
520                None,
521                "https://host/private",
522                Some(true),
523            )
524            .expect("route require_https redirects");
525        assert_eq!(answer.status, HTTPS_REDIRECT_STATUS);
526        assert_eq!(answer.location, "https://host/private");
527        // require_https = false (the RouteConfig default, the unset shape)
528        // leaves the global arm alone (default config: not enforced).
529        assert!(
530            stage
531                .decide_route(
532                    "/private",
533                    "http",
534                    None,
535                    None,
536                    "https://host/private",
537                    Some(false)
538                )
539                .is_none()
540        );
541        // An already-https request passes.
542        assert!(
543            stage
544                .decide_route(
545                    "/private",
546                    "https",
547                    None,
548                    None,
549                    "https://host/private",
550                    Some(true)
551                )
552                .is_none()
553        );
554    }
555
556    #[test]
557    fn global_enforcement_redirects_plain_http_and_passes_https() {
558        let stage = stage();
559        let answer = stage
560            .decide("/x", "http", None, None, "https://host/x")
561            .expect("redirected");
562        assert_eq!(answer.status, 301);
563        assert_eq!(answer.location, "https://host/x");
564        assert!(stage.decide("/x", "https", None, None, "").is_none());
565    }
566
567    #[test]
568    fn the_route_verdict_wins_both_ways() {
569        let stage = route_stage();
570        assert!(
571            stage.decide("/secure", "http", None, None, "").is_some(),
572            "a route requiring https redirects even with the global arm off"
573        );
574        assert!(
575            stage.decide("/open", "http", None, None, "").is_none(),
576            "a route opting out passes even under a global arm"
577        );
578        assert!(
579            stage.decide("/other", "http", None, None, "").is_none(),
580            "no route entry and a globally-off engine passes"
581        );
582    }
583
584    #[test]
585    fn passive_mode_observes_without_redirecting() {
586        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
587            enforce_https: true,
588            passive_mode: true,
589            ..HttpsEnforcementStageConfig::default()
590        })
591        .build()
592        .expect("valid");
593        assert!(
594            stage
595                .decide("/x", "http", None, None, "https://host/x")
596                .is_none(),
597            "passive mode never answers the redirect"
598        );
599    }
600
601    #[test]
602    fn a_forged_forwarded_proto_never_upgrades() {
603        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
604            enforce_https: true,
605            trust_x_forwarded_proto: true,
606            ..HttpsEnforcementStageConfig::default()
607        })
608        .trusted_proxies(["10.0.0.0/8"])
609        .build()
610        .expect("valid");
611        assert!(
612            stage
613                .decide("/x", "http", Some("203.0.113.9"), Some("https"), "")
614                .is_some(),
615            "an untrusted client's header is ignored"
616        );
617        assert!(
618            stage
619                .decide("/x", "http", Some("10.1.2.3"), Some("https"), "")
620                .is_none(),
621            "a trusted proxy's header upgrades the request"
622        );
623    }
624
625    #[test]
626    fn the_builder_fails_closed_on_a_bad_proxy_entry() {
627        let error = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
628            .trusted_proxies(["not-an-ip"])
629            .build()
630            .unwrap_err();
631        assert_eq!(error.list, "trusted_proxies");
632    }
633
634    #[test]
635    fn the_connecting_host_strips_the_port_and_the_v6_brackets() {
636        // The `client_host` the trusted-proxy arm compares must be the
637        // bare host: a port-bearing authority reads as the port string
638        // otherwise, and no proxy entry can ever match it.
639        assert_eq!(host_of_authority("10.1.2.3:8080"), "10.1.2.3");
640        assert_eq!(host_of_authority("host.example"), "host.example");
641        assert_eq!(host_of_authority("host.example:443"), "host.example");
642        assert_eq!(host_of_authority("[2001:db8::1]:8080"), "2001:db8::1");
643        assert_eq!(host_of_authority("[2001:db8::1]"), "2001:db8::1");
644        assert_eq!(host_of_authority("2001:db8::1"), "2001:db8::1");
645    }
646
647    fn recording_bus() -> (
648        Arc<std::sync::Mutex<Vec<SecurityEvent>>>,
649        Arc<SecurityEventBus>,
650    ) {
651        let log = Arc::new(std::sync::Mutex::new(Vec::new()));
652        let sink = Arc::clone(&log);
653        let bus = Arc::new(SecurityEventBus::new(true).on_event(Arc::new(
654            move |event: &SecurityEvent| {
655                sink.lock().expect("sink").push(event.clone());
656            },
657        )));
658        (log, bus)
659    }
660
661    #[test]
662    fn the_violation_fires_the_route_and_global_event_split() {
663        let (log, bus) = recording_bus();
664        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
665            .enforce_https(true)
666            .events(Arc::clone(&bus))
667            .build()
668            .expect("valid");
669        // The global arm fires `https_enforced`.
670        stage
671            .decide("/x", "http", None, None, "https://host.example/x")
672            .expect("redirected");
673        // The route arm fires `decorator_violation`.
674        let route_stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
675            .route_resolver(Arc::new(|_: &str| Some(true)))
676            .events(bus)
677            .build()
678            .expect("valid");
679        route_stage
680            .decide("/secure", "http", None, None, "https://host.example/secure")
681            .expect("redirected");
682
683        let events = log.lock().expect("sink").clone();
684        assert_eq!(events.len(), 2);
685        assert_eq!(events[0].event_type, EVENT_HTTPS_ENFORCED);
686        assert_eq!(events[0].action_taken, "https_redirect");
687        assert_eq!(
688            events[0].reason,
689            "HTTP request redirected to HTTPS for security"
690        );
691        assert_eq!(
692            events[0].metadata["original_scheme"], "http",
693            "the reference metadata rides along"
694        );
695        assert_eq!(
696            events[0].metadata["redirect_url"],
697            serde_json::json!("https://host.example/x")
698        );
699        assert_eq!(events[1].event_type, EVENT_DECORATOR_VIOLATION);
700        assert_eq!(events[1].decorator_type.as_deref(), Some("authentication"));
701        assert_eq!(events[1].metadata["violation_type"], "require_https");
702        assert_eq!(
703            events[1].reason,
704            "Route requires HTTPS but request was HTTP"
705        );
706    }
707
708    #[test]
709    fn passive_mode_still_observes_the_https_violation() {
710        // The reference emits the event before its passive-mode branch.
711        let (log, bus) = recording_bus();
712        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig {
713            enforce_https: true,
714            passive_mode: true,
715            ..HttpsEnforcementStageConfig::default()
716        })
717        .events(bus)
718        .build()
719        .expect("valid");
720        assert!(
721            stage
722                .decide("/x", "http", None, None, "https://host/x")
723                .is_none()
724        );
725        let events = log.lock().expect("sink").clone();
726        assert_eq!(events.len(), 1);
727        assert_eq!(events[0].event_type, EVENT_HTTPS_ENFORCED);
728    }
729
730    #[test]
731    fn the_sensitive_sets_redact_the_emitted_redirect_target() {
732        // The `sensitive` builder seam feeds the `redirect_url` and
733        // `endpoint` redaction the reference event carries: an extra
734        // param name nobody defaults to still redacts its value.
735        let (log, bus) = recording_bus();
736        let stage = HttpsEnforcementStage::builder(HttpsEnforcementStageConfig::default())
737            .enforce_https(true)
738            .sensitive(SensitiveNames::new(
739                None,
740                Some(&std::collections::HashSet::from([String::from(
741                    "guard_test_param",
742                )])),
743                None,
744            ))
745            .events(bus)
746            .build()
747            .expect("valid");
748        stage
749            .decide(
750                "/x?guard_test_param=leaked",
751                "http",
752                None,
753                None,
754                "https://host.example/x?guard_test_param=leaked",
755            )
756            .expect("redirected");
757        let events = log.lock().expect("sink").clone();
758        assert_eq!(events.len(), 1);
759        assert_eq!(
760            events[0].metadata["redirect_url"],
761            serde_json::json!("https://host.example/x?guard_test_param=[REDACTED]")
762        );
763        assert_eq!(
764            events[0].endpoint.as_deref(),
765            Some("/x?guard_test_param=[REDACTED]")
766        );
767    }
768
769    /// The future the plumbing tests drive.
770    fn block_on<F: Future>(future: F) -> F::Output {
771        let mut future = std::pin::pin!(future);
772        let waker = std::task::Waker::noop();
773        let mut cx = Context::from_waker(waker);
774        loop {
775            match future.as_mut().poll(&mut cx) {
776                Poll::Ready(output) => return output,
777                Poll::Pending => std::hint::spin_loop(),
778            }
779        }
780    }
781
782    #[derive(Clone)]
783    struct Inner {
784        calls: Arc<std::sync::atomic::AtomicUsize>,
785    }
786
787    impl Inner {
788        fn new() -> Self {
789            Self {
790                calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
791            }
792        }
793    }
794
795    impl ::tower::Service<Request<&'static str>> for Inner {
796        type Response = Response<&'static str>;
797        type Error = Infallible;
798        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
799
800        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
801            Poll::Ready(Ok(()))
802        }
803
804        fn call(&mut self, _request: Request<&'static str>) -> Self::Future {
805            self.calls
806                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
807            Box::pin(async move { Ok(Response::new("inner")) })
808        }
809    }
810
811    fn request_to(uri: &str) -> Request<&'static str> {
812        Request::builder().uri(uri).body("body").expect("request")
813    }
814
815    #[test]
816    fn the_layer_redirects_http_and_forwards_https() {
817        let layer = HttpsEnforcementStageLayer::new(stage());
818        let inner = Inner::new();
819        let mut service = ::tower::ServiceBuilder::new()
820            .layer(layer)
821            .service(inner.clone());
822
823        let response =
824            block_on(service.call(request_to("http://host/private?q=1"))).expect("ready");
825        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
826        assert_eq!(
827            response
828                .headers()
829                .get(http::header::LOCATION)
830                .expect("location"),
831            "https://host/private?q=1"
832        );
833        assert_eq!(
834            inner.calls.load(std::sync::atomic::Ordering::Relaxed),
835            0,
836            "the redirected request never hits the inner service"
837        );
838
839        let response = block_on(service.call(request_to("https://host/private"))).expect("ready");
840        assert_eq!(response.status(), StatusCode::OK);
841        assert_eq!(response.body(), &"inner");
842    }
843
844    #[test]
845    fn the_stage_and_layer_render_debug_and_the_service_readies() {
846        let built = stage();
847        assert!(format!("{built:?}").starts_with("HttpsEnforcementStage"));
848
849        let layer = HttpsEnforcementStageLayer::new(stage());
850        assert!(format!("{layer:?}").starts_with("HttpsEnforcementStageLayer"));
851
852        let mut service = ::tower::ServiceBuilder::new()
853            .layer(layer)
854            .service(Inner::new());
855        let waker = std::task::Waker::noop();
856        let mut cx = Context::from_waker(waker);
857        assert_eq!(
858            ::tower::Service::<Request<&'static str>>::poll_ready(&mut service, &mut cx),
859            Poll::Ready(Ok(()))
860        );
861    }
862
863    #[test]
864    fn a_path_only_request_takes_the_host_header() {
865        let layer = HttpsEnforcementStageLayer::new(stage());
866        let mut service = ::tower::ServiceBuilder::new()
867            .layer(layer)
868            .service(Inner::new());
869
870        // No authority in the URI: the Host header (port included) composes
871        // the redirect target, and the client host feeds the proxy arm. The
872        // forwarded header rides along (ignored: the stage trusts no proxy).
873        let request = Request::builder()
874            .uri("/private")
875            .header(http::header::HOST, "host.example:8443")
876            .header("x-forwarded-proto", "https")
877            .body("body")
878            .expect("request");
879        let response = block_on(service.call(request)).expect("ready");
880        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
881        assert_eq!(
882            response
883                .headers()
884                .get(http::header::LOCATION)
885                .expect("location"),
886            "https://host.example:8443/private"
887        );
888    }
889
890    /// An inner service whose first answer waits one poll before replying.
891    #[derive(Clone)]
892    struct InnerYieldsOnce {
893        yielded: Arc<std::sync::atomic::AtomicBool>,
894    }
895
896    struct YieldOnce {
897        yielded: bool,
898    }
899
900    impl Future for YieldOnce {
901        type Output = ();
902
903        fn poll(mut self: std::pin::Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
904            if self.yielded {
905                Poll::Ready(())
906            } else {
907                self.yielded = true;
908                cx.waker().wake_by_ref();
909                Poll::Pending
910            }
911        }
912    }
913
914    impl ::tower::Service<Request<&'static str>> for InnerYieldsOnce {
915        type Response = Response<&'static str>;
916        type Error = Infallible;
917        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Infallible>> + Send>>;
918
919        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
920            Poll::Ready(Ok(()))
921        }
922
923        fn call(&mut self, _request: Request<&'static str>) -> Self::Future {
924            let yielded = Arc::clone(&self.yielded);
925            Box::pin(async move {
926                YieldOnce {
927                    yielded: yielded.load(std::sync::atomic::Ordering::Relaxed),
928                }
929                .await;
930                // record that the second poll answered
931                yielded.store(true, std::sync::atomic::Ordering::Relaxed);
932                Ok(Response::new("inner"))
933            })
934        }
935    }
936
937    #[test]
938    fn the_helper_block_on_spins_a_pending_future_once() {
939        struct PendingOnce {
940            polled: std::cell::Cell<bool>,
941        }
942        impl Future for PendingOnce {
943            type Output = u8;
944
945            fn poll(self: std::pin::Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<u8> {
946                if self.polled.get() {
947                    Poll::Ready(7)
948                } else {
949                    self.polled.set(true);
950                    Poll::Pending
951                }
952            }
953        }
954        let future = PendingOnce {
955            polled: std::cell::Cell::new(false),
956        };
957        assert_eq!(block_on(future), 7, "the second poll answers");
958
959        // The plumbing future yields once too: the block_on loop spins a
960        // Pending service answer before it resolves. The yielding inner
961        // then sees the whole stage surface: pass-through, a redirect, and
962        // a Host fallback with a query and a forwarded header.
963        let layer = HttpsEnforcementStageLayer::new(stage());
964        let mut service = ::tower::ServiceBuilder::new()
965            .layer(layer)
966            .service(InnerYieldsOnce {
967                yielded: Arc::new(std::sync::atomic::AtomicBool::new(false)),
968            });
969        let waker = std::task::Waker::noop();
970        let mut cx = Context::from_waker(waker);
971        assert_eq!(
972            ::tower::Service::<Request<&'static str>>::poll_ready(&mut service, &mut cx),
973            Poll::Ready(Ok(()))
974        );
975
976        let response = block_on(service.call(request_to("https://host/private"))).expect("ready");
977        assert_eq!(response.status(), StatusCode::OK);
978        assert_eq!(response.body(), &"inner");
979
980        let response =
981            block_on(service.call(request_to("http://host/private?q=1"))).expect("ready");
982        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
983
984        let request = Request::builder()
985            .uri("/private?q=1")
986            .header(http::header::HOST, "host.example:8443")
987            .header("x-forwarded-proto", "https")
988            .body("body")
989            .expect("request");
990        let response = block_on(service.call(request)).expect("ready");
991        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
992    }
993}