Skip to main content

gossan_origin/
validator.rs

1//! Active origin validation — host-header swap and 404 fingerprinting.
2//!
3//! Every candidate IP discovered by passive/heuristic scanners is validated
4//! by opening a direct connection with the original `Host` header and
5//! comparing the response fingerprint to the CDN-routed baseline.
6
7use 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/// Fingerprint of an HTTP response used for comparison.
14#[derive(Debug, Clone)]
15struct Fingerprint {
16    status: u16,
17    body_hash: String,
18    title: Option<String>,
19    etag: Option<String>,
20}
21
22/// Result of comparing a direct-IP response to the CDN baseline.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24enum Comparison {
25    /// At least two stable attributes match — origin confirmed.
26    Match,
27    /// Direct IP serves a generic default page (nginx/Apache welcome).
28    FalsePositive,
29    /// No meaningful similarity — candidate is speculative at best.
30    NoMatch,
31}
32
33/// Extract `<title>` from HTML without regex.
34fn 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
41/// Compute a stable SHA-256 hex hash of the (possibly truncated) body.
42fn body_hash(body: &str) -> String {
43    use sha2::{Digest, Sha256};
44    hex::encode(Sha256::digest(body.as_bytes()))
45}
46
47/// Fetch the CDN-routed baseline for a domain.
48/// Tries HTTPS first, then HTTP.
49async 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
72/// Format an authority component for an IP+optional-port. IPv6
73/// addresses get bracketed per RFC 3986 so the URL parser doesn't
74/// confuse the colon in `::1` with the port separator.
75fn 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
86/// Fetch the direct-IP response with the original `Host` header.
87async 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
123/// Compare baseline and direct fingerprints.
124fn compare(baseline: &Fingerprint, direct: &Fingerprint) -> Comparison {
125    // If the direct IP returns a generic default page, it's a false positive.
126    if direct.status == 200 {
127        // Common default page markers.
128        let markers = [
129            "Welcome to nginx",
130            "It works!",
131            "Apache2 Ubuntu Default Page",
132            "IIS Windows Server",
133        ];
134        // We don't have the raw body here, but we can infer from title/body hash
135        // if the title matches known defaults.
136        if let Some(ref title) = direct.title {
137            for marker in &markers {
138                if title.contains(marker) {
139                    // Only reject if the baseline does NOT share this title.
140                    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
169/// Fetch a 404 page fingerprint for the given target authority
170/// (`host` or `host:port`, IPv6 already bracketed).
171async 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    // Try HTTPS first
179    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    // Fall back to HTTP
193    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
209/// Compare 404 behaviour between CDN and direct IP.
210/// Returns `true` if the 404 pages differ (strong origin signal).
211async 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
228/// Validate a list of origin candidates.
229///
230/// Confirmed candidates receive `confidence = 100` and `validated = Confirmed`.
231/// Candidates that fail validation keep their original confidence and are marked
232/// `Rejected` (consumers may choose to drop them).
233pub 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        // An explicit port on the candidate signals operator intent —
250        // wiremock harnesses bind to ephemeral 127.0.0.1:N, and Censys/
251        // Shodan-derived candidates may legitimately point at private
252        // ranges in pentest contexts. The unguarded discovery path
253        // (no port set) keeps the global-routability gate.
254        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            // No baseline — keep speculative.
263            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                // 404 divergence can rescue a speculative candidate.
287                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    // Sort: Confirmed first, then by confidence descending.
302    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    // Deduplicate by IP, keeping the best validation state + highest confidence.
319    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}