1use std::time::Duration;
53
54use futures::stream::{self, StreamExt};
55use serde::{Deserialize, Serialize};
56use tracing::debug;
57
58use crate::dns::{DnsResolver, RecordData, RecordType};
59use crate::error::Result;
60use crate::http::GuardedFetcher;
61use crate::validation::{normalize_domain, normalize_host};
62
63const MAX_TAKEOVER_HOSTS: usize = 2000;
70
71pub const DEFAULT_TAKEOVER_TIMEOUT: Duration = Duration::from_secs(10);
75
76pub(crate) const MAX_CNAME_HOPS: usize = 8;
79
80const PROBE_BODY_LIMIT: usize = 32 * 1024;
85
86pub(crate) struct ProviderFingerprint {
89 pub(crate) provider: &'static str,
90 pub(crate) cname_suffixes: &'static [&'static str],
96 pub(crate) cname_infixes: &'static [&'static str],
103 body_markers: &'static [&'static str],
107 claim_statuses: &'static [u16],
111}
112
113pub(crate) const PROVIDERS: &[ProviderFingerprint] = &[
124 ProviderFingerprint {
125 provider: "GitHub Pages",
126 cname_suffixes: &[".github.io"],
127 cname_infixes: &[],
128 body_markers: &[
129 "There isn't a GitHub Pages site here.",
130 "For root URLs (like http://example.com/) you must provide an index.html file",
131 ],
132 claim_statuses: &[404],
133 },
134 ProviderFingerprint {
135 provider: "Heroku",
136 cname_suffixes: &[".herokuapp.com", ".herokudns.com", ".herokussl.com"],
137 cname_infixes: &[],
138 body_markers: &["No such app", "herokucdn.com/error-pages/no-such-app.html"],
139 claim_statuses: &[404],
140 },
141 ProviderFingerprint {
142 provider: "AWS S3",
143 cname_suffixes: &[".s3.amazonaws.com"],
144 cname_infixes: &[".s3-website"],
145 body_markers: &["NoSuchBucket", "The specified bucket does not exist"],
146 claim_statuses: &[404],
147 },
148 ProviderFingerprint {
149 provider: "AWS CloudFront",
150 cname_suffixes: &[".cloudfront.net"],
151 cname_infixes: &[],
153 body_markers: &[],
154 claim_statuses: &[],
155 },
156 ProviderFingerprint {
157 provider: "Azure App Service",
158 cname_suffixes: &[
159 ".azurewebsites.net",
160 ".cloudapp.net",
161 ".cloudapp.azure.com",
162 ".azureedge.net",
163 ],
164 cname_infixes: &[],
165 body_markers: &["404 Web Site not found", "Error 404 - Web app not found"],
166 claim_statuses: &[404],
167 },
168 ProviderFingerprint {
169 provider: "Azure Traffic Manager",
170 cname_suffixes: &[".trafficmanager.net"],
171 cname_infixes: &[],
172 body_markers: &[],
173 claim_statuses: &[],
174 },
175 ProviderFingerprint {
176 provider: "Azure Blob Storage",
177 cname_suffixes: &[".blob.core.windows.net"],
178 cname_infixes: &[],
179 body_markers: &["The specified container does not exist"],
180 claim_statuses: &[404],
181 },
182 ProviderFingerprint {
183 provider: "Shopify",
184 cname_suffixes: &[".myshopify.com"],
185 cname_infixes: &[],
186 body_markers: &[
187 "Sorry, this shop is currently unavailable",
188 "Only one step left!",
189 ],
190 claim_statuses: &[],
191 },
192 ProviderFingerprint {
193 provider: "Fastly",
194 cname_suffixes: &[".fastly.net"],
195 cname_infixes: &[],
196 body_markers: &["Fastly error: unknown domain"],
197 claim_statuses: &[500],
198 },
199 ProviderFingerprint {
200 provider: "Ghost",
201 cname_suffixes: &[".ghost.io"],
202 cname_infixes: &[],
203 body_markers: &["The thing you were looking for is no longer here"],
204 claim_statuses: &[],
205 },
206 ProviderFingerprint {
207 provider: "Surge.sh",
208 cname_suffixes: &[".surge.sh"],
209 cname_infixes: &[],
210 body_markers: &["project not found"],
211 claim_statuses: &[404],
212 },
213 ProviderFingerprint {
214 provider: "Bitbucket",
215 cname_suffixes: &[".bitbucket.io"],
216 cname_infixes: &[],
217 body_markers: &["Repository not found"],
218 claim_statuses: &[404],
219 },
220 ProviderFingerprint {
221 provider: "Pantheon",
222 cname_suffixes: &[".pantheonsite.io"],
223 cname_infixes: &[],
224 body_markers: &["The gods are wise, but do not know of the site which you seek"],
225 claim_statuses: &[404],
226 },
227 ProviderFingerprint {
228 provider: "Read the Docs",
229 cname_suffixes: &[".readthedocs.io"],
230 cname_infixes: &[],
231 body_markers: &["unknown to Read the Docs"],
232 claim_statuses: &[404],
233 },
234 ProviderFingerprint {
235 provider: "WP Engine",
236 cname_suffixes: &[".wpengine.com"],
237 cname_infixes: &[],
238 body_markers: &["The site you were looking for couldn't be found"],
239 claim_statuses: &[],
240 },
241 ProviderFingerprint {
242 provider: "Zendesk",
243 cname_suffixes: &[".zendesk.com"],
244 cname_infixes: &[],
245 body_markers: &["Help Center Closed"],
246 claim_statuses: &[],
247 },
248 ProviderFingerprint {
249 provider: "Netlify",
250 cname_suffixes: &[".netlify.app", ".netlify.com"],
251 cname_infixes: &[],
252 body_markers: &["Not Found - Request ID"],
253 claim_statuses: &[404],
254 },
255 ProviderFingerprint {
256 provider: "Statuspage",
257 cname_suffixes: &[".statuspage.io"],
258 cname_infixes: &[],
259 body_markers: &[],
260 claim_statuses: &[],
261 },
262 ProviderFingerprint {
263 provider: "Unbounce",
264 cname_suffixes: &[".unbouncepages.com"],
265 cname_infixes: &[],
266 body_markers: &["The requested URL was not found on this server"],
267 claim_statuses: &[404],
268 },
269 ProviderFingerprint {
270 provider: "Help Scout",
271 cname_suffixes: &[".helpscoutdocs.com"],
272 cname_infixes: &[],
273 body_markers: &["No settings were found for this company"],
274 claim_statuses: &[],
275 },
276 ProviderFingerprint {
277 provider: "LaunchRock",
278 cname_suffixes: &[".launchrock.com"],
279 cname_infixes: &[],
280 body_markers: &["It looks like you may have taken a wrong turn somewhere"],
281 claim_statuses: &[],
282 },
283 ProviderFingerprint {
284 provider: "Tumblr",
285 cname_suffixes: &[".domains.tumblr.com"],
286 cname_infixes: &[],
287 body_markers: &["Whatever you were looking for doesn't currently exist at this address"],
288 claim_statuses: &[404],
289 },
290 ProviderFingerprint {
291 provider: "Webflow",
292 cname_suffixes: &[".proxy-ssl.webflow.com", ".proxy.webflow.com"],
293 cname_infixes: &[],
294 body_markers: &["The page you are looking for doesn't exist or has been moved"],
295 claim_statuses: &[404],
296 },
297 ProviderFingerprint {
298 provider: "Intercom",
299 cname_suffixes: &[".custom.intercom.help"],
300 cname_infixes: &[],
301 body_markers: &["This page is reserved for artistic dogs"],
302 claim_statuses: &[],
303 },
304 ProviderFingerprint {
305 provider: "UserVoice",
306 cname_suffixes: &[".uservoice.com"],
307 cname_infixes: &[],
308 body_markers: &["This UserVoice subdomain is currently available"],
309 claim_statuses: &[],
310 },
311 ProviderFingerprint {
312 provider: "Wufoo",
313 cname_suffixes: &[".wufoo.com"],
314 cname_infixes: &[],
315 body_markers: &["Profile not found"],
316 claim_statuses: &[],
317 },
318 ProviderFingerprint {
319 provider: "FeedPress",
320 cname_suffixes: &[".redirect.feedpress.me"],
321 cname_infixes: &[],
322 body_markers: &["The feed has not been found"],
323 claim_statuses: &[],
324 },
325];
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(rename_all = "lowercase")]
330pub enum TakeoverVerdict {
331 Vulnerable,
334 Potential,
337 Inconclusive,
341 Safe,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct TakeoverFinding {
348 pub host: String,
349 pub verdict: TakeoverVerdict,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub provider: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
354 pub cname: Option<String>,
355 #[serde(skip_serializing_if = "Vec::is_empty")]
356 pub addresses: Vec<String>,
357 #[serde(skip_serializing_if = "Option::is_none")]
360 pub evidence: Option<String>,
361 #[serde(skip_serializing_if = "Option::is_none")]
363 pub http_status: Option<u16>,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub probe_note: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct TakeoverReport {
372 pub domain: String,
373 pub hosts_checked: usize,
375 #[serde(default)]
377 pub hosts_skipped: usize,
378 pub vulnerable: usize,
379 pub potential: usize,
380 #[serde(default)]
383 pub inconclusive: usize,
384 pub findings: Vec<TakeoverFinding>,
388 pub notes: Vec<String>,
389}
390
391impl TakeoverReport {
392 pub fn has_findings(&self) -> bool {
396 self.vulnerable > 0 || self.potential > 0
397 }
398}
399
400pub(crate) fn match_provider(cname: &str) -> Option<&'static ProviderFingerprint> {
404 let c = cname.trim_end_matches('.').to_ascii_lowercase();
405 PROVIDERS.iter().find(|p| {
406 p.cname_suffixes.iter().any(|suffix| c.ends_with(suffix))
407 || p.cname_infixes.iter().any(|infix| c.contains(infix))
408 })
409}
410
411fn match_body_marker(provider: &ProviderFingerprint, body: &str) -> Option<&'static str> {
416 let haystack = body.to_ascii_lowercase();
417 provider
418 .body_markers
419 .iter()
420 .find(|marker| haystack.contains(&marker.to_ascii_lowercase()))
421 .copied()
422}
423
424fn claim_status_matches(provider: &ProviderFingerprint, status: u16) -> bool {
427 provider.claim_statuses.is_empty() || provider.claim_statuses.contains(&status)
428}
429
430pub(crate) fn truncate_to_cap(hosts: &mut Vec<String>, cap: usize) -> usize {
434 let skipped = hosts.len().saturating_sub(cap);
435 hosts.truncate(cap);
436 skipped
437}
438
439fn build_notes(
441 vulnerable: usize,
442 potential: usize,
443 inconclusive: usize,
444 hosts_skipped: usize,
445) -> Vec<String> {
446 let mut notes = Vec::new();
447
448 if vulnerable > 0 {
449 notes.push(format!(
450 "{vulnerable} host(s) served a provider's unclaimed-resource page — an attacker \
451 can likely claim these and host content on your domain. Remove the DNS record or \
452 re-claim the resource."
453 ));
454 }
455 if potential > 0 {
456 notes.push(format!(
457 "{potential} host(s) have a CNAME to a takeover-prone provider that does not \
458 resolve, so nothing answered to confirm it either way. Verify each manually \
459 before acting."
460 ));
461 }
462 if inconclusive > 0 {
463 notes.push(format!(
464 "{inconclusive} host(s) with a takeover-prone provider CNAME could not be checked \
465 (a DNS lookup or the HTTP probe failed — see each host's note); re-run to confirm."
466 ));
467 }
468 if vulnerable == 0 && potential == 0 && inconclusive == 0 {
469 notes.push("No takeover signals found.".to_string());
470 }
471 if hosts_skipped > 0 {
472 notes.push(format!(
473 "{hosts_skipped} host(s) exceeded the {MAX_TAKEOVER_HOSTS}-host scan cap and were \
474 not examined."
475 ));
476 }
477
478 notes
479}
480
481pub(crate) struct HostResolution {
485 pub(crate) addresses: Vec<String>,
487 pub(crate) cname: Option<String>,
489 pub(crate) provider: Option<(&'static ProviderFingerprint, String)>,
492 pub(crate) lookup_error: Option<String>,
496}
497
498async fn cname_of(resolver: &DnsResolver, name: &str) -> Option<String> {
501 let records = resolver.resolve(name, RecordType::CNAME, None).await.ok()?;
502 records.into_iter().find_map(|r| match r.data {
503 RecordData::CNAME { target } => Some(target),
504 _ => None,
505 })
506}
507
508pub(crate) async fn resolve_host(resolver: &DnsResolver, host: &str) -> HostResolution {
510 let (a, aaaa, cname) = tokio::join!(
511 Box::pin(resolver.resolve(host, RecordType::A, None)),
512 Box::pin(resolver.resolve(host, RecordType::AAAA, None)),
513 Box::pin(cname_of(resolver, host)),
514 );
515
516 let mut addresses = Vec::new();
517 let mut lookup_error = None;
518 for result in [a, aaaa] {
519 match result {
520 Ok(records) => addresses.extend(
521 records
522 .iter()
523 .filter_map(|r| r.data.address().map(str::to_string)),
524 ),
525 Err(e) => lookup_error = Some(e.sanitized_message()),
528 }
529 }
530 if !addresses.is_empty() {
531 lookup_error = None;
532 }
533
534 let mut provider = None;
538 let mut seen = std::collections::HashSet::new();
539 let mut hop = cname.clone();
540 for _ in 0..MAX_CNAME_HOPS {
541 let Some(target) = hop.take() else { break };
542 if let Some(p) = match_provider(&target) {
543 provider = Some((p, target));
544 break;
545 }
546 if !seen.insert(target.clone()) {
547 break;
548 }
549 hop = cname_of(resolver, &target).await;
550 }
551
552 HostResolution {
553 addresses,
554 cname,
555 provider,
556 lookup_error,
557 }
558}
559
560fn finding_from_probe(
568 host: String,
569 provider: &ProviderFingerprint,
570 cname: Option<String>,
571 addresses: Vec<String>,
572 response: &crate::http::FetchedResponse,
573) -> TakeoverFinding {
574 let head = &response.head;
579 let final_host = reqwest::Url::parse(&head.final_url)
580 .ok()
581 .as_ref()
582 .and_then(crate::net::url_host)
583 .map(|h| h.trim_end_matches('.').to_ascii_lowercase());
584 let same_host = final_host.as_deref() == Some(host.trim_end_matches('.'));
585 if !same_host {
586 return TakeoverFinding {
587 host,
588 verdict: TakeoverVerdict::Safe,
589 provider: Some(provider.provider.to_string()),
590 cname,
591 addresses,
592 evidence: None,
593 http_status: Some(head.status),
594 probe_note: Some(format!(
595 "redirected to {}; that response is not attributed to this host",
596 final_host.as_deref().unwrap_or("another host")
597 )),
598 };
599 }
600
601 let marker = match_body_marker(provider, &response.body);
602 let evidence = marker.filter(|_| claim_status_matches(provider, head.status));
605 let verdict = if evidence.is_some() {
606 TakeoverVerdict::Vulnerable
607 } else {
608 TakeoverVerdict::Safe
609 };
610 let probe_note = if marker.is_some() && evidence.is_none() {
611 Some(format!(
612 "claim-page text was served with HTTP {}, not the status {}'s unclaimed-resource \
613 page uses; not treated as evidence",
614 head.status, provider.provider
615 ))
616 } else if evidence.is_none() && provider.body_markers.is_empty() {
617 Some("provider has no reliable body fingerprint; verify this host manually".to_string())
620 } else {
621 None
622 };
623
624 TakeoverFinding {
625 host,
626 verdict,
627 provider: Some(provider.provider.to_string()),
628 cname,
629 addresses,
630 evidence: evidence.map(str::to_string),
631 http_status: Some(head.status),
632 probe_note,
633 }
634}
635
636async fn check_host(
639 resolver: &DnsResolver,
640 fetcher: &GuardedFetcher,
641 host: String,
642) -> TakeoverFinding {
643 let HostResolution {
644 addresses,
645 cname,
646 provider,
647 lookup_error,
648 } = resolve_host(resolver, &host).await;
649
650 let Some((provider, provider_cname)) = provider else {
655 return TakeoverFinding {
656 host,
657 verdict: TakeoverVerdict::Safe,
658 provider: None,
659 cname,
660 addresses,
661 evidence: None,
662 http_status: None,
663 probe_note: None,
664 };
665 };
666
667 let cname = Some(provider_cname);
669
670 if addresses.is_empty() {
675 let (verdict, probe_note) = match lookup_error {
676 Some(e) => (
677 TakeoverVerdict::Inconclusive,
678 format!(
679 "CNAME points at a takeover-prone provider, but its address lookup failed \
680 ({e}); re-run to confirm"
681 ),
682 ),
683 None => (
684 TakeoverVerdict::Potential,
685 "CNAME points at a takeover-prone provider but does not resolve".to_string(),
686 ),
687 };
688 return TakeoverFinding {
689 host,
690 verdict,
691 provider: Some(provider.provider.to_string()),
692 cname,
693 addresses,
694 evidence: None,
695 http_status: None,
696 probe_note: Some(probe_note),
697 };
698 }
699
700 let https_err = match fetcher.get(&format!("https://{host}/")).await {
706 Ok(response) => return finding_from_probe(host, provider, cname, addresses, &response),
707 Err(e) => e,
708 };
709 match fetcher.get(&format!("http://{host}/")).await {
710 Ok(response) => finding_from_probe(host, provider, cname, addresses, &response),
711 Err(http_err) => {
712 debug!(host = %host, https = %https_err, http = %http_err, "takeover probe failed");
713 TakeoverFinding {
717 host,
718 verdict: TakeoverVerdict::Inconclusive,
719 provider: Some(provider.provider.to_string()),
720 cname,
721 addresses,
722 evidence: None,
723 http_status: None,
724 probe_note: Some(format!(
725 "HTTP probe failed over HTTPS ({}) and HTTP ({})",
726 https_err.sanitized_message(),
727 http_err.sanitized_message()
728 )),
729 }
730 }
731 }
732}
733
734fn build_report(
737 domain: String,
738 findings: Vec<TakeoverFinding>,
739 hosts_skipped: usize,
740) -> TakeoverReport {
741 let hosts_checked = findings.len();
742 let vulnerable = findings
743 .iter()
744 .filter(|f| f.verdict == TakeoverVerdict::Vulnerable)
745 .count();
746 let potential = findings
747 .iter()
748 .filter(|f| f.verdict == TakeoverVerdict::Potential)
749 .count();
750 let inconclusive = findings
751 .iter()
752 .filter(|f| f.verdict == TakeoverVerdict::Inconclusive)
753 .count();
754
755 let mut reported: Vec<TakeoverFinding> = findings
757 .into_iter()
758 .filter(|f| f.verdict != TakeoverVerdict::Safe)
759 .collect();
760 reported.sort_by(|a, b| {
761 let rank = |v: TakeoverVerdict| match v {
762 TakeoverVerdict::Vulnerable => 0,
763 TakeoverVerdict::Potential => 1,
764 TakeoverVerdict::Inconclusive => 2,
765 TakeoverVerdict::Safe => 3,
766 };
767 rank(a.verdict)
768 .cmp(&rank(b.verdict))
769 .then_with(|| a.host.cmp(&b.host))
770 });
771
772 TakeoverReport {
773 domain,
774 hosts_checked,
775 hosts_skipped,
776 vulnerable,
777 potential,
778 inconclusive,
779 notes: build_notes(vulnerable, potential, inconclusive, hosts_skipped),
780 findings: reported,
781 }
782}
783
784pub async fn scan_takeover(
804 resolver: &DnsResolver,
805 domain: &str,
806 mut hosts: Vec<String>,
807 concurrency: usize,
808 timeout: Duration,
809) -> Result<TakeoverReport> {
810 let domain = normalize_domain(domain)?;
811 for host in &mut hosts {
815 if let Ok(normalized) = normalize_host(host) {
816 *host = normalized;
817 }
818 }
819 let mut seen = std::collections::HashSet::new();
820 hosts.retain(|h| seen.insert(h.clone()));
821 let hosts_skipped = truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS);
822 let concurrency = concurrency.max(1);
823
824 let fetcher = GuardedFetcher::new()
825 .with_timeout(timeout)
826 .with_max_body(PROBE_BODY_LIMIT);
827
828 let findings: Vec<TakeoverFinding> = stream::iter(hosts)
829 .map(|host| {
830 let fetcher = &fetcher;
831 async move { check_host(resolver, fetcher, host).await }
832 })
833 .buffer_unordered(concurrency)
834 .collect()
835 .await;
836
837 Ok(build_report(domain, findings, hosts_skipped))
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 fn finding(host: &str, verdict: TakeoverVerdict) -> TakeoverFinding {
845 TakeoverFinding {
846 host: host.to_string(),
847 verdict,
848 provider: Some("GitHub Pages".to_string()),
849 cname: Some("x.github.io".to_string()),
850 addresses: vec![],
851 evidence: None,
852 http_status: None,
853 probe_note: None,
854 }
855 }
856
857 #[test]
858 fn provider_matching_by_cname_suffix() {
859 assert_eq!(
860 match_provider("myapp.herokuapp.com").map(|p| p.provider),
861 Some("Heroku")
862 );
863 assert_eq!(
864 match_provider("user.github.io.").map(|p| p.provider),
865 Some("GitHub Pages")
866 );
867 assert_eq!(
868 match_provider("BUCKET.S3.AMAZONAWS.COM").map(|p| p.provider),
869 Some("AWS S3")
870 );
871 assert_eq!(match_provider("cdn.example.com").map(|p| p.provider), None);
873 }
874
875 #[test]
876 fn provider_apex_alone_does_not_match() {
877 assert!(match_provider("github.io").is_none());
880 assert!(match_provider("herokuapp.com").is_none());
881 }
882
883 #[test]
884 fn body_markers_match_case_insensitively() {
885 let gh = match_provider("x.github.io").expect("github provider");
886 assert_eq!(
887 match_body_marker(gh, "<h1>There isn't a GitHub Pages site here.</h1>"),
888 Some("There isn't a GitHub Pages site here.")
889 );
890 assert!(match_body_marker(gh, "THERE ISN'T A GITHUB PAGES SITE HERE.").is_some());
892 assert_eq!(match_body_marker(gh, "<h1>Welcome to my blog</h1>"), None);
894 }
895
896 #[test]
897 fn providers_without_markers_never_match_a_body() {
898 let cf = match_provider("d123.cloudfront.net").expect("cloudfront provider");
899 assert!(cf.body_markers.is_empty());
900 assert_eq!(match_body_marker(cf, "404 Not Found"), None);
903 }
904
905 #[test]
906 fn suffix_match_is_anchored_at_the_end() {
907 assert!(match_provider("x.github.io.attacker.example").is_none());
911 assert!(match_provider("app.herokuapp.com.evil.test").is_none());
912 }
913
914 #[test]
915 fn s3_website_region_endpoints_match_via_infix() {
916 assert_eq!(
919 match_provider("bucket.s3-website-us-east-1.amazonaws.com").map(|p| p.provider),
920 Some("AWS S3")
921 );
922 assert_eq!(
923 match_provider("bucket.s3-website.eu-west-2.amazonaws.com").map(|p| p.provider),
924 Some("AWS S3")
925 );
926 }
927
928 #[test]
929 fn only_s3_opts_into_loose_infix_matching() {
930 for p in PROVIDERS {
933 if p.provider != "AWS S3" {
934 assert!(
935 p.cname_infixes.is_empty(),
936 "{} must not use infix matching without a documented reason",
937 p.provider
938 );
939 }
940 }
941 }
942
943 #[test]
944 fn every_fingerprint_suffix_is_dot_prefixed() {
945 for p in PROVIDERS {
948 assert!(
949 !p.cname_suffixes.is_empty(),
950 "{} has no CNAME suffixes",
951 p.provider
952 );
953 for suffix in p.cname_suffixes {
954 assert!(
955 suffix.starts_with('.'),
956 "{} suffix {suffix:?} must start with a dot",
957 p.provider
958 );
959 }
960 }
961 }
962
963 fn probe(host: &str, status: u16, body: &str) -> crate::http::FetchedResponse {
966 crate::http::FetchedResponse {
967 head: crate::http::ResponseHead {
968 final_url: format!("https://{host}/"),
969 status,
970 headers: vec![],
971 redirects: 0,
972 },
973 body: body.to_string(),
974 }
975 }
976
977 #[test]
978 fn matching_body_marker_confirms_vulnerable_with_evidence() {
979 let gh = match_provider("gone.github.io").expect("github provider");
980 let finding = finding_from_probe(
981 "gone.example.com".to_string(),
982 gh,
983 Some("gone.github.io".to_string()),
984 vec!["185.199.108.153".to_string()],
985 &probe(
986 "gone.example.com",
987 404,
988 "<h1>There isn't a GitHub Pages site here.</h1>",
989 ),
990 );
991 assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
992 assert_eq!(
994 finding.evidence.as_deref(),
995 Some("There isn't a GitHub Pages site here.")
996 );
997 assert_eq!(finding.http_status, Some(404));
998 }
999
1000 #[test]
1001 fn a_live_site_on_a_provider_cname_is_safe() {
1002 let gh = match_provider("user.github.io").expect("github provider");
1005 let finding = finding_from_probe(
1006 "blog.example.com".to_string(),
1007 gh,
1008 Some("user.github.io".to_string()),
1009 vec!["185.199.108.153".to_string()],
1010 &probe(
1011 "blog.example.com",
1012 200,
1013 "<html><body><h1>My Blog</h1></body></html>",
1014 ),
1015 );
1016 assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1017 assert!(finding.evidence.is_none());
1018 }
1019
1020 #[test]
1021 fn a_404_alone_never_confirms_a_takeover() {
1022 let gh = match_provider("user.github.io").expect("github provider");
1025 let finding = finding_from_probe(
1026 "blog.example.com".to_string(),
1027 gh,
1028 Some("user.github.io".to_string()),
1029 vec!["185.199.108.153".to_string()],
1030 &probe("blog.example.com", 404, "<h1>Page not found</h1>"),
1031 );
1032 assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1033 }
1034
1035 #[test]
1036 fn marker_after_a_cross_host_redirect_is_not_evidence() {
1037 let heroku = match_provider("app.herokuapp.com").expect("heroku provider");
1040 let mut response = probe("retired-app.herokuapp.com", 404, "No such app");
1041 response.head.redirects = 1;
1042 let finding = finding_from_probe(
1043 "app.example.com".to_string(),
1044 heroku,
1045 Some("app.herokuapp.com".to_string()),
1046 vec!["203.0.113.7".to_string()],
1047 &response,
1048 );
1049 assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1050 assert!(finding.evidence.is_none());
1051 assert!(finding
1052 .probe_note
1053 .as_deref()
1054 .unwrap_or_default()
1055 .contains("retired-app.herokuapp.com"));
1056
1057 let mut plain = probe("app.example.com", 404, "No such app");
1059 plain.head.final_url = "http://App.Example.com./".to_string();
1060 let finding = finding_from_probe(
1061 "app.example.com".to_string(),
1062 heroku,
1063 Some("app.herokuapp.com".to_string()),
1064 vec!["203.0.113.7".to_string()],
1065 &plain,
1066 );
1067 assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
1068 }
1069
1070 #[test]
1071 fn fingerprintless_provider_says_it_could_not_confirm() {
1072 let cf = match_provider("d123.cloudfront.net").expect("cloudfront provider");
1075 let finding = finding_from_probe(
1076 "cdn.example.com".to_string(),
1077 cf,
1078 Some("d123.cloudfront.net".to_string()),
1079 vec!["203.0.113.9".to_string()],
1080 &probe("cdn.example.com", 403, "Bad request"),
1081 );
1082 assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1083 assert!(
1084 finding
1085 .probe_note
1086 .as_deref()
1087 .unwrap_or_default()
1088 .contains("verify this host manually"),
1089 "got {:?}",
1090 finding.probe_note
1091 );
1092 }
1093
1094 #[test]
1095 fn report_counts_and_orders_findings() {
1096 let findings = vec![
1097 finding("safe.example.com", TakeoverVerdict::Safe),
1098 finding("zeta.example.com", TakeoverVerdict::Potential),
1099 finding("beta.example.com", TakeoverVerdict::Vulnerable),
1100 finding("alpha.example.com", TakeoverVerdict::Potential),
1101 ];
1102 let report = build_report("example.com".to_string(), findings, 0);
1103
1104 assert_eq!(report.hosts_checked, 4);
1105 assert_eq!(report.vulnerable, 1);
1106 assert_eq!(report.potential, 2);
1107 assert_eq!(report.findings.len(), 3);
1109 assert_eq!(report.findings[0].host, "beta.example.com");
1110 assert_eq!(report.findings[1].host, "alpha.example.com");
1112 assert_eq!(report.findings[2].host, "zeta.example.com");
1113 assert!(report.has_findings());
1114 }
1115
1116 #[test]
1117 fn clean_scan_reports_no_findings() {
1118 let findings = vec![
1119 finding("a.example.com", TakeoverVerdict::Safe),
1120 finding("b.example.com", TakeoverVerdict::Safe),
1121 ];
1122 let report = build_report("example.com".to_string(), findings, 0);
1123 assert_eq!(report.hosts_checked, 2);
1124 assert!(report.findings.is_empty());
1125 assert!(!report.has_findings());
1126 assert!(report
1127 .notes
1128 .iter()
1129 .any(|n| n.contains("No takeover signals")));
1130 }
1131
1132 #[test]
1133 fn host_cap_truncates_and_reports_the_remainder() {
1134 let over = MAX_TAKEOVER_HOSTS + 15;
1135 let mut hosts: Vec<String> = (0..over).map(|i| format!("h{i}.example.com")).collect();
1136 let skipped = truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS);
1137 assert_eq!(hosts.len(), MAX_TAKEOVER_HOSTS);
1138 assert_eq!(skipped, 15);
1139
1140 let report = build_report("example.com".to_string(), vec![], skipped);
1142 assert!(report.notes.iter().any(|n| n.contains("scan cap")));
1143 assert_eq!(report.hosts_skipped, 15);
1144 }
1145
1146 #[test]
1147 fn host_cap_is_a_noop_under_the_limit() {
1148 let mut hosts: Vec<String> = (0..5).map(|i| format!("h{i}.example.com")).collect();
1149 assert_eq!(truncate_to_cap(&mut hosts, MAX_TAKEOVER_HOSTS), 0);
1150 assert_eq!(hosts.len(), 5);
1151 }
1152
1153 #[test]
1154 fn notes_distinguish_confirmed_from_unconfirmed() {
1155 let notes = build_notes(2, 3, 4, 0);
1156 assert!(notes.iter().any(|n| n.contains("2 host(s) served")));
1158 assert!(notes
1160 .iter()
1161 .any(|n| n.contains("3 host(s)") && n.contains("manually")));
1162 assert!(notes
1164 .iter()
1165 .any(|n| n.contains("4 host(s)") && n.contains("could not be checked")));
1166 }
1167
1168 #[test]
1171 fn a_marker_under_the_wrong_status_is_not_evidence() {
1172 let gh = match_provider("user.github.io").expect("github provider");
1173 let finding = finding_from_probe(
1174 "blog.example.com".to_string(),
1175 gh,
1176 Some("user.github.io".to_string()),
1177 vec!["185.199.108.153".to_string()],
1178 &probe(
1179 "blog.example.com",
1180 200,
1181 "<p>If you see \"There isn't a GitHub Pages site here.\" ...</p>",
1182 ),
1183 );
1184 assert_eq!(finding.verdict, TakeoverVerdict::Safe);
1185 assert!(finding.evidence.is_none());
1186 assert!(finding
1187 .probe_note
1188 .as_deref()
1189 .unwrap_or_default()
1190 .contains("HTTP 200"));
1191
1192 let shopify = match_provider("shop.myshopify.com").expect("shopify provider");
1194 assert!(shopify.claim_statuses.is_empty());
1195 let finding = finding_from_probe(
1196 "shop.example.com".to_string(),
1197 shopify,
1198 Some("shop.myshopify.com".to_string()),
1199 vec!["203.0.113.7".to_string()],
1200 &probe(
1201 "shop.example.com",
1202 200,
1203 "Sorry, this shop is currently unavailable",
1204 ),
1205 );
1206 assert_eq!(finding.verdict, TakeoverVerdict::Vulnerable);
1207 }
1208
1209 #[test]
1210 fn claim_statuses_are_error_statuses() {
1211 for p in PROVIDERS {
1214 for status in p.claim_statuses {
1215 assert!(*status >= 400, "{}: {status}", p.provider);
1216 }
1217 }
1218 }
1219
1220 #[test]
1221 fn inconclusive_hosts_are_listed_but_not_findings() {
1222 let findings = vec![
1223 finding("a.example.com", TakeoverVerdict::Inconclusive),
1224 finding("b.example.com", TakeoverVerdict::Safe),
1225 ];
1226 let report = build_report("example.com".to_string(), findings, 0);
1227 assert_eq!(report.inconclusive, 1);
1228 assert_eq!(report.potential, 0);
1229 assert_eq!(report.findings.len(), 1);
1230 assert!(!report.has_findings());
1231 }
1232
1233 async fn chain_resolver(chain: &'static [(&'static str, &'static str)]) -> DnsResolver {
1235 use crate::dns::test_support::{
1236 cname_rdata, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1237 };
1238 use hickory_resolver::proto::rr::RecordType as WireType;
1239
1240 let port = spawn_mock_dns_fn(move |name, rtype| {
1241 match chain.iter().find(|(from, _)| *from == name) {
1242 Some((_, to)) if rtype == WireType::CNAME => {
1243 MockReply::Answer(vec![cname_rdata(to)])
1244 }
1245 _ => MockReply::NxDomain,
1246 }
1247 })
1248 .await;
1249 mock_dns_resolver_default(port)
1250 }
1251
1252 #[tokio::test]
1256 async fn a_provider_later_in_the_cname_chain_is_found() {
1257 let resolver = chain_resolver(&[
1258 ("docs.example.com", "docs.cdn.example.net"),
1259 ("docs.cdn.example.net", "gone.github.io"),
1260 ])
1261 .await;
1262 let r = resolve_host(&resolver, "docs.example.com").await;
1263 assert_eq!(r.cname.as_deref(), Some("docs.cdn.example.net."));
1264 let (provider, hop) = r.provider.expect("provider in the chain");
1265 assert_eq!(provider.provider, "GitHub Pages");
1266 assert_eq!(hop, "gone.github.io.");
1267 }
1268
1269 #[tokio::test]
1270 async fn a_looping_cname_chain_ends() {
1271 let resolver = chain_resolver(&[
1272 ("a.example.com", "b.example.net"),
1273 ("b.example.net", "a.example.com"),
1274 ])
1275 .await;
1276 let r = resolve_host(&resolver, "a.example.com").await;
1277 assert!(r.provider.is_none());
1278 }
1279}