Skip to main content

acme_proxy/challenge/
http_01.rs

1//! The `http-01` challenge (RFC 8555 §8.3): a file served at
2//! `http://<identifier>/.well-known/acme-challenge/<token>` whose body is the
3//! key authorization.
4//!
5//! ## Redirects, and the SSRF surface they open
6//!
7//! Redirecting port 80 to https is the single most common web-server
8//! configuration, and RFC 8555 §8.3 explicitly permits following it — saying, in
9//! the same breath, that the responder's certificate is *not* validated. Boulder,
10//! Pebble and step-ca all follow. So does this validator, by default.
11//!
12//! The cost is real: an account holder can make this server issue GET requests
13//! to wherever it points a `Location` header, and Boulder's mitigation — refusing
14//! RFC1918 destinations — is unavailable here, because serving private networks
15//! is the entire purpose of this server. What keeps it a nuisance rather than an
16//! exfiltration channel is a set of rules that must not be relaxed casually:
17//!
18//! - only `http` and `https`, only the two configured ports;
19//! - at most `max_redirects` hops, and `follow_redirects` turns it off entirely;
20//! - the whole chain shares the registry's one timeout;
21//! - **the fetched body is never echoed into the client-visible error**. The
22//!   client is told the status and the length, nothing more. A truncated preview
23//!   is logged at `debug`, server-side.
24//!
25//! Operators who want the request itself not to happen have
26//! [`filter.allowed_ip`](crate::filter::ip_allow) and
27//! [`filter.identifiers`](crate::filter::identifiers).
28
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use bytes::Bytes;
33use http_body_util::{BodyExt, Empty, Limited};
34use hyper::Request;
35use tracing::{debug, info, warn};
36use url::Url;
37
38use super::{ChallengeError, ChallengeValidator, HTTP_01, ValidationContext};
39use crate::config::Http01Config;
40
41/// The well-known path RFC 8555 §8.3 reserves for this challenge.
42///
43/// Shared with the *other* direction: the `relay` signer's
44/// [`http01`](crate::signer::relay::http01) strategy answers an upstream
45/// CA's own challenge, and the route it is served from is built from this
46/// constant. Same rule the `dns-01` pair follows, where
47/// [`super::dns_01::record_name`] and [`super::dns_01::expected_value`] are
48/// called by the publisher rather than restated — a validator and a responder
49/// that disagree about the convention would fail in the least legible way.
50pub(crate) const WELL_KNOWN_PREFIX: &str = "/.well-known/acme-challenge/";
51
52/// What one HTTP request returned.
53#[derive(Debug)]
54pub struct HttpResponse {
55    pub status: u16,
56    /// The `Location` header, if any. The redirect *policy* is the validator's,
57    /// not the fetcher's.
58    pub location: Option<String>,
59    pub body: Vec<u8>,
60    /// The body hit the caller's cap and was cut short. A truncated body is
61    /// refused without being compared — the key authorization is under a hundred
62    /// bytes and nothing else belongs at that URL.
63    pub truncated: bool,
64}
65
66/// Why a single fetch produced no response.
67#[derive(Debug)]
68pub enum FetchError {
69    /// Nothing answered: DNS, TCP connect, TLS, the socket dying.
70    Connect(String),
71    /// Something answered, but not with usable HTTP.
72    Protocol(String),
73}
74
75/// A single HTTP GET.
76///
77/// Deliberately **does not follow redirects**: that policy lives in
78/// [`Http01Validator`], where a stub can drive every branch of it without a
79/// listener.
80#[async_trait]
81pub trait HttpFetcher: Send + Sync {
82    async fn get(&self, url: &Url, max_bytes: usize) -> Result<HttpResponse, FetchError>;
83}
84
85/// Fetches the challenge file and compares it to the key authorization.
86pub struct Http01Validator {
87    fetcher: Arc<dyn HttpFetcher>,
88    port: u16,
89    https_port: u16,
90    follow_redirects: bool,
91    max_redirects: u8,
92    max_response_bytes: usize,
93}
94
95impl std::fmt::Debug for Http01Validator {
96    /// `dyn HttpFetcher` is not `Debug`; the policy is what matters.
97    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        formatter
99            .debug_struct("Http01Validator")
100            .field("port", &self.port)
101            .field("https_port", &self.https_port)
102            .field("follow_redirects", &self.follow_redirects)
103            .field("max_redirects", &self.max_redirects)
104            .field("max_response_bytes", &self.max_response_bytes)
105            .finish_non_exhaustive()
106    }
107}
108
109impl Http01Validator {
110    /// Builds the validator with a real HTTP client, resolving its connect
111    /// target through `resolver` — the same one `challenge::from_config`
112    /// selected for `dns-01` (`dns.resolver` if set, else the system
113    /// configuration), so a deployment overriding it gets one consistent
114    /// answer for every challenge type rather than `dns-01` alone.
115    pub fn from_config(
116        cfg: &Http01Config,
117        outbound: crate::http_client::Outbound,
118    ) -> anyhow::Result<Self> {
119        let fetcher = Arc::new(
120            HyperFetcher::new(outbound)
121                .map_err(|error| anyhow::anyhow!("challenge.http_01: {error}"))?,
122        );
123        info!(
124            event = "challenge_http_01_loaded",
125            outcome = "success",
126            port = cfg.port,
127            follow_redirects = cfg.follow_redirects,
128        );
129        Ok(Self::with_fetcher(cfg, fetcher))
130    }
131
132    /// Same, against a caller-supplied fetcher. Used by tests.
133    pub fn with_fetcher(cfg: &Http01Config, fetcher: Arc<dyn HttpFetcher>) -> Self {
134        Self {
135            fetcher,
136            port: cfg.port,
137            https_port: cfg.https_port,
138            follow_redirects: cfg.follow_redirects,
139            max_redirects: cfg.max_redirects,
140            max_response_bytes: cfg.max_response_bytes,
141        }
142    }
143
144    /// The URL the challenge file must be served at.
145    fn challenge_url(&self, identifier: &str, token: &str) -> Result<Url, ChallengeError> {
146        let authority = if self.port == 80 {
147            identifier.to_string()
148        } else {
149            format!("{identifier}:{}", self.port)
150        };
151        Url::parse(&format!("http://{authority}{WELL_KNOWN_PREFIX}{token}")).map_err(|error| {
152            ChallengeError::Internal(format!(
153                "building the challenge URL for {identifier}: {error}"
154            ))
155        })
156    }
157
158    /// Whether a redirect target is somewhere this validator will follow.
159    ///
160    /// Without this a `Location: file:///etc/passwd` or
161    /// `http://10.0.0.5:9200/` would turn the validator into a file reader and a
162    /// port scanner respectively.
163    fn redirect_allowed(&self, target: &Url) -> Result<(), ChallengeError> {
164        let (scheme, expected_port) = match target.scheme() {
165            "http" => ("http", self.port),
166            "https" => ("https", self.https_port),
167            other => {
168                return Err(ChallengeError::Unauthorized(format!(
169                    "redirect to unsupported scheme {other}"
170                )));
171            }
172        };
173
174        let default_port = if scheme == "http" { 80 } else { 443 };
175        let port = target.port().unwrap_or(default_port);
176        if port != expected_port {
177            return Err(ChallengeError::Unauthorized(format!(
178                "redirect to {scheme} port {port}, which is not the configured {expected_port}"
179            )));
180        }
181        Ok(())
182    }
183}
184
185#[async_trait]
186impl ChallengeValidator for Http01Validator {
187    fn typ(&self) -> &'static str {
188        HTTP_01
189    }
190
191    async fn validate(&self, ctx: &ValidationContext<'_>) -> Result<(), ChallengeError> {
192        let mut url = self.challenge_url(ctx.identifier, ctx.token)?;
193
194        for hop in 0..=self.max_redirects {
195            let response = self
196                .fetcher
197                .get(&url, self.max_response_bytes)
198                .await
199                .map_err(|error| match error {
200                    FetchError::Connect(detail) => ChallengeError::Connection(detail),
201                    // A reply that is not HTTP still means something answered,
202                    // but the client's setup is what is wrong.
203                    FetchError::Protocol(detail) => ChallengeError::Connection(detail),
204                })?;
205
206            if let (true, Some(location)) = (is_redirect(response.status), &response.location) {
207                if !self.follow_redirects {
208                    return Err(ChallengeError::Unauthorized(format!(
209                        "{url} redirected but following redirects is disabled"
210                    )));
211                }
212                let target = url.join(location).map_err(|error| {
213                    ChallengeError::Unauthorized(format!("redirect Location is not a URL: {error}"))
214                })?;
215                self.redirect_allowed(&target)?;
216                debug!(
217                    event = "challenge_http_01_redirect",
218                    outcome = "progress",
219                    from = %url,
220                    to = %target,
221                    hop,
222                    challenge_id = ctx.challenge_id,
223                );
224                url = target;
225                continue;
226            }
227
228            if response.status != 200 {
229                return Err(ChallengeError::Unauthorized(format!(
230                    "{url} responded with HTTP {}",
231                    response.status
232                )));
233            }
234
235            if response.truncated {
236                return Err(ChallengeError::Unauthorized(format!(
237                    "{url} responded with more than {} bytes",
238                    self.max_response_bytes
239                )));
240            }
241
242            // RFC 8555 §8.3: leading and trailing whitespace is ignored, so a
243            // webroot file written with a trailing newline still matches.
244            let body = String::from_utf8_lossy(&response.body);
245            if body.trim() == ctx.key_authorization {
246                debug!(
247                    event = "challenge_http_01_matched",
248                    outcome = "success",
249                    probe_url = %url,
250                    challenge_id = ctx.challenge_id,
251                );
252                return Ok(());
253            }
254
255            // The responder answered but with the wrong content — the single
256            // most useful signal that a client is misconfigured, so it belongs
257            // at `warn` where the default filter shows it.
258            warn!(
259                event = "challenge_http_01_mismatch",
260                outcome = "failure",
261                probe_url = %url,
262                challenge_id = ctx.challenge_id,
263                body_bytes = response.body.len(),
264            );
265            // The preview stays at `debug`, separately: the body is
266            // attacker-controlled and may be the response to a redirect the
267            // client chose, so it must not be promoted into the level an
268            // operator alerts on. The client itself learns the length, never
269            // the content.
270            debug!(
271                event = "challenge_http_01_mismatch_body",
272                outcome = "failure",
273                probe_url = %url,
274                challenge_id = ctx.challenge_id,
275                preview = %body.chars().take(64).collect::<String>(),
276            );
277            return Err(ChallengeError::Unauthorized(format!(
278                "{url} served {} bytes that are not the key authorization",
279                response.body.len()
280            )));
281        }
282
283        Err(ChallengeError::Connection(format!(
284            "more than {} redirects while validating {}",
285            self.max_redirects, ctx.identifier
286        )))
287    }
288}
289
290/// The 3xx statuses that carry a `Location` worth following.
291fn is_redirect(status: u16) -> bool {
292    matches!(status, 301 | 302 | 303 | 307 | 308)
293}
294
295/// The production fetcher: one HTTP/1.1 request per call, over TCP or TLS.
296pub struct HyperFetcher {
297    tls: Arc<rustls::ClientConfig>,
298    /// Where every outbound hop resolves and whether it goes through a
299    /// proxy — `dns.resolver` and `[proxy]`, bundled.
300    outbound: crate::http_client::Outbound,
301}
302
303impl HyperFetcher {
304    pub fn new(outbound: crate::http_client::Outbound) -> anyhow::Result<Self> {
305        // No ALPN: this is an ordinary https request, not a challenge handshake.
306        // The certificate is not validated — RFC 8555 §8.3 says so explicitly,
307        // and the proof is the body, not the transport.
308        Ok(Self {
309            tls: super::tls_alpn_01::accept_any_client_config(&[])?,
310            outbound,
311        })
312    }
313
314    /// Sends the request over an established connection and reads a capped body.
315    async fn exchange(
316        mut connection: crate::http_client::Connection<Empty<Bytes>>,
317        endpoint: &crate::http_client::Endpoint,
318        url: &Url,
319        max_bytes: usize,
320    ) -> Result<HttpResponse, FetchError> {
321        // The endpoint the connection was opened to already knows this, and
322        // elides a default port the same way; deriving it a second time from
323        // the URL was one more place for the two spellings to disagree.
324        let authority = endpoint.authority();
325
326        // Origin-form directly, absolute-form when this connection forwards
327        // through a proxy — the connection knows which, the caller does not.
328        let target = connection.request_target(url);
329
330        // The low-level client sends what it is given: hyper 1.x does not add a
331        // `Host` header, and a request without one is rejected by most servers.
332        let request = Request::builder()
333            .uri(target)
334            .header(hyper::header::HOST, &authority)
335            .header(hyper::header::USER_AGENT, "acme-proxy")
336            .header(hyper::header::CONNECTION, "close")
337            .body(Empty::<Bytes>::new())
338            .map_err(|error| FetchError::Protocol(format!("building the request: {error}")))?;
339
340        let response = connection
341            .send_request(request)
342            .await
343            .map_err(|error| FetchError::Protocol(format!("request to {url} failed: {error}")))?;
344
345        let status = response.status().as_u16();
346        let location = response
347            .headers()
348            .get(hyper::header::LOCATION)
349            .and_then(|value| value.to_str().ok())
350            .map(str::to_string);
351
352        // `Limited` errors once the cap is passed; that is the signal, not a
353        // failure — a body too large is refused without being read further.
354        let (body, truncated) = match Limited::new(response.into_body(), max_bytes)
355            .collect()
356            .await
357        {
358            Ok(collected) => (collected.to_bytes().to_vec(), false),
359            Err(_) => (Vec::new(), true),
360        };
361
362        Ok(HttpResponse {
363            status,
364            location,
365            body,
366            truncated,
367        })
368    }
369}
370
371#[async_trait]
372impl HttpFetcher for HyperFetcher {
373    async fn get(&self, url: &Url, max_bytes: usize) -> Result<HttpResponse, FetchError> {
374        // Resolved and connected through the shared resolver rather than left
375        // to `TcpStream::connect`'s own OS-level lookup, so `dns.resolver`
376        // governs this connect target the same way it governs the `dns-01` TXT
377        // query — and so a dual-stack answer whose first address is
378        // unreachable falls back rather than failing outright. That is
379        // `http_client::connect`'s job now; this validator is the one client
380        // that always did it right, and the other three were brought to it.
381        //
382        // The TLS configuration handed over is `accept_any_client_config`:
383        // RFC 8555 §8.3 says the responder's certificate is *not* validated —
384        // what it carries is the proof. That is the policy this module keeps
385        // for itself, and the only reason it cannot share more than transport.
386        let endpoint = crate::http_client::Endpoint::from_url(url).map_err(FetchError::Protocol)?;
387
388        let connection = self
389            .outbound
390            .connect(&endpoint, &self.tls)
391            .await
392            .map_err(FetchError::Connect)?;
393
394        Self::exchange(connection, &endpoint, url, max_bytes).await
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::dns::Resolver;
402    use std::collections::HashMap;
403    use std::sync::Mutex;
404
405    const KEY_AUTH: &str = "token-value.thumbprint-value";
406    const TOKEN: &str = "token-value";
407
408    /// A fetcher answering from canned responses, recording what it was asked
409    /// for.
410    #[derive(Default)]
411    struct StubFetcher {
412        responses: HashMap<String, (u16, Option<String>, Vec<u8>, bool)>,
413        error: Option<&'static str>,
414        requested: Mutex<Vec<String>>,
415    }
416
417    impl StubFetcher {
418        fn serving(url: &str, body: &str) -> Self {
419            Self::default().with(url, 200, None, body.as_bytes().to_vec(), false)
420        }
421
422        fn with(
423            mut self,
424            url: &str,
425            status: u16,
426            location: Option<&str>,
427            body: Vec<u8>,
428            truncated: bool,
429        ) -> Self {
430            self.responses.insert(
431                url.to_string(),
432                (status, location.map(str::to_string), body, truncated),
433            );
434            self
435        }
436
437        fn redirecting(url: &str, to: &str) -> Self {
438            Self::default().with(url, 301, Some(to), Vec::new(), false)
439        }
440
441        fn failing(error: &'static str) -> Self {
442            Self {
443                error: Some(error),
444                ..Self::default()
445            }
446        }
447
448        fn requested(&self) -> Vec<String> {
449            self.requested.lock().unwrap().clone()
450        }
451    }
452
453    #[async_trait]
454    impl HttpFetcher for StubFetcher {
455        async fn get(&self, url: &Url, _max_bytes: usize) -> Result<HttpResponse, FetchError> {
456            self.requested.lock().unwrap().push(url.to_string());
457            if let Some(error) = self.error {
458                return Err(FetchError::Connect(error.to_string()));
459            }
460            match self.responses.get(url.as_str()) {
461                Some((status, location, body, truncated)) => Ok(HttpResponse {
462                    status: *status,
463                    location: location.clone(),
464                    body: body.clone(),
465                    truncated: *truncated,
466                }),
467                // An unmapped URL stands in for "nothing there".
468                None => Ok(HttpResponse {
469                    status: 404,
470                    location: None,
471                    body: Vec::new(),
472                    truncated: false,
473                }),
474            }
475        }
476    }
477
478    fn validate_with(cfg: Http01Config, fetcher: Arc<StubFetcher>) -> Http01Validator {
479        Http01Validator::with_fetcher(&cfg, fetcher)
480    }
481
482    fn context(identifier: &str) -> ValidationContext<'_> {
483        ValidationContext {
484            identifier,
485            wildcard: false,
486            token: TOKEN,
487            key_authorization: KEY_AUTH,
488            challenge_id: "chall-1",
489        }
490    }
491
492    const CHALLENGE_URL: &str = "http://example.com/.well-known/acme-challenge/token-value";
493
494    #[tokio::test]
495    async fn the_key_authorization_is_fetched_from_the_well_known_url() {
496        let fetcher = Arc::new(StubFetcher::serving(CHALLENGE_URL, KEY_AUTH));
497        let validator = validate_with(Http01Config::default(), fetcher.clone());
498
499        assert!(validator.validate(&context("example.com")).await.is_ok());
500        assert_eq!(fetcher.requested(), vec![CHALLENGE_URL.to_string()]);
501    }
502
503    /// RFC 8555 §8.3 ignores surrounding whitespace — a webroot file written by
504    /// `echo` has a trailing newline.
505    #[tokio::test]
506    async fn surrounding_whitespace_is_ignored() {
507        let fetcher = Arc::new(StubFetcher::serving(
508            CHALLENGE_URL,
509            &format!("  {KEY_AUTH}\n"),
510        ));
511        assert!(
512            validate_with(Http01Config::default(), fetcher)
513                .validate(&context("example.com"))
514                .await
515                .is_ok()
516        );
517    }
518
519    /// The body may be the response to a redirect the client chose, so it must
520    /// never reach the client-visible problem document.
521    #[tokio::test]
522    async fn a_wrong_body_is_refused_without_echoing_it() {
523        let secret = "internal-api-token-abc123";
524        let fetcher = Arc::new(StubFetcher::serving(CHALLENGE_URL, secret));
525        let error = validate_with(Http01Config::default(), fetcher)
526            .validate(&context("example.com"))
527            .await
528            .unwrap_err();
529
530        match &error {
531            ChallengeError::Unauthorized(detail) => {
532                assert!(!detail.contains(secret), "the body leaked: {detail}");
533                assert!(detail.contains("25 bytes"), "{detail}");
534            }
535            other => panic!("expected Unauthorized, got {other:?}"),
536        }
537    }
538
539    #[tokio::test]
540    async fn a_non_200_response_is_refused() {
541        // Nothing is mapped, so the stub answers 404.
542        let error = validate_with(Http01Config::default(), Arc::new(StubFetcher::default()))
543            .validate(&context("example.com"))
544            .await
545            .unwrap_err();
546        assert!(
547            matches!(&error, ChallengeError::Unauthorized(detail) if detail.contains("HTTP 404")),
548            "{error:?}"
549        );
550    }
551
552    #[tokio::test]
553    async fn an_unreachable_target_is_a_connection_error() {
554        let error = validate_with(
555            Http01Config::default(),
556            Arc::new(StubFetcher::failing("connection refused")),
557        )
558        .validate(&context("example.com"))
559        .await
560        .unwrap_err();
561        assert!(
562            matches!(&error, ChallengeError::Connection(detail) if detail.contains("refused")),
563            "{error:?}"
564        );
565    }
566
567    /// The common deployment: port 80 redirects everything to https.
568    #[tokio::test]
569    async fn a_redirect_to_https_is_followed() {
570        const HTTPS_URL: &str = "https://example.com/.well-known/acme-challenge/token-value";
571        let fetcher = Arc::new(StubFetcher::redirecting(CHALLENGE_URL, HTTPS_URL).with(
572            HTTPS_URL,
573            200,
574            None,
575            KEY_AUTH.as_bytes().to_vec(),
576            false,
577        ));
578        let validator = validate_with(Http01Config::default(), fetcher.clone());
579
580        assert!(validator.validate(&context("example.com")).await.is_ok());
581        assert_eq!(
582            fetcher.requested(),
583            vec![CHALLENGE_URL.to_string(), HTTPS_URL.to_string()]
584        );
585    }
586
587    #[tokio::test]
588    async fn a_relative_location_is_resolved_against_the_current_url() {
589        const MOVED: &str = "http://example.com/elsewhere";
590        let fetcher = Arc::new(StubFetcher::redirecting(CHALLENGE_URL, "/elsewhere").with(
591            MOVED,
592            200,
593            None,
594            KEY_AUTH.as_bytes().to_vec(),
595            false,
596        ));
597        let validator = validate_with(Http01Config::default(), fetcher.clone());
598
599        assert!(validator.validate(&context("example.com")).await.is_ok());
600        assert_eq!(fetcher.requested()[1], MOVED);
601    }
602
603    /// A redirect loop must terminate at the cap rather than spinning until the
604    /// registry's timeout fires.
605    #[tokio::test]
606    async fn a_redirect_loop_stops_at_the_cap() {
607        let fetcher = Arc::new(StubFetcher::redirecting(CHALLENGE_URL, CHALLENGE_URL));
608        let cfg = Http01Config {
609            max_redirects: 3,
610            ..Http01Config::default()
611        };
612        let validator = validate_with(cfg, fetcher.clone());
613
614        let error = validator
615            .validate(&context("example.com"))
616            .await
617            .unwrap_err();
618        assert!(
619            matches!(&error, ChallengeError::Connection(detail) if detail.contains("more than 3 redirects")),
620            "{error:?}"
621        );
622        // The cap counts hops, so the first request plus three redirects.
623        assert_eq!(fetcher.requested().len(), 4);
624    }
625
626    /// Without a scheme check the validator reads local files on request.
627    #[tokio::test]
628    async fn a_redirect_to_another_scheme_is_refused() {
629        let fetcher = Arc::new(StubFetcher::redirecting(
630            CHALLENGE_URL,
631            "file:///etc/passwd",
632        ));
633        let error = validate_with(Http01Config::default(), fetcher)
634            .validate(&context("example.com"))
635            .await
636            .unwrap_err();
637        assert!(
638            matches!(&error, ChallengeError::Unauthorized(detail) if detail.contains("unsupported scheme")),
639            "{error:?}"
640        );
641    }
642
643    /// Without a port check the validator is a port scanner for the client's
644    /// own network.
645    #[tokio::test]
646    async fn a_redirect_to_another_port_is_refused() {
647        let fetcher = Arc::new(StubFetcher::redirecting(
648            CHALLENGE_URL,
649            "http://10.0.0.5:9200/_cluster/health",
650        ));
651        let error = validate_with(Http01Config::default(), fetcher)
652            .validate(&context("example.com"))
653            .await
654            .unwrap_err();
655        assert!(
656            matches!(&error, ChallengeError::Unauthorized(detail) if detail.contains("port 9200")),
657            "{error:?}"
658        );
659    }
660
661    #[tokio::test]
662    async fn redirects_can_be_turned_off_entirely() {
663        let fetcher = Arc::new(StubFetcher::redirecting(
664            CHALLENGE_URL,
665            "https://example.com/.well-known/acme-challenge/token-value",
666        ));
667        let cfg = Http01Config {
668            follow_redirects: false,
669            ..Http01Config::default()
670        };
671        let error = validate_with(cfg, fetcher.clone())
672            .validate(&context("example.com"))
673            .await
674            .unwrap_err();
675
676        assert!(
677            matches!(&error, ChallengeError::Unauthorized(detail) if detail.contains("disabled")),
678            "{error:?}"
679        );
680        assert_eq!(fetcher.requested().len(), 1);
681    }
682
683    /// An oversized body is refused *without* being compared — the key
684    /// authorization is under a hundred bytes.
685    #[tokio::test]
686    async fn an_oversized_body_is_refused_without_comparison() {
687        let fetcher = Arc::new(StubFetcher::default().with(
688            CHALLENGE_URL,
689            200,
690            None,
691            KEY_AUTH.as_bytes().to_vec(),
692            true,
693        ));
694        let error = validate_with(Http01Config::default(), fetcher)
695            .validate(&context("example.com"))
696            .await
697            .unwrap_err();
698        assert!(
699            matches!(&error, ChallengeError::Unauthorized(detail) if detail.contains("more than 4096 bytes")),
700            "{error:?}"
701        );
702    }
703
704    #[tokio::test]
705    async fn a_non_default_port_appears_in_the_url() {
706        const URL: &str = "http://example.com:8080/.well-known/acme-challenge/token-value";
707        let fetcher = Arc::new(StubFetcher::serving(URL, KEY_AUTH));
708        let cfg = Http01Config {
709            port: 8080,
710            ..Http01Config::default()
711        };
712        let validator = validate_with(cfg, fetcher.clone());
713
714        assert!(validator.validate(&context("example.com")).await.is_ok());
715        assert_eq!(fetcher.requested(), vec![URL.to_string()]);
716    }
717
718    #[test]
719    fn reports_its_challenge_type() {
720        assert_eq!(
721            validate_with(Http01Config::default(), Arc::new(StubFetcher::default())).typ(),
722            "http-01"
723        );
724    }
725
726    #[test]
727    fn only_3xx_statuses_carrying_a_location_are_redirects() {
728        for status in [301, 302, 303, 307, 308] {
729            assert!(is_redirect(status), "{status}");
730        }
731        for status in [200, 204, 304, 400, 404, 500] {
732            assert!(!is_redirect(status), "{status}");
733        }
734    }
735
736    /// The real fetcher, against a loopback listener. This is the only thing
737    /// that catches a missing `Host` header or a mis-formatted request line — a
738    /// stub never would.
739    mod loopback {
740        use super::*;
741        use std::net::IpAddr;
742        use tokio::io::{AsyncReadExt, AsyncWriteExt};
743        use tokio::net::TcpListener;
744
745        /// Every test here targets `127.0.0.1` literally, so `resolve_first`
746        /// short-circuits before ever asking a resolver anything.
747        struct UnreachableResolver;
748
749        #[async_trait]
750        impl Resolver for UnreachableResolver {
751            async fn reverse(&self, _ip: IpAddr) -> Result<Vec<String>, String> {
752                unreachable!()
753            }
754            async fn forward(&self, _name: &str) -> Result<Vec<IpAddr>, String> {
755                unreachable!("a literal 127.0.0.1 must short-circuit before this is called")
756            }
757            async fn txt(&self, _name: &str) -> Result<Vec<String>, String> {
758                unreachable!()
759            }
760        }
761
762        fn fetcher() -> HyperFetcher {
763            HyperFetcher::new(crate::testutil::outbound_with(Arc::new(
764                UnreachableResolver,
765            )))
766            .unwrap()
767        }
768
769        /// Serves one canned response and returns the request it received.
770        async fn serve_once(response: &'static str) -> (u16, tokio::task::JoinHandle<String>) {
771            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
772            let port = listener.local_addr().unwrap().port();
773
774            let handle = tokio::spawn(async move {
775                let (mut stream, _) = listener.accept().await.unwrap();
776                let mut buffer = vec![0u8; 2048];
777                let read = stream.read(&mut buffer).await.unwrap();
778                stream.write_all(response.as_bytes()).await.unwrap();
779                stream.shutdown().await.unwrap();
780                String::from_utf8_lossy(&buffer[..read]).into_owned()
781            });
782
783            (port, handle)
784        }
785
786        #[tokio::test]
787        async fn fetches_a_body_and_sends_a_host_header() {
788            let (port, server) = serve_once(
789                "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello",
790            )
791            .await;
792            let url = Url::parse(&format!("http://127.0.0.1:{port}/.well-known/x")).unwrap();
793
794            let response = fetcher().get(&url, 4096).await.unwrap();
795            assert_eq!(response.status, 200);
796            assert_eq!(response.body, b"hello");
797            assert!(!response.truncated);
798
799            let request = server.await.unwrap();
800            assert!(
801                request.starts_with("GET /.well-known/x HTTP/1.1"),
802                "{request}"
803            );
804            assert!(
805                request
806                    .to_lowercase()
807                    .contains(&format!("host: 127.0.0.1:{port}")),
808                "{request}"
809            );
810        }
811
812        #[tokio::test]
813        async fn reads_a_redirect_location() {
814            let (port, _server) = serve_once(
815                "HTTP/1.1 301 Moved Permanently\r\nLocation: https://example.com/x\r\n\
816                 Content-Length: 0\r\nConnection: close\r\n\r\n",
817            )
818            .await;
819            let url = Url::parse(&format!("http://127.0.0.1:{port}/x")).unwrap();
820
821            let response = fetcher().get(&url, 4096).await.unwrap();
822            assert_eq!(response.status, 301);
823            assert_eq!(response.location.as_deref(), Some("https://example.com/x"));
824        }
825
826        #[tokio::test]
827        async fn a_body_over_the_cap_is_reported_as_truncated() {
828            let (port, _server) = serve_once(
829                "HTTP/1.1 200 OK\r\nContent-Length: 20\r\nConnection: close\r\n\r\n\
830                 aaaaaaaaaaaaaaaaaaaa",
831            )
832            .await;
833            let url = Url::parse(&format!("http://127.0.0.1:{port}/x")).unwrap();
834
835            let response = fetcher().get(&url, 8).await.unwrap();
836            assert!(response.truncated);
837        }
838
839        #[tokio::test]
840        async fn a_closed_port_is_a_connect_error() {
841            // Bind then drop, so the port is almost certainly free and unbound.
842            let port = {
843                let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
844                listener.local_addr().unwrap().port()
845            };
846            let url = Url::parse(&format!("http://127.0.0.1:{port}/x")).unwrap();
847
848            let error = fetcher().get(&url, 4096).await.unwrap_err();
849            assert!(matches!(&error, FetchError::Connect(_)), "{error:?}");
850        }
851
852        /// The request line carries the query string. A challenge URL has none,
853        /// but a redirect can land on one, and dropping it would fetch a
854        /// different resource than the one that was redirected to.
855        #[tokio::test]
856        async fn the_request_target_keeps_the_query_string() {
857            let (port, server) =
858                serve_once("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
859                    .await;
860            let url =
861                Url::parse(&format!("http://127.0.0.1:{port}/.well-known/x?a=1&b=2")).unwrap();
862
863            fetcher().get(&url, 4096).await.unwrap();
864            let request = server.await.unwrap();
865            assert!(
866                request.starts_with("GET /.well-known/x?a=1&b=2 "),
867                "{request}"
868            );
869        }
870
871        /// A cleartext server on an `https` URL fails the handshake. The point
872        /// is that the failure is a `Connect` error naming the host, not a
873        /// panic or a silent fall back to plaintext.
874        #[tokio::test]
875        async fn an_https_url_against_a_cleartext_server_is_a_connect_error() {
876            let (port, _server) =
877                serve_once("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
878                    .await;
879            let url = Url::parse(&format!("https://127.0.0.1:{port}/x")).unwrap();
880
881            let error = fetcher().get(&url, 4096).await.unwrap_err();
882            assert!(matches!(&error, FetchError::Connect(_)), "{error:?}");
883        }
884    }
885
886    /// `dyn HttpFetcher` is not `Debug`, so the validator renders its policy —
887    /// the ports and redirect limits an operator would check a startup log for.
888    #[test]
889    fn the_validator_debug_shows_its_policy() {
890        let validator = validate_with(
891            Http01Config {
892                port: 8080,
893                https_port: 8443,
894                follow_redirects: false,
895                max_redirects: 2,
896                max_response_bytes: 1024,
897            },
898            Arc::new(StubFetcher::default()),
899        );
900        let rendered = format!("{validator:?}");
901        for expected in ["Http01Validator", "8080", "8443", "false", "1024"] {
902            assert!(
903                rendered.contains(expected),
904                "{expected} missing: {rendered}"
905            );
906        }
907    }
908
909    /// A `Location` that is neither absolute nor a valid relative reference
910    /// cannot be resolved against the current URL. Refusing is the only safe
911    /// answer — guessing would mean fetching something nobody named.
912    #[tokio::test]
913    async fn a_location_that_is_not_a_url_is_refused() {
914        let fetcher = Arc::new(StubFetcher::redirecting(CHALLENGE_URL, "http://"));
915        let error = validate_with(Http01Config::default(), fetcher)
916            .validate(&context("example.com"))
917            .await
918            .unwrap_err();
919        match error {
920            ChallengeError::Unauthorized(detail) => {
921                assert!(detail.contains("Location is not a URL"), "{detail}")
922            }
923            other => panic!("expected Unauthorized, got {other:?}"),
924        }
925    }
926}