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}