1use 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
34pub 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 pub const fn new() -> Self {
56 Self { oob_domain: None }
57 }
58
59 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}