1use crate::util::{bounded_text, is_routable_ip};
8use crate::{OriginCandidate, ValidationState};
9use gossan_core::{Config, ScanClient};
10use std::collections::HashSet;
11use std::net::IpAddr;
12
13#[derive(Debug, Clone)]
15struct Fingerprint {
16 status: u16,
17 body_hash: String,
18 title: Option<String>,
19 etag: Option<String>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum Comparison {
25 Match,
27 FalsePositive,
29 NoMatch,
31}
32
33fn extract_title(body: &str) -> Option<String> {
35 let lower = body.to_lowercase();
36 let start = lower.find("<title>")? + 7;
37 let end = lower[start..].find("</title>")?;
38 Some(body[start..start + end].trim().to_string())
39}
40
41fn body_hash(body: &str) -> String {
43 use sha2::{Digest, Sha256};
44 hex::encode(Sha256::digest(body.as_bytes()))
45}
46
47async fn fetch_baseline(client: &ScanClient, domain: &str, limit: usize) -> Option<Fingerprint> {
50 for scheme in ["https", "http"] {
51 let url = format!("{}://{}/", scheme, domain);
52 if let Ok(resp) = client.get(&url).await {
53 let status = resp.status().as_u16();
54 let etag = resp
55 .headers()
56 .get("etag")
57 .and_then(|v| v.to_str().ok())
58 .map(|s| s.to_string());
59 if let Ok(body) = bounded_text(resp, limit).await {
60 return Some(Fingerprint {
61 status,
62 body_hash: body_hash(&body),
63 title: extract_title(&body),
64 etag,
65 });
66 }
67 }
68 }
69 None
70}
71
72fn ip_authority(ip: IpAddr, port: Option<u16>) -> String {
76 let host = match ip {
77 IpAddr::V4(v4) => v4.to_string(),
78 IpAddr::V6(v6) => format!("[{}]", v6),
79 };
80 match port {
81 Some(p) => format!("{}:{}", host, p),
82 None => host,
83 }
84}
85
86async fn fetch_direct(
88 client: &ScanClient,
89 domain: &str,
90 ip: IpAddr,
91 port: Option<u16>,
92 limit: usize,
93) -> Option<Fingerprint> {
94 let authority = ip_authority(ip, port);
95 for scheme in ["https", "http"] {
96 let url = format!("{}://{}/", scheme, authority);
97 let req = client
98 .inner()
99 .get(&url)
100 .header("Host", domain)
101 .build()
102 .ok()?;
103 if let Ok(resp) = client.execute(req).await {
104 let status = resp.status().as_u16();
105 let etag = resp
106 .headers()
107 .get("etag")
108 .and_then(|v| v.to_str().ok())
109 .map(|s| s.to_string());
110 if let Ok(body) = bounded_text(resp, limit).await {
111 return Some(Fingerprint {
112 status,
113 body_hash: body_hash(&body),
114 title: extract_title(&body),
115 etag,
116 });
117 }
118 }
119 }
120 None
121}
122
123fn compare(baseline: &Fingerprint, direct: &Fingerprint) -> Comparison {
125 if direct.status == 200 {
127 let markers = [
129 "Welcome to nginx",
130 "It works!",
131 "Apache2 Ubuntu Default Page",
132 "IIS Windows Server",
133 ];
134 if let Some(ref title) = direct.title {
137 for marker in &markers {
138 if title.contains(marker) {
139 if baseline.title.as_ref() != Some(title) {
141 return Comparison::FalsePositive;
142 }
143 }
144 }
145 }
146 }
147
148 let mut matches = 0;
149 if direct.status == baseline.status {
150 matches += 1;
151 }
152 if direct.body_hash == baseline.body_hash {
153 matches += 1;
154 }
155 if direct.etag.is_some() && direct.etag == baseline.etag {
156 matches += 1;
157 }
158 if direct.title.is_some() && direct.title == baseline.title {
159 matches += 1;
160 }
161
162 if matches >= 2 {
163 Comparison::Match
164 } else {
165 Comparison::NoMatch
166 }
167}
168
169async fn fetch_404(
172 client: &ScanClient,
173 domain: &str,
174 target: &str,
175 limit: usize,
176) -> Option<(u16, String)> {
177 let path = format!("/nonexistent-{}", uuid::Uuid::new_v4());
178 let https_url = format!("https://{}{}", target, path);
180 let req = client
181 .inner()
182 .get(&https_url)
183 .header("Host", domain)
184 .build()
185 .ok()?;
186 if let Ok(resp) = client.execute(req).await {
187 let status = resp.status().as_u16();
188 if let Ok(body) = bounded_text(resp, limit).await {
189 return Some((status, body_hash(&body)));
190 }
191 }
192 let http_url = format!("http://{}{}", target, path);
194 let req = client
195 .inner()
196 .get(&http_url)
197 .header("Host", domain)
198 .build()
199 .ok()?;
200 if let Ok(resp) = client.execute(req).await {
201 let status = resp.status().as_u16();
202 if let Ok(body) = bounded_text(resp, limit).await {
203 return Some((status, body_hash(&body)));
204 }
205 }
206 None
207}
208
209async fn fingerprint_404(
212 client: &ScanClient,
213 domain: &str,
214 ip: IpAddr,
215 port: Option<u16>,
216 limit: usize,
217) -> bool {
218 let cdn_404 = fetch_404(client, domain, domain, limit).await;
219 let direct_404 = fetch_404(client, domain, &ip_authority(ip, port), limit).await;
220 match (cdn_404, direct_404) {
221 (Some((cdn_status, cdn_hash)), Some((direct_status, direct_hash))) => {
222 cdn_status == direct_status && cdn_hash != direct_hash
223 }
224 _ => false,
225 }
226}
227
228pub async fn validate(
234 candidates: Vec<OriginCandidate>,
235 domain: &str,
236 _config: &Config,
237 client: &ScanClient,
238) -> Vec<OriginCandidate> {
239 let limit = _config.max_response_size.min(2 * 1024 * 1024).max(1024);
240
241 let baseline = fetch_baseline(client, domain, limit).await;
242 if baseline.is_none() {
243 tracing::warn!(domain = %domain, "validator could not fetch baseline");
244 }
245
246 let mut validated = Vec::with_capacity(candidates.len());
247
248 for mut candidate in candidates {
249 let allow_non_routable = candidate.port.is_some();
255 if !allow_non_routable && !is_routable_ip(candidate.ip) {
256 candidate.validated = ValidationState::Rejected;
257 validated.push(candidate);
258 continue;
259 }
260
261 let Some(ref baseline_fp) = baseline else {
262 validated.push(candidate);
264 continue;
265 };
266
267 let Some(direct_fp) =
268 fetch_direct(client, domain, candidate.ip, candidate.port, limit).await
269 else {
270 validated.push(candidate);
271 continue;
272 };
273
274 match compare(baseline_fp, &direct_fp) {
275 Comparison::Match => {
276 candidate.confidence = 100;
277 candidate.validated = ValidationState::Confirmed;
278 candidate.method = "validated_origin".to_string();
279 tracing::info!(ip = %candidate.ip, "origin confirmed by host-header swap");
280 }
281 Comparison::FalsePositive => {
282 candidate.validated = ValidationState::Rejected;
283 tracing::info!(ip = %candidate.ip, "origin candidate rejected (generic default page)");
284 }
285 Comparison::NoMatch => {
286 if fingerprint_404(client, domain, candidate.ip, candidate.port, limit).await {
288 candidate.confidence = 95;
289 candidate.validated = ValidationState::Confirmed;
290 candidate.method = "validated_origin_404".to_string();
291 tracing::info!(ip = %candidate.ip, "origin confirmed by 404 divergence");
292 } else {
293 candidate.validated = ValidationState::Speculative;
294 }
295 }
296 }
297
298 validated.push(candidate);
299 }
300
301 validated.sort_by(|a, b| {
303 let a_ord = match a.validated {
304 ValidationState::Confirmed => 2,
305 ValidationState::Speculative => 1,
306 ValidationState::Rejected => 0,
307 };
308 let b_ord = match b.validated {
309 ValidationState::Confirmed => 2,
310 ValidationState::Speculative => 1,
311 ValidationState::Rejected => 0,
312 };
313 b_ord
314 .cmp(&a_ord)
315 .then_with(|| b.confidence.cmp(&a.confidence))
316 });
317
318 let mut seen = HashSet::new();
320 validated.retain(|c| seen.insert(c.ip));
321
322 validated
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn extract_title_finds_simple_title() {
331 let body = "<html><head><title>Hello World</title></head></html>";
332 assert_eq!(extract_title(body), Some("Hello World".to_string()));
333 }
334
335 #[test]
336 fn body_hash_is_deterministic() {
337 let h1 = body_hash("test");
338 let h2 = body_hash("test");
339 assert_eq!(h1, h2);
340 assert_ne!(h1, body_hash("different"));
341 }
342
343 #[test]
344 fn comparison_match_with_body_and_title() {
345 let baseline = Fingerprint {
346 status: 200,
347 body_hash: "abc".into(),
348 title: Some("Home".into()),
349 etag: Some("e1".into()),
350 };
351 let direct = Fingerprint {
352 status: 200,
353 body_hash: "abc".into(),
354 title: Some("Home".into()),
355 etag: Some("e2".into()),
356 };
357 assert_eq!(compare(&baseline, &direct), Comparison::Match);
358 }
359
360 #[test]
361 fn comparison_false_positive_for_welcome_nginx() {
362 let baseline = Fingerprint {
363 status: 200,
364 body_hash: "base".into(),
365 title: Some("Real Site".into()),
366 etag: None,
367 };
368 let direct = Fingerprint {
369 status: 200,
370 body_hash: "direct".into(),
371 title: Some("Welcome to nginx!".into()),
372 etag: None,
373 };
374 assert_eq!(compare(&baseline, &direct), Comparison::FalsePositive);
375 }
376
377 #[test]
378 fn comparison_no_match_when_different() {
379 let baseline = Fingerprint {
380 status: 200,
381 body_hash: "base".into(),
382 title: Some("Home".into()),
383 etag: None,
384 };
385 let direct = Fingerprint {
386 status: 200,
387 body_hash: "other".into(),
388 title: Some("Other".into()),
389 etag: None,
390 };
391 assert_eq!(compare(&baseline, &direct), Comparison::NoMatch);
392 }
393}