Skip to main content

postrust_proxy/saas/
verification.rs

1//! Domain verification service.
2//!
3//! Provides DNS TXT and HTTP challenge verification for domain ownership.
4
5use 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
13/// The most of a verification response that is read. The expected content is
14/// about fifty bytes.
15const MAX_BODY: usize = 1024;
16
17/// What a tenant is told when the HTTP check could not get an answer.
18///
19/// Fixed, and deliberately unspecific. The underlying error -- a TLS failure,
20/// a refused connection, a timeout, a status code -- is logged for the
21/// operator. Returned to the tenant, those were a way to probe what answers
22/// where from the proxy's vantage point.
23const 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/// Why a fetch did not produce a body. For the log only.
28#[derive(Debug)]
29enum FetchError {
30    /// The request could not be made or did not complete.
31    Request(reqwest::Error),
32    /// Anything but 200, redirects included: they are not followed.
33    Status(reqwest::StatusCode),
34    /// More than [`MAX_BODY`].
35    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
48/// Whether a TXT record's rendered rdata carries the expected challenge value.
49///
50/// Worth its own function because the rendering is not the raw value. A TXT
51/// record's rdata renders quoted (`"postrust-verify=abc"`), and a record with
52/// several character strings renders them space-separated and each quoted, so
53/// comparing the rendered form directly against a bare token fails on a record
54/// that is in fact correct.
55fn txt_matches(rendered: &str, expected: &str) -> bool {
56    if rendered == expected {
57        return true;
58    }
59    // A single quoted string, which is the ordinary case.
60    if rendered.trim().trim_matches('"').trim() == expected {
61        return true;
62    }
63    // A long value is split into several 255-byte character strings, each
64    // rendered quoted. DNS defines the value as their concatenation.
65    let joined: String = rendered
66        .split_whitespace()
67        .map(|part| part.trim_matches('"'))
68        .collect();
69    joined == expected
70}
71
72/// Domain verification service for DNS and HTTP challenges.
73pub struct DomainVerificationService {
74    dns_resolver: TokioResolver,
75}
76
77impl DomainVerificationService {
78    /// Create a new domain verification service.
79    pub fn new() -> Self {
80        // Create DNS resolver with the default config.
81        //
82        // `builder_with_config` rather than `builder`: the latter reads
83        // /etc/resolv.conf, and a verification service should not refuse to
84        // construct because the host's resolver file is unreadable. The
85        // default config is what this used before hickory 0.26.
86        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    /// Verify domain ownership via DNS TXT record.
97    ///
98    /// The user must create a TXT record at `_postrust-verification.{domain}`
99    /// with the value `postrust-verify={token}`.
100    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        // Perform DNS TXT lookup
110        match self.dns_resolver.txt_lookup(&record_name).await {
111            Ok(response) => {
112                // `answers()`, and `record.data()` rather than `record` --
113                // hickory 0.26 replaced the rdata iterator with a record
114                // slice, and `Record`'s Display renders the whole line
115                // (`name ttl class type rdata`), which would never match a
116                // bare token. Getting this wrong fails every verification.
117                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                // Provide helpful error messages based on error type.
144                //
145                // hickory 0.26 moved the taxonomy: what was
146                // `ResolveErrorKind::NoRecordsFound` is now reached through
147                // `NetError::is_no_records_found`, and `Timeout` is a variant
148                // of `NetError` rather than of the resolver's own error.
149                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                    // The resolver's own error text is logged above, not
158                    // returned: it describes this proxy's resolver, not the
159                    // tenant's record.
160                    "DNS lookup failed. Please try again later.".to_string()
161                };
162
163                VerificationResult::Failed { reason }
164            }
165        }
166    }
167
168    /// Verify domain ownership via HTTP challenge.
169    ///
170    /// The user must serve a file at `https://{domain}/.well-known/postrust-verification/{token}`
171    /// (or the `http://` equivalent) with the content `postrust-verify={token}`.
172    ///
173    /// This makes the proxy fetch a URL on a name the tenant chose, so it is
174    /// careful about where it connects:
175    ///
176    /// - the name is resolved here, every address in a [forbidden
177    ///   range](is_forbidden_address) is discarded, and the request is pinned
178    ///   to an address that survived. Pinned, because letting the HTTP client
179    ///   resolve again would let a short-TTL record answer differently the
180    ///   second time -- public for the check, `169.254.169.254` for the
181    ///   connection.
182    /// - redirects are not followed. A redirect is a second URL, on a host
183    ///   nobody checked.
184    /// - at most [`MAX_BODY`] bytes are read, whatever `Content-Length` says
185    ///   or does not say.
186    /// - what the tenant is told is a fixed sentence. Neither the fetched body
187    ///   nor the client's error text is returned: either would turn this into
188    ///   a way to read responses from wherever the proxy can reach.
189    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    /// The first public address `domain` resolves to.
198    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
228/// Fetch the challenge from `target`, as `domain`, and compare it.
229///
230/// `target` has already been checked; a port of 0 means the scheme's own. A
231/// separate function so tests can point it at a local server, which the
232/// address check would otherwise -- correctly -- refuse.
233async 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        // A configured HTTP proxy would make its own connection, to wherever
241        // it resolved the name, and the pinning would be for nothing.
242        .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    // HTTPS first; plain HTTP for a domain that has no certificate yet. A
257    // response that arrives but does not match is final -- only a failure to
258    // get one falls through to the next scheme.
259    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
285/// GET `url` and read at most [`MAX_BODY`] bytes of a 200 response.
286///
287/// Streamed, with the cap applied to what arrives: `Content-Length` is
288/// checked first as a shortcut, but a chunked response has none, and one that
289/// lies would otherwise be read to the end.
290async 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    // These cover the comparison that the hickory 0.26 migration moved. The
324    // old code iterated rdata and stringified it; the new code takes
325    // `record.data` off a `Record`, because `Record`'s own Display renders
326    // `name ttl class type rdata` and would never match a bare token. A
327    // regression here fails every DNS verification silently.
328
329    #[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        // A TXT value over 255 bytes is stored as several character strings
356        // and renders as several quoted parts; DNS defines the value as their
357        // concatenation.
358        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        // Exactly what `record.to_string()` would have produced. If this ever
377        // starts passing, the comparison has been loosened too far.
378        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        // Use a domain that definitely won't have our verification record
389        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        // Use a domain that won't have our verification file
406        let result = service.verify_http("example.com", "testtoken123").await;
407
408        match result {
409            VerificationResult::Failed { .. } => {
410                // Expected to fail
411            }
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        // IP literals, and `localhost`, which the resolver answers itself
427        // without asking the network (RFC 6761).
428        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    /// A local server for the HTTP checks, with whatever routes the test
444    /// gives it, and a counter of requests to `/landed`.
445    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        // Chunked, so there is no Content-Length to check up front: the cap
513        // has to be applied to what is actually read.
514        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        // Nothing listens here: bind, note the port, close.
537        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}