Skip to main content

termivar_scanner/phases/
phase9_ssrf.rs

1//! # Phase 9: quarantined SSRF out-of-band probing
2//!
3//! This legacy phase can deliver an explicitly configured out-of-band (OOB)
4//! callback URL through already observed query parameters. Delivery uses the
5//! scan's shared, exact-origin, bounded HTTP authority. A completed request is
6//! recorded as typed probe evidence; it carries no security conclusion.
7//!
8//! Callback collection is deliberately absent from the legacy contract. Until
9//! a host-owned verifier can provide nonce-correlated callback evidence, this
10//! phase emits an empty result. No default callback destinations or sensitive
11//! target payloads are compiled into this module.
12
13use async_trait::async_trait;
14use termivar_core::{
15    ConfidenceScore, Evidence, EvidenceId, EvidenceKind, EvidenceSource, EvidenceValue,
16    KnowledgePredicate,
17};
18use url::Url;
19use uuid::Uuid;
20
21use crate::{
22    context::ScanContext,
23    contracts::{ScanFinding, ScanPhase},
24    error::ScannerError,
25    http_evidence::HttpProbeMethod,
26};
27
28const SSRF_PROBE_ACTION_ID: &str = "legacy.probe.ssrf-oob";
29const SSRF_PROBE_PREDICATE_NAMESPACE: &str = "legacy.ssrf";
30const SSRF_PROBE_PREDICATE_NAME: &str = "oob-probe-response-status";
31const MAX_CALLBACK_DOMAIN_BYTES: usize = 253;
32const MAX_SSRF_DELIVERY_REQUESTS: usize = 16;
33
34/// Legacy SSRF probe configuration.
35///
36/// Without an OOB domain the phase is inert. Configuring a domain authorizes
37/// only bounded delivery of a nonce-bearing callback URL to the exact scan
38/// origin. It does not install a callback verifier or draw a security
39/// conclusion.
40pub struct SsrfScanner {
41    oob_domain: Option<String>,
42}
43
44impl std::fmt::Debug for SsrfScanner {
45    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        formatter
47            .debug_struct("SsrfScanner")
48            .field("oob_domain_configured", &self.oob_domain.is_some())
49            .finish()
50    }
51}
52
53impl SsrfScanner {
54    /// Creates an inert SSRF phase with no probe destination.
55    pub const fn new() -> Self {
56        Self { oob_domain: None }
57    }
58
59    /// Retains the pre-1.0 configuration shape for an opt-in OOB domain.
60    ///
61    /// The domain is validated before any request is dispatched. A bare DNS
62    /// domain is required; URLs, IP literals, single-label names, and local or
63    /// internal suffixes fail closed during execution.
64    pub fn with_oob_domain(oob_domain: String) -> Self {
65        Self {
66            oob_domain: Some(oob_domain),
67        }
68    }
69}
70
71impl Default for SsrfScanner {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77#[async_trait]
78impl ScanPhase for SsrfScanner {
79    fn phase_number(&self) -> u8 {
80        9
81    }
82
83    fn name(&self) -> &'static str {
84        "SSRF Callback Probe Recorder"
85    }
86
87    async fn execute(&self, ctx: &ScanContext) -> Result<Vec<ScanFinding>, ScannerError> {
88        let Some(raw_domain) = self.oob_domain.as_deref() else {
89            ctx.log("Phase 9: SSRF OOB probing skipped; no callback domain configured".to_owned());
90            return Ok(Vec::new());
91        };
92        let callback_domain = validate_callback_domain(raw_domain)?;
93        ctx.log(
94            "Phase 9: bounded SSRF OOB probe delivery initiated; callback verification unavailable"
95                .to_owned(),
96        );
97
98        let snapshot = ctx.discovery_snapshot();
99        let mut receipts = Vec::new();
100        'endpoints: for (endpoint, parameters) in snapshot.endpoints() {
101            let endpoint = Url::parse(endpoint)?;
102            for parameter in parameters {
103                if receipts.len() >= MAX_SSRF_DELIVERY_REQUESTS {
104                    break 'endpoints;
105                }
106                let nonce = Uuid::new_v4().simple().to_string();
107                let callback_url = format!("http://{nonce}.{callback_domain}/");
108                let probe_url = replace_query_parameter(&endpoint, parameter, &callback_url);
109                let response = ctx
110                    .verification_request(SSRF_PROBE_ACTION_ID, HttpProbeMethod::Get, probe_url)
111                    .await?;
112
113                receipts.push((nonce, response.status()));
114            }
115        }
116
117        ctx.ensure_legacy_verification_commit(SSRF_PROBE_ACTION_ID)?;
118        let evidence = receipts
119            .iter()
120            .map(|(nonce, status)| probe_receipt(ctx, nonce, *status))
121            .collect::<Result<Vec<_>, _>>()?;
122        ctx.knowledge()
123            .insert_evidence_batch(evidence)
124            .map_err(|_| receipt_error())?;
125
126        ctx.log(format!(
127            "Phase 9: SSRF OOB probing completed with {} probe receipts and an empty result",
128            receipts.len()
129        ));
130        Ok(Vec::new())
131    }
132}
133
134fn validate_callback_domain(raw: &str) -> Result<String, ScannerError> {
135    if raw.is_empty()
136        || raw.len() > MAX_CALLBACK_DOMAIN_BYTES
137        || raw.trim() != raw
138        || !raw.is_ascii()
139        || raw.ends_with('.')
140    {
141        return Err(invalid_callback_domain());
142    }
143
144    let normalized = raw.to_ascii_lowercase();
145    let labels = normalized.split('.').collect::<Vec<_>>();
146    let forbidden_suffix = normalized == "localhost"
147        || normalized.ends_with(".localhost")
148        || normalized.ends_with(".local")
149        || normalized.ends_with(".internal");
150    let labels_valid = labels.len() >= 2
151        && labels.iter().all(|label| {
152            !label.is_empty()
153                && label.len() <= 63
154                && !label.starts_with('-')
155                && !label.ends_with('-')
156                && label
157                    .bytes()
158                    .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
159        });
160    if forbidden_suffix || !labels_valid || normalized.parse::<std::net::IpAddr>().is_ok() {
161        return Err(invalid_callback_domain());
162    }
163    Ok(normalized)
164}
165
166fn invalid_callback_domain() -> ScannerError {
167    ScannerError::PayloadGenerationError(
168        "SSRF OOB callback configuration is not an eligible DNS domain".to_owned(),
169    )
170}
171
172fn replace_query_parameter(endpoint: &Url, name: &str, value: &str) -> Url {
173    let retained = endpoint
174        .query_pairs()
175        .filter(|(existing, _)| existing != name)
176        .map(|(existing, value)| (existing.into_owned(), value.into_owned()))
177        .collect::<Vec<_>>();
178    let mut mutated = endpoint.clone();
179    mutated.set_query(None);
180    mutated
181        .query_pairs_mut()
182        .extend_pairs(retained)
183        .append_pair(name, value);
184    mutated
185}
186
187fn probe_receipt(ctx: &ScanContext, nonce: &str, status: u16) -> Result<Evidence, ScannerError> {
188    let subject = ctx.legacy_verification_subject()?;
189    let predicate =
190        KnowledgePredicate::new(SSRF_PROBE_PREDICATE_NAMESPACE, SSRF_PROBE_PREDICATE_NAME)
191            .map_err(|_| receipt_error())?;
192    let source = EvidenceSource::new(SSRF_PROBE_ACTION_ID, "bounded-oob-probe")
193        .and_then(|source| source.with_correlation_id(nonce))
194        .map_err(|_| receipt_error())?;
195    let evidence_id = EvidenceId::parse(format!("evidence:legacy.ssrf.probe-receipt:{nonce}"))
196        .map_err(|_| receipt_error())?;
197    Ok(Evidence::with_id_at(
198        evidence_id,
199        subject,
200        EvidenceKind::Http,
201        predicate,
202        EvidenceValue::Unsigned(u64::from(status)),
203        source,
204        ConfidenceScore::MAX,
205        0,
206    ))
207}
208
209fn receipt_error() -> ScannerError {
210    ScannerError::PayloadGenerationError(
211        "failed to record a typed SSRF OOB probe receipt".to_owned(),
212    )
213}
214
215#[cfg(test)]
216mod tests {
217    use std::net::Ipv4Addr;
218    use std::sync::{Arc, Mutex};
219    use std::time::Duration;
220
221    use tokio::{
222        io::{AsyncReadExt, AsyncWriteExt},
223        net::TcpListener,
224        task::JoinHandle,
225    };
226
227    use super::*;
228    use crate::legacy_discovery::VerificationLimits;
229
230    struct LocalFixture {
231        target: Url,
232        requests: Arc<Mutex<Vec<String>>>,
233        task: JoinHandle<()>,
234    }
235
236    impl Drop for LocalFixture {
237        fn drop(&mut self) {
238            self.task.abort();
239        }
240    }
241
242    async fn serve_fixture(status: u16, max_requests: usize) -> LocalFixture {
243        let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await.unwrap();
244        let address = listener.local_addr().unwrap();
245        let requests = Arc::new(Mutex::new(Vec::new()));
246        let observed = Arc::clone(&requests);
247        let task = tokio::spawn(async move {
248            for _ in 0..max_requests {
249                let (mut stream, _) = listener.accept().await.unwrap();
250                let mut request = Vec::new();
251                loop {
252                    let mut chunk = [0_u8; 1_024];
253                    let read = stream.read(&mut chunk).await.unwrap();
254                    if read == 0 {
255                        break;
256                    }
257                    request.extend_from_slice(&chunk[..read]);
258                    if request.windows(4).any(|window| window == b"\r\n\r\n") {
259                        break;
260                    }
261                }
262                let request = String::from_utf8_lossy(&request).into_owned();
263                observed.lock().unwrap().push(request);
264                let reason = match status {
265                    200 => "OK",
266                    401 => "Unauthorized",
267                    403 => "Forbidden",
268                    _ => "Response",
269                };
270                let response = format!(
271                    "HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
272                );
273                stream.write_all(response.as_bytes()).await.unwrap();
274            }
275        });
276        LocalFixture {
277            target: Url::parse(&format!("http://{address}/fetch?url=seed")).unwrap(),
278            requests,
279            task,
280        }
281    }
282
283    fn context(target: Url, max_requests: u32) -> ScanContext {
284        let (telemetry, _) = tokio::sync::mpsc::unbounded_channel();
285        let limits = VerificationLimits::new()
286            .with_max_requests(max_requests)
287            .unwrap()
288            .with_request_timeout(Duration::from_secs(2))
289            .unwrap();
290        ScanContext::new_with_verification_limits(target, reqwest::Client::new(), telemetry, limits)
291    }
292
293    fn probe_receipts(ctx: &ScanContext) -> Vec<Evidence> {
294        let predicate =
295            KnowledgePredicate::new(SSRF_PROBE_PREDICATE_NAMESPACE, SSRF_PROBE_PREDICATE_NAME)
296                .unwrap();
297        ctx.knowledge().evidence_for_predicate(&predicate)
298    }
299
300    #[test]
301    fn stable_phase_identity_is_preserved() {
302        let scanner = SsrfScanner::new();
303        assert_eq!(scanner.phase_number(), 9);
304        assert_eq!(scanner.name(), "SSRF Callback Probe Recorder");
305    }
306
307    #[test]
308    fn default_profile_has_no_probe_destination() {
309        assert!(SsrfScanner::default().oob_domain.is_none());
310    }
311
312    #[test]
313    fn callback_domain_validation_is_fail_closed() {
314        for invalid in [
315            "",
316            "localhost",
317            "callback.local",
318            "callback.internal",
319            "192.0.2.1",
320            "http://callback.example",
321            "callback.example/path",
322            " callback.example",
323            "callback.example.",
324            "-callback.example",
325        ] {
326            assert!(
327                validate_callback_domain(invalid).is_err(),
328                "unsafe callback domain was accepted: {invalid}"
329            );
330        }
331        assert_eq!(
332            validate_callback_domain("OOB.Example").unwrap(),
333            "oob.example"
334        );
335    }
336
337    #[test]
338    fn debug_output_redacts_callback_domain() {
339        let scanner = SsrfScanner::with_oob_domain("tenant.callbacks.invalid".to_owned());
340        let debug = format!("{scanner:?}");
341
342        assert!(debug.contains("oob_domain_configured: true"));
343        assert!(!debug.contains("tenant"));
344        assert!(!debug.contains("callbacks.invalid"));
345    }
346
347    #[test]
348    fn query_mutation_replaces_only_the_selected_parameter() {
349        let endpoint =
350            Url::parse("https://example.test/fetch?mode=safe&url=old&url=older").unwrap();
351        let mutated = replace_query_parameter(&endpoint, "url", "http://nonce.oob.example/");
352        assert_eq!(
353            mutated.as_str(),
354            "https://example.test/fetch?mode=safe&url=http%3A%2F%2Fnonce.oob.example%2F"
355        );
356    }
357
358    #[tokio::test]
359    async fn no_configuration_dispatches_nothing() {
360        let fixture = serve_fixture(200, 1).await;
361        let ctx = context(fixture.target.clone(), 1);
362
363        let findings = SsrfScanner::new().execute(&ctx).await.unwrap();
364
365        assert!(findings.is_empty());
366        assert!(probe_receipts(&ctx).is_empty());
367        assert!(fixture.requests.lock().unwrap().is_empty());
368    }
369
370    #[tokio::test]
371    async fn successful_delivery_is_typed_probe_evidence_with_an_empty_result() {
372        let fixture = serve_fixture(200, 1).await;
373        let ctx = context(fixture.target.clone(), 1);
374
375        let findings = SsrfScanner::with_oob_domain("callbacks.invalid".to_owned())
376            .execute(&ctx)
377            .await
378            .unwrap();
379
380        assert!(findings.is_empty());
381        let receipts = probe_receipts(&ctx);
382        assert_eq!(receipts.len(), 1);
383        assert_eq!(receipts[0].value(), &EvidenceValue::Unsigned(200));
384        let nonce = receipts[0].source().correlation_id().unwrap();
385        assert_eq!(nonce.len(), 32);
386        assert_eq!(receipts[0].observed_at_ms(), 0);
387        assert_eq!(
388            receipts[0].subject(),
389            &ctx.legacy_verification_subject().unwrap()
390        );
391        assert_eq!(
392            receipts[0].id().as_str(),
393            format!("evidence:legacy.ssrf.probe-receipt:{nonce}")
394        );
395        let requests = fixture.requests.lock().unwrap();
396        assert_eq!(requests.len(), 1);
397        assert!(requests[0].contains("callbacks.invalid"));
398        assert!(requests[0].contains(nonce));
399    }
400
401    #[tokio::test]
402    async fn authorization_statuses_never_imply_internal_reachability() {
403        for status in [401, 403] {
404            let fixture = serve_fixture(status, 1).await;
405            let ctx = context(fixture.target.clone(), 1);
406
407            let findings = SsrfScanner::with_oob_domain("callbacks.invalid".to_owned())
408                .execute(&ctx)
409                .await
410                .unwrap();
411
412            assert!(findings.is_empty());
413            let receipts = probe_receipts(&ctx);
414            assert_eq!(receipts.len(), 1);
415            assert_eq!(
416                receipts[0].value(),
417                &EvidenceValue::Unsigned(u64::from(status))
418            );
419        }
420    }
421
422    #[tokio::test]
423    async fn invalid_callback_configuration_fails_before_dispatch() {
424        let fixture = serve_fixture(200, 1).await;
425        let ctx = context(fixture.target.clone(), 1);
426
427        let error = SsrfScanner::with_oob_domain("192.0.2.1".to_owned())
428            .execute(&ctx)
429            .await
430            .unwrap_err();
431
432        assert!(matches!(error, ScannerError::PayloadGenerationError(_)));
433        assert!(fixture.requests.lock().unwrap().is_empty());
434        assert!(probe_receipts(&ctx).is_empty());
435    }
436
437    #[tokio::test]
438    async fn shared_request_budget_denial_creates_no_receipt() {
439        let fixture = serve_fixture(200, 1).await;
440        let ctx = context(fixture.target.clone(), 0);
441
442        let error = SsrfScanner::with_oob_domain("callbacks.invalid".to_owned())
443            .execute(&ctx)
444            .await
445            .unwrap_err();
446
447        assert!(matches!(error, ScannerError::BudgetExceeded(_)));
448        assert!(fixture.requests.lock().unwrap().is_empty());
449        assert!(probe_receipts(&ctx).is_empty());
450    }
451}