Skip to main content

protocol_droid/adapters/primary/transport/
origin.rs

1//! The `Origin` check, which the specification makes a `MUST`.
2//!
3//! # What the specification says
4//!
5//! > When implementing HTTP with SSE transport:
6//! >
7//! > 1. Servers **MUST** validate the `Origin` header on all incoming
8//! >    connections to prevent DNS rebinding attacks
9//! > 2. When running locally, servers **SHOULD** bind only to localhost
10//! >    (127.0.0.1) rather than all network interfaces (0.0.0.0)
11//! > 3. Servers **SHOULD** implement proper authentication for all connections
12//!
13//! Cited: <https://modelcontextprotocol.io/specification/2024-11-05/basic/transports>
14//!
15//! This module is (1). (2) is a bind address and (3) is [`AuthPolicy`](crate::adapters::primary::transport::http::AuthPolicy) in the
16//! sibling `auth` module. Of the three, (1) is the only `MUST`, and it is the
17//! one that is easiest to implement wrongly in a way that looks correct.
18//!
19//! # The attack
20//!
21//! DNS rebinding, in the shape it takes against a local MCP server:
22//!
23//! 1. The user visits `evil.example`, which serves a page.
24//! 2. The page's DNS record has a short TTL and points at `127.0.0.1`.
25//!    The user sees `http://127.0.0.1:8080/` in their address bar, and it is
26//!    the real local server, so nothing looks wrong.
27//! 3. The page now runs `fetch("http://127.0.0.1:8080/mcp", …)`. The browser
28//!    treats this as **cross-origin** and attaches
29//!    `Origin: http://evil.example`.
30//! 4. The local server answers, because it authenticated nobody and the URL
31//!    is correct, and a page in a browser tab can now enumerate tools, call
32//!    them, and read the results.
33//!
34//! Step 4 is the one the server is supposed to prevent. The whole defence is
35//! step 3's header: a browser **always** attaches `Origin` to a cross-origin
36//! request, so "the `Origin` is not mine" is a signal that is present exactly
37//! when the request is the one the `MUST` is about.
38//!
39//! # Why a *missing* `Origin` is allowed
40//!
41//! Because every non-browser client omits it, and refusing those would break
42//! the transport for everyone except the attacker. A CLI, a Rust SDK, a
43//! server-to-server call, and a `curl` all send no `Origin` — and those are
44//! the legitimate majority. Failing closed on absent would not be more secure;
45//! it would be secure against browsers and unusable for everything else, which
46//! is the trade that produces a transport nobody runs.
47//!
48//! So the rule is deliberately asymmetric, and the asymmetry is the design:
49//!
50//! - **Present and not ours** → [`Refusal::ForeignOrigin`]. The browser case,
51//!   and the only one that can carry a forged cross-site request.
52//! - **Present and ours** → allowed. A same-origin page, or a browser client
53//!   whose origin the operator listed.
54//! - **Absent** → allowed. Not a browser, so not the rebinding case.
55//!
56//! # Why a *present* `Origin` is not automatically an attack
57//!
58//! Because the ordinary local development case is a page on
59//! `http://localhost:3000` talking to a server on `http://127.0.0.1:8080`.
60//! That `Origin` is genuinely not the server's own, it is genuinely
61//! cross-origin, and it is legitimate. A policy that refused it would make
62//! every front-end developer conclude the `MUST` was unusable and disable it —
63//! so `OriginPolicy::allow` takes an explicit list, and refusing everything
64//! by default is the *safe* direction to fail in when the operator has not
65//! configured it.
66//!
67//! The default is therefore: no listed origins, and the server's own origin
68//! as configured. A same-origin browser page works; every other browser page
69//! is refused; every non-browser client works.
70
71use core::str::FromStr;
72
73use crate::adapters::primary::transport::http::Refusal;
74
75/// The scheme-and-authority an `Origin` names, compared as such.
76///
77/// Lowercased on parse, because RFC 6454 §6.2.2 makes the comparison
78/// case-insensitive for scheme and host: a browser sends `HTTP://Example.COM`
79/// and a byte-comparing server refuses a page it just served. A path or query
80/// in an `Origin` is refused outright — the header is scheme-and-host-only,
81/// and a value carrying more is not a value this policy can reason about.
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
83pub struct Origin(String);
84
85impl Origin {
86    /// The origin as sent, lowercased in its scheme and host.
87    #[must_use]
88    pub fn as_str(&self) -> &str {
89        &self.0
90    }
91
92    /// The host this origin names, without the scheme and without the port.
93    ///
94    /// Brackets are stripped, so an IPv6 host reads as `::1` rather than as
95    /// `[::1]`, which is the form every comparison in this module wants.
96    #[must_use]
97    pub fn host(&self) -> &str {
98        let authority = self
99            .0
100            .split_once("://")
101            .map_or(self.0.as_str(), |(_, rest)| rest);
102        host_of(authority)
103    }
104
105    /// Whether this origin names the loopback interface.
106    ///
107    /// Exactly `localhost`, `127.0.0.1` or `::1`, and nothing else. This is
108    /// the check that keeps a rebinding defence from rebinding: `localhost`
109    /// is compared whole, so `localhost.evil.example` and `127.0.0.1.evil.example`
110    /// are not loopback and are not excused by this.
111    #[must_use]
112    pub fn is_loopback(&self) -> bool {
113        is_loopback_host(self.host())
114    }
115}
116
117/// The host from an authority, which is `host[:port]` or `[host]:port`.
118///
119/// The bracketed form is handled before the port is split off, because the
120/// port separator inside `[::1]` is the `:` in the address itself: splitting
121/// `[::1]:18099` on the first colon yields `[` and `1]:18099`, which names
122/// nothing.
123fn host_of(authority: &str) -> &str {
124    if let Some(bracketed) = authority.strip_prefix('[') {
125        return bracketed.split(']').next().unwrap_or(authority);
126    }
127    match authority.split_once(':') {
128        // Only a numeric suffix is a port. `localhost:8080.evil.example`
129        // carries a colon and still names a single host, so splitting there
130        // would read the host as `localhost` and excuse it as loopback —
131        // which is the one shape that turns this function into the hole it
132        // exists to close.
133        Some((host, port))
134            if !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()) =>
135        {
136            host
137        }
138        _ => authority,
139    }
140}
141
142/// Whether `host` is one of the three names the specification's own suite
143/// treats as localhost.
144fn is_loopback_host(host: &str) -> bool {
145    host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
146}
147
148/// Why a string is not a usable `Origin`.
149#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
150pub enum OriginError {
151    /// The value was empty.
152    #[error("an Origin header was present but empty")]
153    Empty,
154
155    /// The value was not `scheme://host[:port]`.
156    #[error("an Origin header must be scheme://host[:port] with no path")]
157    NotAnOrigin,
158
159    /// The scheme was not `http` or `https`.
160    ///
161    /// A browser's `Origin` for a page it loaded over HTTP or HTTPS is `http`
162    /// or `https` and nothing else. Anything else came from something that is
163    /// not a browser, which means it is not the case this check exists for, and
164    /// it is refused rather than passed through as a string comparison.
165    #[error("an Origin header must use the http or https scheme")]
166    UnsupportedScheme,
167}
168
169impl FromStr for Origin {
170    type Err = OriginError;
171
172    /// # Errors
173    ///
174    /// [`OriginError::Empty`] for an empty value,
175    /// [`OriginError::UnsupportedScheme`] for a scheme other than HTTP(S), and
176    /// [`OriginError::NotAnOrigin`] for anything that is not
177    /// `scheme://host[:port]` — including a bare host, a trailing path, or a
178    /// `null` origin, which is what a sandboxed frame and some redirects send.
179    fn from_str(value: &str) -> Result<Self, Self::Err> {
180        if value.is_empty() {
181            return Err(OriginError::Empty);
182        }
183        let Some((scheme, rest)) = value.split_once("://") else {
184            return Err(OriginError::NotAnOrigin);
185        };
186        let scheme = scheme.to_ascii_lowercase();
187        if scheme != "http" && scheme != "https" {
188            return Err(OriginError::UnsupportedScheme);
189        }
190        // The rest must be host or host:port and nothing else. A `/` in here
191        // is a path, which RFC 6454 does not put in an `Origin`; accepting and
192        // ignoring it would mean two different headers compare equal as
193        // strings while meaning different things to the browser that sent them.
194        if rest.is_empty() || rest.contains('/') {
195            return Err(OriginError::NotAnOrigin);
196        }
197        Ok(Self(format!("{scheme}://{}", rest.to_ascii_lowercase())))
198    }
199}
200
201/// Which `Origin`s this server answers.
202///
203/// Not a trait and not a port: the check is a pure function over two strings,
204/// and a port trait here would be a dynamic dispatch bought for nothing. The
205/// type exists so the *default* can be fail-secure and the comparison can be
206/// spelled once.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct OriginPolicy {
209    /// Origins this server answers for, beyond its own.
210    allowed: Vec<Origin>,
211    /// The origin this server considers to be itself.
212    own: Option<Origin>,
213}
214
215impl OriginPolicy {
216    /// A policy that answers only its own origin.
217    ///
218    /// `own` is `None` when the server was not told its own origin — behind a
219    /// proxy that rewrites `Host`, or bound to `0.0.0.0` and reached by
220    /// several names. That is **not** a reason to answer everything: with no
221    /// known own-origin the policy refuses every `Origin` that is present,
222    /// which is the fail-secure direction. A server in that position has to
223    /// say what it serves, because nothing else can work it out.
224    #[must_use]
225    pub const fn strict(own: Option<Origin>) -> Self {
226        Self {
227            allowed: Vec::new(),
228            own,
229        }
230    }
231
232    /// Adds an origin this server answers for.
233    ///
234    /// The list is exactly what the operator wrote: no wildcard, no suffix
235    /// match, no parent domain. A suffix rule (`evil-example.com` matching
236    /// `not-evil-example.com`) is the sort of thing that gets added once,
237    /// for a legitimate case, and never looked at again.
238    #[must_use]
239    pub fn allowing(mut self, origin: Origin) -> Self {
240        self.allowed.push(origin);
241        self
242    }
243
244    /// The origin this server considers its own, if it was told one.
245    #[must_use]
246    pub const fn own(&self) -> Option<&Origin> {
247        self.own.as_ref()
248    }
249
250    /// The origins answered for, including this server's own.
251    pub fn permitted(&self) -> impl Iterator<Item = &Origin> {
252        self.own.iter().chain(self.allowed.iter())
253    }
254
255    /// Decides whether a request carrying `header` is answered.
256    ///
257    /// `None` means the header was absent, which is a non-browser client and
258    /// is allowed — see the module docs for why that asymmetry is the design
259    /// and not an oversight.
260    ///
261    /// A loopback origin is answered whatever the allow-list says, and that is
262    /// not a hole in it. `Origin` is set by the browser and names the page the
263    /// request came *from*, so a page served from `http://localhost:8080`
264    /// genuinely sends `http://localhost:8080` — and a rebound page at
265    /// `evil.example` sends `http://evil.example`, which is not loopback and is
266    /// refused here exactly as before. What a loopback allowlist would get
267    /// wrong is a suffix match, so the host is compared whole.
268    ///
269    /// The comparison is exact and case-insensitive-in-scheme-and-host, done
270    /// once at parse. A *substring* or *suffix* match is the tempting
271    /// alternative and is the bug this type is shaped to prevent: an allowlist
272    /// of `localhost` that also answers `localhost.evil.example` is a
273    /// rebinding defence that rebinds.
274    pub fn check(&self, header: Option<&str>) -> Result<(), Refusal> {
275        let Some(raw) = header else {
276            return Ok(());
277        };
278        let origin = Origin::from_str(raw).map_err(|_| Refusal::ForeignOrigin)?;
279        if origin.is_loopback() || self.permitted().any(|permitted| permitted == &origin) {
280            Ok(())
281        } else {
282            Err(Refusal::ForeignOrigin)
283        }
284    }
285
286    /// Decides whether a request whose `Host` is `header` is answered.
287    ///
288    /// `Host` is the header a rebinding attack actually controls, and checking
289    /// only `Origin` leaves the attack open to a client that omits it: a
290    /// browser rebound onto `127.0.0.1` sends `Host: evil.example`, and a
291    /// non-browser client can send whatever it likes. A browser cannot forge
292    /// `Host` — it is set from the URL it dialled — so a `Host` that is not
293    /// loopback and not one this server was told to serve names a request for
294    /// somewhere else.
295    ///
296    /// Compared on the *host* rather than the whole authority, because the
297    /// port is not part of the question: a client reaching this server on the
298    /// port it was bound to names a host, and a loopback host on a different
299    /// port is a different server that this policy has no opinion about.
300    ///
301    /// `None` means the header was absent. HTTP/1.1 requires it and HTTP/2
302    /// carries it as `:authority`, so this is a proxy that stripped it rather
303    /// than a client that had none; it is refused, because a check that
304    /// answers "no evidence either way" cannot be the defence.
305    pub fn check_host(&self, header: Option<&str>) -> Result<(), Refusal> {
306        let Some(raw) = header else {
307            return Err(Refusal::ForeignOrigin);
308        };
309        let host = host_of(raw.trim());
310        if is_loopback_host(host) || self.permitted().any(|permitted| permitted.host() == host) {
311            Ok(())
312        } else {
313            Err(Refusal::ForeignOrigin)
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::{Origin, OriginError, OriginPolicy};
321    use crate::adapters::primary::transport::http::Refusal;
322    use core::str::FromStr;
323
324    type TestError = Box<dyn std::error::Error>;
325
326    /// Asserts `value` parses to an origin reading back as `expected`.
327    fn accepts(value: &str, expected: &str) -> Result<(), TestError> {
328        let origin = Origin::from_str(value)?;
329        if origin.as_str() != expected {
330            return Err(format!("{value:?} read back as {:?}", origin.as_str()).into());
331        }
332        Ok(())
333    }
334
335    /// Asserts `value` is refused, and with `expected`.
336    fn refuses(value: &str, expected: &OriginError) -> Result<(), TestError> {
337        match Origin::from_str(value) {
338            Err(error) if &error == expected => Ok(()),
339            Err(error) => {
340                Err(format!("{value:?} refused as {error:?}, wanted {expected:?}").into())
341            }
342            Ok(origin) => Err(format!("{value:?} was accepted as {:?}", origin.as_str()).into()),
343        }
344    }
345
346    /// A policy answering only `http://127.0.0.1:8080`.
347    fn policy() -> Result<OriginPolicy, TestError> {
348        Ok(OriginPolicy::strict(Some(Origin::from_str(
349            "http://127.0.0.1:8080",
350        )?)))
351    }
352
353    /// RFC 6454 §6.2.2: the comparison is case-insensitive in scheme and
354    /// host, and a byte-comparing server refuses the page it just served.
355    #[test]
356    fn scheme_and_host_are_lowercased() -> Result<(), TestError> {
357        accepts("HTTP://Example.COM", "http://example.com")
358    }
359
360    #[test]
361    fn a_trailing_path_is_refused() -> Result<(), TestError> {
362        refuses("http://example.com/steal", &OriginError::NotAnOrigin)
363    }
364
365    /// What a sandboxed frame and some redirects send. Not a browser talking
366    /// to a local server, so there is nothing here to reason about.
367    #[test]
368    fn the_null_origin_is_refused() -> Result<(), TestError> {
369        refuses("null", &OriginError::NotAnOrigin)
370    }
371
372    #[test]
373    fn a_non_http_scheme_is_refused() -> Result<(), TestError> {
374        refuses("file://example.com", &OriginError::UnsupportedScheme)
375    }
376
377    /// The attack, step 3: the header a browser attaches to the forged
378    /// cross-site request. Refused, because it is not the server's own.
379    #[test]
380    fn a_foreign_origin_is_refused() -> Result<(), TestError> {
381        if policy()?.check(Some("http://evil.example")).is_ok() {
382            return Err("a foreign origin was answered".into());
383        }
384        Ok(())
385    }
386
387    /// Step 3 in the other direction: the legitimate local dev page.
388    ///
389    /// Listed explicitly, because refusing it is what makes an operator turn
390    /// the check off entirely, and a disabled check is worse than a wrong
391    /// default.
392    #[test]
393    fn a_listed_origin_is_answered() -> Result<(), TestError> {
394        let policy = policy()?.allowing(Origin::from_str("http://localhost:3000")?);
395        if policy.check(Some("http://localhost:3000")).is_err() {
396            return Err("a listed origin was refused".into());
397        }
398        Ok(())
399    }
400
401    /// The suffix bug, stated as a test.
402    ///
403    /// An allowlist matching by suffix answers `not-localhost` for a policy
404    /// that lists `localhost`, and answers `localhost.evil.example` for one
405    /// that lists `localhost.evil.example`'s parent. The comparison is exact
406    /// so neither can happen.
407    ///
408    /// Stated on `app.internal` rather than on `localhost`, because loopback
409    /// hosts are now answered whatever the allow-list says (see
410    /// [`OriginPolicy::check`]) and a loopback case would pass for the wrong
411    /// reason. What this is about is the allow-list, so the allow-list's host
412    /// is the one under test.
413    #[test]
414    fn a_neighbouring_host_is_not_answered() -> Result<(), TestError> {
415        let policy = policy()?.allowing(Origin::from_str("http://app.internal:3000")?);
416        for neighbour in [
417            "http://notapp.internal:3000",
418            "http://app.internal.evil.example:3000",
419            "http://app.internal:3001",
420            "http://app.internal:3000.evil.example",
421            "http://app.internalx:3000",
422        ] {
423            if policy.check(Some(neighbour)).is_ok() {
424                return Err(format!("{neighbour} was answered").into());
425            }
426        }
427        Ok(())
428    }
429
430    /// **Every** listed origin answers, not just the first.
431    ///
432    /// This test exists because the allowlist was originally exercised with a
433    /// single entry, and a `first()`-based check passed all of them: with one
434    /// permitted origin, "the first one matches" and "any one matches" are the
435    /// same predicate. A user with a dev server on `:3000` and a second
436    /// client on `:3001` is the ordinary case for listing more than one, and a
437    /// `first()` check silently refuses the second — which reads as "the
438    /// allowlist does not work" and gets diagnosed by deleting the allowlist.
439    #[test]
440    fn every_listed_origin_answers_not_only_the_first() -> Result<(), TestError> {
441        let policy = policy()?
442            .allowing(Origin::from_str("http://localhost:3000")?)
443            .allowing(Origin::from_str("http://localhost:3001")?)
444            .allowing(Origin::from_str("http://app.internal")?);
445
446        for listed in [
447            "http://localhost:3000",
448            "http://localhost:3001",
449            "http://app.internal",
450            // And the server's own, which is answered alongside them.
451            "http://127.0.0.1:8080",
452        ] {
453            if policy.check(Some(listed)).is_err() {
454                return Err(format!("{listed} was refused").into());
455            }
456        }
457        Ok(())
458    }
459
460    /// The port is part of the origin, so a different port is a different
461    /// origin and is refused.
462    ///
463    /// On `app.internal`, because loopback is answered on any port — the one
464    /// place this module deliberately answers something the allow-list did
465    /// not name, and it is not a rebinding hole because `Origin` names the
466    /// page rather than the address, so a rebound page cannot present a
467    /// loopback one. The allow-list stays exact.
468    #[test]
469    fn a_different_port_is_a_different_origin() -> Result<(), TestError> {
470        if policy()?.check(Some("http://app.internal:9999")).is_ok() {
471            return Err("a different port was answered".into());
472        }
473        Ok(())
474    }
475
476    /// Loopback is answered on any port, which is the loosening this module
477    /// made on purpose.
478    ///
479    /// Pinned rather than left to the tests above, so that a reader who finds
480    /// it surprising has the reason here rather than in a diff: a browser
481    /// reaching a server on `localhost:18099` sends `Origin:
482    /// http://localhost:18099`, and refusing that refuses every browser client
483    /// and the conformance suite with it.
484    #[test]
485    fn a_loopback_origin_is_answered_on_any_port() -> Result<(), TestError> {
486        let policy = policy()?;
487        for origin in [
488            "http://localhost:1",
489            "http://localhost:9999",
490            "http://127.0.0.1:65535",
491            "http://[::1]:18099",
492        ] {
493            if policy.check(Some(origin)).is_err() {
494                return Err(format!("{origin} was refused").into());
495            }
496        }
497        Ok(())
498    }
499
500    /// The asymmetry, and the whole reason the transport works for anyone who
501    /// is not a browser.
502    #[test]
503    fn an_absent_origin_is_answered() -> Result<(), TestError> {
504        if policy()?.check(None).is_err() {
505            return Err("a client sending no Origin was refused".into());
506        }
507        Ok(())
508    }
509
510    /// A server that does not know its own origin refuses every `Origin` that
511    /// is present, rather than answering all of them.
512    #[test]
513    fn a_server_that_knows_its_own_origin_still_refuses_foreign_ones() -> Result<(), TestError> {
514        let policy = OriginPolicy::strict(None);
515        if policy.check(Some("http://anything.example")).is_ok() {
516            return Err("a server with no known origin answered one".into());
517        }
518        Ok(())
519    }
520
521    /// A present-but-malformed header is refused, not treated as absent. The
522    /// difference is the attack: a browser sends a well-formed `Origin`, so a
523    /// malformed one did not come from the browser case the `MUST` names.
524    #[test]
525    fn a_malformed_origin_is_refused_rather_than_ignored() -> Result<(), TestError> {
526        if policy()?.check(Some("garbage")).is_ok() {
527            return Err("a malformed Origin was answered".into());
528        }
529        Ok(())
530    }
531
532    /// The refusal carries nothing the attacker chose. Echoing a forged
533    /// `Origin` back in a response body reflects untrusted input to its sender.
534    #[test]
535    fn the_refusal_does_not_echo_the_header() -> Result<(), TestError> {
536        let forged = "http://evil.example/steal?token=abc";
537        let refusal = policy()?.check(Some(forged)).err();
538        let Some(refusal) = refusal else {
539            return Err("a foreign origin was answered".into());
540        };
541        if refusal != Refusal::ForeignOrigin {
542            return Err(format!("refused as {refusal:?}").into());
543        }
544        let body = refusal.body().to_string();
545        if body.contains("evil.example") || body.contains("token") {
546            return Err(format!("the refusal echoed the header: {body}").into());
547        }
548        Ok(())
549    }
550
551    /// The `MUST` wants a status a browser and a proxy both understand, and no
552    /// challenge — a `WWW-Authenticate` would be advice to authenticate with
553    /// something that cannot help.
554    #[test]
555    fn the_refusal_is_a_plain_403() -> Result<(), TestError> {
556        if Refusal::ForeignOrigin.status() != axum::http::StatusCode::FORBIDDEN {
557            return Err("the refusal is not a 403".into());
558        }
559        Ok(())
560    }
561
562    /// The three loopback spellings, each with and without a port.
563    ///
564    /// These are the values the specification's own conformance suite calls
565    /// valid localhost, and a server that refuses them refuses every browser
566    /// client and the suite with it.
567    #[test]
568    fn a_loopback_origin_is_answered() -> Result<(), TestError> {
569        let policy = policy()?;
570        for host in [
571            "localhost:8080",
572            "127.0.0.1:8080",
573            "[::1]:8080",
574            "localhost",
575        ] {
576            let origin = format!("http://{host}");
577            if let Err(refusal) = policy.check(Some(&origin)) {
578                return Err(format!("{origin} was refused as {refusal:?}").into());
579            }
580        }
581        Ok(())
582    }
583
584    /// Excusing loopback must not become excusing anything that looks like it.
585    ///
586    /// The whole defence rests on this comparison being whole rather than a
587    /// suffix: `localhost.evil.example` and `127.0.0.1.evil.example` are
588    /// attacker-controlled names that resolve to loopback, and an allowlist
589    /// that answers them is a rebinding defence that rebinds.
590    #[test]
591    fn a_name_that_only_looks_like_loopback_is_refused() -> Result<(), TestError> {
592        let policy = policy()?;
593        for origin in [
594            "http://localhost.evil.example",
595            "http://127.0.0.1.evil.example",
596            "http://notlocalhost",
597            "http://127.0.0.2",
598            "http://localhost:8080.evil.example",
599        ] {
600            let refused = policy.check(Some(origin)).err();
601            if refused.is_none() {
602                return Err(format!("{origin} was answered").into());
603            }
604        }
605        Ok(())
606    }
607
608    /// `Host` is the header the attack controls, so it is checked too.
609    ///
610    /// `Host` is absent from no non-browser client that reaches a real
611    /// server, and a rebinding request names the attacker's domain in it.
612    #[test]
613    fn a_loopback_host_is_answered_and_a_foreign_one_is_not() -> Result<(), TestError> {
614        let policy = policy()?;
615        for host in [
616            "localhost:8080",
617            "127.0.0.1:8080",
618            "[::1]:8080",
619            "localhost",
620        ] {
621            if let Err(refusal) = policy.check_host(Some(host)) {
622                return Err(format!("Host {host} was refused as {refusal:?}").into());
623            }
624        }
625        for host in [
626            "evil.example",
627            "evil.example:8080",
628            "localhost.evil.example",
629        ] {
630            let refused = policy.check_host(Some(host)).err();
631            if refused.is_none() {
632                return Err(format!("Host {host} was answered").into());
633            }
634        }
635        Ok(())
636    }
637
638    /// A stripped `Host` is refused rather than waved through.
639    ///
640    /// Absent is not "allowed" here even though it is for `Origin`: `Origin`
641    /// is legitimately absent from a CLI, but `Host` is required on every
642    /// HTTP/1.1 request, so its absence is evidence of a proxy rewriting the
643    /// request and not of a client that had no name to give.
644    #[test]
645    fn an_absent_host_is_refused() -> Result<(), TestError> {
646        if policy()?.check_host(None).is_ok() {
647            return Err("a request with no Host was answered".into());
648        }
649        Ok(())
650    }
651
652    /// A configured origin answers its own host, whatever the port.
653    ///
654    /// An operator who names an origin they serve has answered the host
655    /// question; requiring the port to match as well would refuse a request
656    /// that arrived on a port the deployment redirects here.
657    #[test]
658    fn a_configured_origin_answers_its_own_host() -> Result<(), TestError> {
659        let policy = OriginPolicy::strict(None).allowing(Origin::from_str("https://mcp.example")?);
660        for host in ["mcp.example", "mcp.example:8443"] {
661            if let Err(refusal) = policy.check_host(Some(host)) {
662                return Err(format!("Host {host} was refused as {refusal:?}").into());
663            }
664        }
665        if policy.check_host(Some("other.example")).is_ok() {
666            return Err("an unconfigured host was answered".into());
667        }
668        Ok(())
669    }
670}