1use crate::saas::types::VerificationResult;
6use crate::saas::validation::is_forbidden_address;
7use hickory_resolver::config::ResolverConfig;
8use hickory_resolver::net::runtime::TokioRuntimeProvider;
9use hickory_resolver::TokioResolver;
10use std::net::{IpAddr, SocketAddr};
11use std::time::Duration;
12
13const MAX_BODY: usize = 1024;
16
17const COULD_NOT_FETCH: &str = "Could not fetch the verification file from the domain over \
24 HTTPS or HTTP. Check that it is served at the URL in the instructions, with status 200 \
25 and no redirect.";
26
27#[derive(Debug)]
29enum FetchError {
30 Request(reqwest::Error),
32 Status(reqwest::StatusCode),
34 TooLarge,
36}
37
38impl std::fmt::Display for FetchError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 FetchError::Request(e) => write!(f, "request failed: {e}"),
42 FetchError::Status(status) => write!(f, "status {status}"),
43 FetchError::TooLarge => write!(f, "response larger than {MAX_BODY} bytes"),
44 }
45 }
46}
47
48fn txt_matches(rendered: &str, expected: &str) -> bool {
56 if rendered == expected {
57 return true;
58 }
59 if rendered.trim().trim_matches('"').trim() == expected {
61 return true;
62 }
63 let joined: String = rendered
66 .split_whitespace()
67 .map(|part| part.trim_matches('"'))
68 .collect();
69 joined == expected
70}
71
72pub struct DomainVerificationService {
74 dns_resolver: TokioResolver,
75}
76
77impl DomainVerificationService {
78 pub fn new() -> Self {
80 let dns_resolver = TokioResolver::builder_with_config(
87 ResolverConfig::default(),
88 TokioRuntimeProvider::default(),
89 )
90 .build()
91 .expect("Failed to create DNS resolver");
92
93 Self { dns_resolver }
94 }
95
96 pub async fn verify_dns(&self, domain: &str, token: &str) -> VerificationResult {
101 let record_name = format!("_postrust-verification.{}", domain);
102 let expected_value = format!("postrust-verify={}", token);
103
104 tracing::debug!(
105 record_name = %record_name,
106 "Performing DNS TXT verification"
107 );
108
109 match self.dns_resolver.txt_lookup(&record_name).await {
111 Ok(response) => {
112 for record in response.answers() {
118 let txt_data = record.data.to_string();
119 tracing::debug!(txt_value = %txt_data, "Found TXT record");
120
121 if txt_matches(&txt_data, &expected_value) {
122 tracing::info!(domain = %domain, "DNS verification successful");
123 return VerificationResult::Verified;
124 }
125 }
126
127 tracing::warn!(
128 domain = %domain,
129 expected = %expected_value,
130 "DNS TXT record found but value mismatch"
131 );
132 VerificationResult::Failed {
133 reason: "DNS TXT record found but value does not match".into(),
134 }
135 }
136 Err(e) => {
137 tracing::warn!(
138 domain = %domain,
139 error = %e,
140 "DNS lookup failed"
141 );
142
143 let reason = if e.is_no_records_found() {
150 format!(
151 "No TXT record found at {}. Please create a TXT record with value: {}",
152 record_name, expected_value
153 )
154 } else if matches!(e, hickory_resolver::net::NetError::Timeout) {
155 "DNS lookup timed out. Please try again later.".to_string()
156 } else {
157 "DNS lookup failed. Please try again later.".to_string()
161 };
162
163 VerificationResult::Failed { reason }
164 }
165 }
166 }
167
168 pub async fn verify_http(&self, domain: &str, token: &str) -> VerificationResult {
190 let address = match self.resolve_public(domain).await {
191 Ok(address) => address,
192 Err(reason) => return VerificationResult::Failed { reason },
193 };
194 check_http_at(domain, token, SocketAddr::new(address, 0)).await
195 }
196
197 async fn resolve_public(&self, domain: &str) -> Result<IpAddr, String> {
199 let addresses: Vec<IpAddr> = match domain.parse::<IpAddr>() {
200 Ok(ip) => vec![ip],
201 Err(_) => match self.dns_resolver.lookup_ip(domain).await {
202 Ok(lookup) => lookup.iter().collect(),
203 Err(error) => {
204 tracing::warn!(%domain, %error, "could not resolve a domain for HTTP verification");
205 return Err("Could not resolve the domain. Check its A/AAAA records.".into());
206 }
207 },
208 };
209
210 match addresses
211 .iter()
212 .copied()
213 .find(|ip| !is_forbidden_address(*ip))
214 {
215 Some(ip) => Ok(ip),
216 None => {
217 tracing::warn!(
218 %domain,
219 ?addresses,
220 "refused HTTP verification: the domain resolves only to non-public addresses"
221 );
222 Err("The domain does not resolve to a public address.".into())
223 }
224 }
225 }
226}
227
228async fn check_http_at(domain: &str, token: &str, target: SocketAddr) -> VerificationResult {
234 let expected_content = format!("postrust-verify={}", token);
235
236 let client = match reqwest::Client::builder()
237 .timeout(Duration::from_secs(10))
238 .connect_timeout(Duration::from_secs(5))
239 .redirect(reqwest::redirect::Policy::none())
240 .no_proxy()
243 .resolve(domain, target)
244 .user_agent("PostrustProxy/1.0 DomainVerification")
245 .build()
246 {
247 Ok(client) => client,
248 Err(error) => {
249 tracing::error!(%error, "could not build the verification HTTP client");
250 return VerificationResult::Failed {
251 reason: "Verification could not be attempted. Please try again later.".into(),
252 };
253 }
254 };
255
256 let mut errors = Vec::new();
260 for scheme in ["https", "http"] {
261 let url = format!("{scheme}://{domain}/.well-known/postrust-verification/{token}");
262 match fetch_capped(&client, &url).await {
263 Ok(body) => {
264 let content = String::from_utf8_lossy(&body);
265 if content.trim() == expected_content {
266 tracing::info!(%domain, %scheme, "HTTP verification successful");
267 return VerificationResult::Verified;
268 }
269 tracing::warn!(%domain, %scheme, "HTTP verification content mismatch");
270 return VerificationResult::Failed {
271 reason: "The verification file was fetched, but its content did not match."
272 .into(),
273 };
274 }
275 Err(error) => errors.push(format!("{scheme}: {error}")),
276 }
277 }
278
279 tracing::warn!(%domain, errors = ?errors, "HTTP verification could not fetch the challenge");
280 VerificationResult::Failed {
281 reason: COULD_NOT_FETCH.into(),
282 }
283}
284
285async fn fetch_capped(client: &reqwest::Client, url: &str) -> Result<Vec<u8>, FetchError> {
291 let mut response = client.get(url).send().await.map_err(FetchError::Request)?;
292
293 if response.status() != reqwest::StatusCode::OK {
294 return Err(FetchError::Status(response.status()));
295 }
296 if response
297 .content_length()
298 .is_some_and(|len| len > MAX_BODY as u64)
299 {
300 return Err(FetchError::TooLarge);
301 }
302
303 let mut body = Vec::new();
304 while let Some(chunk) = response.chunk().await.map_err(FetchError::Request)? {
305 if body.len() + chunk.len() > MAX_BODY {
306 return Err(FetchError::TooLarge);
307 }
308 body.extend_from_slice(&chunk);
309 }
310 Ok(body)
311}
312
313impl Default for DomainVerificationService {
314 fn default() -> Self {
315 Self::new()
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
330 fn a_quoted_txt_value_matches() {
331 assert!(txt_matches(
332 "\"postrust-verify=abc123\"",
333 "postrust-verify=abc123"
334 ));
335 }
336
337 #[test]
338 fn an_unquoted_txt_value_matches() {
339 assert!(txt_matches(
340 "postrust-verify=abc123",
341 "postrust-verify=abc123"
342 ));
343 }
344
345 #[test]
346 fn surrounding_whitespace_does_not_break_it() {
347 assert!(txt_matches(
348 " \"postrust-verify=abc123\" ",
349 "postrust-verify=abc123"
350 ));
351 }
352
353 #[test]
354 fn a_value_split_across_character_strings_matches() {
355 assert!(txt_matches(
359 "\"postrust-verify=\" \"abc123\"",
360 "postrust-verify=abc123"
361 ));
362 }
363
364 #[test]
365 fn a_different_value_does_not_match() {
366 assert!(!txt_matches(
367 "\"postrust-verify=wrong\"",
368 "postrust-verify=abc123"
369 ));
370 assert!(!txt_matches("\"\"", "postrust-verify=abc123"));
371 assert!(!txt_matches("", "postrust-verify=abc123"));
372 }
373
374 #[test]
375 fn a_whole_record_line_does_not_match() {
376 assert!(!txt_matches(
379 "_postrust-verification.example.com. 300 IN TXT \"postrust-verify=abc123\"",
380 "postrust-verify=abc123"
381 ));
382 }
383
384 #[tokio::test]
385 async fn test_dns_verification_not_found() {
386 let service = DomainVerificationService::new();
387
388 let result = service
390 .verify_dns("definitely-not-a-real-domain-12345.invalid", "testtoken123")
391 .await;
392
393 match result {
394 VerificationResult::Failed { reason } => {
395 assert!(reason.contains("No TXT record") || reason.contains("DNS lookup failed"));
396 }
397 _ => panic!("Expected verification to fail for non-existent domain"),
398 }
399 }
400
401 #[tokio::test]
402 async fn test_http_verification_not_found() {
403 let service = DomainVerificationService::new();
404
405 let result = service.verify_http("example.com", "testtoken123").await;
407
408 match result {
409 VerificationResult::Failed { .. } => {
410 }
412 _ => panic!("Expected verification to fail"),
413 }
414 }
415
416 fn reason(result: VerificationResult) -> String {
417 match result {
418 VerificationResult::Failed { reason } => reason,
419 other => panic!("expected a failure, got {other:?}"),
420 }
421 }
422
423 #[tokio::test]
424 async fn loopback_and_metadata_addresses_are_never_fetched() {
425 let service = DomainVerificationService::new();
426 for target in [
429 "127.0.0.1",
430 "169.254.169.254",
431 "::1",
432 "10.0.0.1",
433 "localhost",
434 ] {
435 let reason = reason(service.verify_http(target, "tok").await);
436 assert!(
437 reason.contains("public address"),
438 "{target}: unexpected reason {reason:?}"
439 );
440 }
441 }
442
443 async fn serve(
446 app: axum::Router,
447 ) -> (SocketAddr, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
448 use std::sync::atomic::{AtomicUsize, Ordering};
449 let landed = std::sync::Arc::new(AtomicUsize::new(0));
450 let counter = landed.clone();
451 let app = app.route(
452 "/landed",
453 axum::routing::get(move || {
454 counter.fetch_add(1, Ordering::SeqCst);
455 async { "postrust-verify=tok" }
456 }),
457 );
458 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
459 let addr = listener.local_addr().unwrap();
460 tokio::spawn(async move { axum::serve(listener, app).await.ok() });
461 (addr, landed)
462 }
463
464 const PATH: &str = "/.well-known/postrust-verification/tok";
465
466 #[tokio::test]
467 async fn the_right_content_verifies() {
468 let app = axum::Router::new().route(
469 PATH,
470 axum::routing::get(|| async { "postrust-verify=tok\n" }),
471 );
472 let (addr, _) = serve(app).await;
473 assert!(matches!(
474 check_http_at("victim.test", "tok", addr).await,
475 VerificationResult::Verified
476 ));
477 }
478
479 #[tokio::test]
480 async fn redirects_are_not_followed() {
481 use std::sync::atomic::Ordering;
482 let app = axum::Router::new().route(
483 PATH,
484 axum::routing::get(|| async { axum::response::Redirect::temporary("/landed") }),
485 );
486 let (addr, landed) = serve(app).await;
487
488 let reason = reason(check_http_at("victim.test", "tok", addr).await);
489 assert_eq!(reason, COULD_NOT_FETCH);
490 assert_eq!(
491 landed.load(Ordering::SeqCst),
492 0,
493 "the redirect target was fetched"
494 );
495 }
496
497 #[tokio::test]
498 async fn the_fetched_body_is_never_echoed() {
499 let app = axum::Router::new().route(
500 PATH,
501 axum::routing::get(|| async { "SECRET-INTERNAL-RESPONSE" }),
502 );
503 let (addr, _) = serve(app).await;
504
505 let reason = reason(check_http_at("victim.test", "tok", addr).await);
506 assert!(!reason.contains("SECRET"), "{reason}");
507 assert!(reason.contains("did not match"), "{reason}");
508 }
509
510 #[tokio::test]
511 async fn an_oversized_body_is_refused_without_a_content_length() {
512 let app = axum::Router::new().route(
515 PATH,
516 axum::routing::get(|| async {
517 let chunks = futures::stream::iter(
518 (0..64).map(|_| Ok::<_, std::io::Error>(bytes::Bytes::from(vec![b'a'; 1024]))),
519 );
520 axum::body::Body::from_stream(chunks)
521 }),
522 );
523 let (addr, _) = serve(app).await;
524
525 let client = reqwest::Client::builder()
526 .no_proxy()
527 .resolve("victim.test", addr)
528 .build()
529 .unwrap();
530 let fetched = fetch_capped(&client, &format!("http://victim.test{PATH}")).await;
531 assert!(matches!(fetched, Err(FetchError::TooLarge)), "{fetched:?}");
532 }
533
534 #[tokio::test]
535 async fn connection_errors_are_not_echoed() {
536 let addr = {
538 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
539 listener.local_addr().unwrap()
540 };
541 let reason = reason(check_http_at("victim.test", "tok", addr).await);
542 assert_eq!(reason, COULD_NOT_FETCH);
543 }
544}