Skip to main content

seer_core/
https_upgrade.rs

1//! Does plain HTTP upgrade to HTTPS?
2//!
3//! One SSRF-guarded GET to `http://<host>/`, following redirects manually
4//! (every hop re-validated, at most 5) without reading a body, then a pure
5//! assessment of the redirect chain: an upgrade is a chain that ends on
6//! `https://` at the same registrable domain. A connection failure on the
7//! first hop means nothing listens on port 80 (`listening: false`) — which is
8//! fine, not a finding. A failure on a later hop does not erase what the
9//! chain already shows once it has reached `https://` or left the site; a
10//! failed plain-http hop on the same site leaves the answer unknown (an
11//! error). IP literals compare exactly, never through the PSL.
12//! Single-attempt, like every probe.
13
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::Result;
19use crate::http::{GuardedFetcher, SendError};
20use crate::validation::normalize_host;
21
22/// The outcome of an HTTP→HTTPS upgrade check.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct HttpsUpgradeReport {
25    /// The host that was probed.
26    pub host: String,
27    /// Whether anything answered on port 80.
28    pub listening: bool,
29    /// URLs attempted, in order (remote-influenced — sanitize before display).
30    pub chain: Vec<String>,
31    /// The chain ends on HTTPS at the same registrable domain.
32    pub upgraded: bool,
33    /// The first redirect points at an `https://` URL.
34    pub first_hop_https: bool,
35    /// The chain ends at a different registrable domain.
36    pub cross_domain: bool,
37}
38
39/// Probes `http://<host>/` and reports whether it upgrades to HTTPS.
40///
41/// # Errors
42/// `InvalidDomain` for bad input; an HTTP error when the first hop failed for
43/// a reason other than a refused/failed connection, or a later hop failed
44/// before the chain reached `https://` or another site (the result is
45/// unknown).
46pub async fn check_https_upgrade(host: &str, timeout: Duration) -> Result<HttpsUpgradeReport> {
47    let host = normalize_host(host)?;
48    check_with(&GuardedFetcher::new().with_timeout(timeout), &host).await
49}
50
51/// [`check_https_upgrade`] for a normalized host and a caller-built fetcher.
52pub(crate) async fn check_with(fetcher: &GuardedFetcher, host: &str) -> Result<HttpsUpgradeReport> {
53    let traced = fetcher.get_head_traced(&format!("http://{host}/")).await;
54    // An empty chain (the URL never parsed) is a first-hop failure too.
55    let first_hop = traced.chain.len() <= 1;
56    match traced.result {
57        Ok(_) => Ok(assess(host, &traced.chain)),
58        Err(SendError::Connect(_)) if first_hop => Ok(HttpsUpgradeReport {
59            host: host.to_string(),
60            listening: false,
61            chain: traced.chain,
62            upgraded: false,
63            first_hop_https: false,
64            cross_domain: false,
65        }),
66        Err(e) if first_hop => Err(e.into()),
67        // A later hop failed. The chain so far is evidence only once it has
68        // reached `https://` or left the site; a failed plain-http hop on the
69        // same site leaves the answer unknown.
70        Err(e) => {
71            let report = assess(host, &traced.chain);
72            let reached_https = traced
73                .chain
74                .last()
75                .and_then(|u| url::Url::parse(u).ok())
76                .is_some_and(|u| u.scheme() == "https");
77            if reached_https || report.cross_domain {
78                Ok(report)
79            } else {
80                Err(e.into())
81            }
82        }
83    }
84}
85
86/// Assesses a redirect chain that started at `http://<host>/`. Pure.
87pub(crate) fn assess(host: &str, chain: &[String]) -> HttpsUpgradeReport {
88    let parsed: Vec<Option<url::Url>> = chain.iter().map(|u| url::Url::parse(u).ok()).collect();
89    let is_https = |u: &Option<url::Url>| u.as_ref().is_some_and(|u| u.scheme() == "https");
90    let last = parsed.last().cloned().flatten();
91    let same_site = last
92        .as_ref()
93        .and_then(|u| u.host_str())
94        .is_some_and(|final_host| same_registrable(host, final_host));
95    let final_https = parsed.last().is_some_and(is_https);
96    HttpsUpgradeReport {
97        host: host.to_string(),
98        listening: true,
99        chain: chain.to_vec(),
100        upgraded: final_https && same_site,
101        first_hop_https: parsed.get(1).is_some_and(is_https),
102        cross_domain: last.is_some() && !same_site,
103    }
104}
105
106/// Whether two hosts share a registrable domain; IP literals and hosts with
107/// none (bare labels) must match exactly — an IP never goes through the PSL,
108/// which would read `10.1.2.3` as the registrable `2.3`. A `:port` on `a` is
109/// ignored.
110fn same_registrable(a: &str, b: &str) -> bool {
111    let a = a.rsplit_once(':').map_or(a, |(h, port)| {
112        if port.chars().all(|c| c.is_ascii_digit()) {
113            h
114        } else {
115            a
116        }
117    });
118    let is_ip = |h: &str| h.parse::<std::net::IpAddr>().is_ok();
119    if is_ip(a) || is_ip(b) {
120        return a.eq_ignore_ascii_case(b);
121    }
122    match (
123        crate::psl::registrable_domain(a),
124        crate::psl::registrable_domain(b),
125    ) {
126        (Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
127        _ => a.eq_ignore_ascii_case(b),
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use wiremock::matchers::{method, path};
135    use wiremock::{Mock, MockServer, ResponseTemplate};
136
137    fn chain(urls: &[&str]) -> Vec<String> {
138        urls.iter().map(|u| u.to_string()).collect()
139    }
140
141    #[test]
142    fn same_domain_https_redirect_is_an_upgrade() {
143        let r = assess(
144            "example.com",
145            &chain(&["http://example.com/", "https://www.example.com/"]),
146        );
147        assert!(r.listening && r.upgraded && r.first_hop_https && !r.cross_domain);
148    }
149
150    #[test]
151    fn plain_http_answer_is_not_an_upgrade() {
152        let r = assess("example.com", &chain(&["http://example.com/"]));
153        assert!(r.listening && !r.upgraded && !r.first_hop_https);
154    }
155
156    #[test]
157    fn redirect_to_another_domain_is_not_an_upgrade() {
158        let r = assess(
159            "example.com",
160            &chain(&["http://example.com/", "https://parking.test/"]),
161        );
162        assert!(!r.upgraded && r.cross_domain);
163    }
164
165    #[test]
166    fn http_hop_after_https_ends_on_http() {
167        let r = assess(
168            "example.com",
169            &chain(&[
170                "http://example.com/",
171                "https://example.com/",
172                "http://example.com/login",
173            ]),
174        );
175        assert!(r.first_hop_https && !r.upgraded);
176    }
177
178    #[test]
179    fn ip_literals_compare_exactly() {
180        // Never through the PSL, which would read `2.3` as a registrable domain.
181        assert!(!same_registrable("10.1.2.3", "10.9.2.3"));
182        assert!(!same_registrable("10.1.2.3:8080", "10.9.2.3"));
183        assert!(same_registrable("10.1.2.3:8080", "10.1.2.3"));
184        assert!(!same_registrable("example.com", "10.1.2.3"));
185        assert!(same_registrable("example.com", "www.example.com"));
186    }
187
188    #[tokio::test]
189    async fn closed_port_80_is_not_listening() {
190        let port = {
191            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
192            l.local_addr().unwrap().port()
193        };
194        let fetcher = GuardedFetcher::new()
195            .with_timeout(crate::http::CLOSED_PORT_TEST_TIMEOUT)
196            .allowing_private_hosts();
197        let r = check_with(&fetcher, &format!("127.0.0.1:{port}"))
198            .await
199            .unwrap();
200        assert!(!r.listening && !r.upgraded);
201    }
202
203    #[tokio::test]
204    async fn plain_http_site_is_listening_without_upgrade() {
205        let server = MockServer::start().await;
206        Mock::given(method("GET"))
207            .and(path("/"))
208            .respond_with(ResponseTemplate::new(200))
209            .mount(&server)
210            .await;
211        let host = server.uri().trim_start_matches("http://").to_string();
212        let fetcher = GuardedFetcher::new().allowing_private_hosts();
213        let r = check_with(&fetcher, &host).await.unwrap();
214        assert!(r.listening && !r.upgraded);
215        assert_eq!(r.chain.len(), 1);
216    }
217
218    #[tokio::test]
219    async fn later_hop_failure_still_reports_the_upgrade() {
220        // Redirects to an https URL on a closed port: the hop fails, but the
221        // first hop already proved the upgrade.
222        let closed = {
223            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
224            l.local_addr().unwrap().port()
225        };
226        let server = MockServer::start().await;
227        Mock::given(method("GET"))
228            .and(path("/"))
229            .respond_with(
230                ResponseTemplate::new(301)
231                    .insert_header("location", format!("https://127.0.0.1:{closed}/").as_str()),
232            )
233            .mount(&server)
234            .await;
235        let host = server.uri().trim_start_matches("http://").to_string();
236        let fetcher = GuardedFetcher::new()
237            .with_timeout(crate::http::CLOSED_PORT_TEST_TIMEOUT)
238            .allowing_private_hosts();
239        let r = check_with(&fetcher, &host).await.unwrap();
240        assert!(r.listening && r.first_hop_https, "{r:?}");
241        // IP literals compare exactly, ignoring the probed host's port: the
242        // ports differ, the IP is equal.
243        assert!(r.upgraded, "{r:?}");
244    }
245
246    #[tokio::test]
247    async fn later_plain_http_hop_failure_is_unknown() {
248        // Redirects to another plain-http URL that fails: nothing shows the
249        // site upgrades or not, so the result is unknown.
250        let closed = {
251            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
252            l.local_addr().unwrap().port()
253        };
254        let server = MockServer::start().await;
255        Mock::given(method("GET"))
256            .and(path("/"))
257            .respond_with(
258                ResponseTemplate::new(301)
259                    .insert_header("location", format!("http://127.0.0.1:{closed}/").as_str()),
260            )
261            .mount(&server)
262            .await;
263        let host = server.uri().trim_start_matches("http://").to_string();
264        let fetcher = GuardedFetcher::new()
265            .with_timeout(crate::http::CLOSED_PORT_TEST_TIMEOUT)
266            .allowing_private_hosts();
267        let r = check_with(&fetcher, &host).await;
268        assert!(r.is_err(), "{r:?}");
269    }
270
271    #[tokio::test]
272    async fn first_hop_non_connect_failure_is_unknown() {
273        // A production fetcher refuses loopback before connecting (SSRF): an
274        // unknown, not "nothing listens".
275        let r = check_with(&GuardedFetcher::new(), "127.0.0.1").await;
276        assert!(r.is_err(), "{r:?}");
277    }
278
279    #[tokio::test]
280    async fn a_failure_before_any_hop_is_unknown() {
281        // The URL never parses, so the chain is empty: unknown, never an
282        // assessment of an empty chain.
283        let r = check_with(&GuardedFetcher::new().allowing_private_hosts(), "exa mple").await;
284        assert!(r.is_err(), "{r:?}");
285    }
286}