Skip to content

Commit 4b9d961

Browse files
committed
fix(server): compare Host and Origin case-insensitively
RFC 9110 makes the scheme and host case-insensitive, and every WHATWG-URL client - fetch, undici, browsers, and so mcp-remote - lowercases the host before sending it. TransportSecurityMiddleware compared it byte for byte. On Windows that is the default way into the bug rather than an edge case: %COMPUTERNAME% is always uppercase, so deriving allowed_hosts from the machine name yields MYHOST:*, clients send host: myhost:8000, and the server answers 421 to everything while configured exactly as intended. _validate_origin had the same comparison, and an origin is a scheme and a host with no path, so both sides fold. The header is kept as sent for the warning line; only the comparison folds, and a host differing by more than case still fails. Fixes #3437
1 parent 7bb486a commit 4b9d961

2 files changed

Lines changed: 58 additions & 6 deletions

File tree

src/mcp/server/transport_security.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,24 @@ def _validate_host(self, host: str | None) -> bool:
5353
logger.warning("Missing Host header in request")
5454
return False
5555

56+
# RFC 9110: the host is case-insensitive. Clients built on WHATWG URL parsing
57+
# (fetch, undici, browsers) lowercase it before sending, so an `allowed_hosts`
58+
# entry derived from something uppercase - %COMPUTERNAME% on Windows always is -
59+
# never matches, and the server 421s while configured exactly as intended.
60+
# The header is compared folded; `host` is kept as sent for the log line.
61+
folded = host.lower()
62+
5663
# Check exact match first
57-
if host in self.settings.allowed_hosts:
64+
if folded in [allowed.lower() for allowed in self.settings.allowed_hosts]:
5865
return True
5966

6067
# Check wildcard port patterns
6168
for allowed in self.settings.allowed_hosts:
6269
if allowed.endswith(":*"):
6370
# Extract base host from pattern
64-
base_host = allowed[:-2]
71+
base_host = allowed[:-2].lower()
6572
# Check if the actual host starts with base host and has a port
66-
if host.startswith(base_host + ":"):
73+
if folded.startswith(base_host + ":"):
6774
return True
6875

6976
logger.warning(f"Invalid Host header: {host}")
@@ -75,17 +82,21 @@ def _validate_origin(self, origin: str | None) -> bool:
7582
if not origin:
7683
return True
7784

85+
# Same rule, same reason: an origin is a scheme and a host, and RFC 9110 makes
86+
# both case-insensitive. There is no path here to compare case-sensitively.
87+
folded = origin.lower()
88+
7889
# Check exact match first
79-
if origin in self.settings.allowed_origins:
90+
if folded in [allowed.lower() for allowed in self.settings.allowed_origins]:
8091
return True
8192

8293
# Check wildcard port patterns
8394
for allowed in self.settings.allowed_origins:
8495
if allowed.endswith(":*"):
8596
# Extract base origin from pattern
86-
base_origin = allowed[:-2]
97+
base_origin = allowed[:-2].lower()
8798
# Check if the actual origin starts with base origin and has a port
88-
if origin.startswith(base_origin + ":"):
99+
if folded.startswith(base_origin + ":"):
89100
return True
90101

91102
logger.warning(f"Invalid Origin header: {origin}")

tests/server/test_transport_security.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ def _request(host: str | None, origin: str | None, content_type: str | None = "a
4545
pytest.param("good.example", "http://evil.example:9000", 403, id="origin-wildcard-base-mismatch"),
4646
pytest.param("good.example", "http://good.example", None, id="origin-exact"),
4747
pytest.param("good.example", "http://wild.example:9000", None, id="origin-wildcard-match"),
48+
# RFC 9110: scheme and host are case-insensitive, and WHATWG-URL clients
49+
# (fetch, undici, browsers) send them lowercased whatever was configured.
50+
pytest.param("GOOD.EXAMPLE", None, None, id="host-exact-differing-case"),
51+
pytest.param("WILD.EXAMPLE:9000", None, None, id="host-wildcard-differing-case"),
52+
pytest.param("good.example", "http://GOOD.EXAMPLE", None, id="origin-exact-differing-case"),
53+
pytest.param("good.example", "http://WILD.EXAMPLE:9000", None, id="origin-wildcard-differing-case"),
4854
],
4955
)
5056
async def test_validate_request_checks_host_then_origin(
@@ -56,6 +62,41 @@ async def test_validate_request_checks_host_then_origin(
5662
assert (None if response is None else response.status_code) == expected
5763

5864

65+
@pytest.mark.anyio
66+
@pytest.mark.parametrize(
67+
("host", "origin"),
68+
[
69+
pytest.param("myhost", None, id="host-exact"),
70+
pytest.param("myhost:8000", None, id="host-wildcard"),
71+
pytest.param("myhost", "http://myhost", id="origin-exact"),
72+
pytest.param("myhost", "http://myhost:8000", id="origin-wildcard"),
73+
],
74+
)
75+
async def test_an_uppercase_allowlist_still_matches_what_clients_send(host: str, origin: str | None) -> None:
76+
"""The reported path: %COMPUTERNAME% is always uppercase on Windows.
77+
78+
Deriving the allowlist from the machine name is the obvious thing to do and
79+
yields entries like `MYHOST:*`; every fetch-based client then sends
80+
`host: myhost:8000`, and the server 421s while configured exactly as intended.
81+
"""
82+
settings = TransportSecuritySettings(
83+
enable_dns_rebinding_protection=True,
84+
allowed_hosts=["MYHOST", "MYHOST:*"],
85+
allowed_origins=["http://MYHOST", "http://MYHOST:*"],
86+
)
87+
middleware = TransportSecurityMiddleware(settings)
88+
assert await middleware.validate_request(_request(host, origin)) is None
89+
90+
91+
@pytest.mark.anyio
92+
async def test_case_folding_does_not_widen_the_allowlist() -> None:
93+
"""Folding case must not make a host match that differs by more than case."""
94+
middleware = TransportSecurityMiddleware(SETTINGS)
95+
for host in ("EVIL.EXAMPLE", "good.example.evil.example", "GOOD.EXAMPLEX"):
96+
response = await middleware.validate_request(_request(host, None))
97+
assert response is not None and response.status_code == 421, host
98+
99+
59100
@pytest.mark.anyio
60101
async def test_validate_request_skips_host_and_origin_when_protection_is_disabled() -> None:
61102
"""With DNS-rebinding protection off, any Host/Origin is accepted."""

0 commit comments

Comments
 (0)