seer_core/
https_upgrade.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct HttpsUpgradeReport {
25 pub host: String,
27 pub listening: bool,
29 pub chain: Vec<String>,
31 pub upgraded: bool,
33 pub first_hop_https: bool,
35 pub cross_domain: bool,
37}
38
39pub 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
51pub(crate) async fn check_with(fetcher: &GuardedFetcher, host: &str) -> Result<HttpsUpgradeReport> {
53 let traced = fetcher.get_head_traced(&format!("http://{host}/")).await;
54 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 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
86pub(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
106fn 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 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 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 assert!(r.upgraded, "{r:?}");
244 }
245
246 #[tokio::test]
247 async fn later_plain_http_hop_failure_is_unknown() {
248 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 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 let r = check_with(&GuardedFetcher::new().allowing_private_hosts(), "exa mple").await;
284 assert!(r.is_err(), "{r:?}");
285 }
286}