Skip to content

Commit b7d2a9a

Browse files
fix(server): compare allowed_hosts and allowed_origins case-insensitively
- Normalize host/origin and allowed lists to lowercase in TransportSecurityMiddleware - Ensures RFC 9110 compliant case-insensitive hostname and origin matching - Prevents 421/403 rejections when uppercase hostnames are configured (e.g. on Windows) - Add tests for uppercase and mixed-case host and origin validation Fixes #3437
1 parent d060b36 commit b7d2a9a

2 files changed

Lines changed: 42 additions & 6 deletions

File tree

src/mcp/server/transport_security.py

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

56+
host_lower = host.lower()
57+
allowed_hosts = [allowed.lower() for allowed in self.settings.allowed_hosts]
58+
5659
# Check exact match first
57-
if host in self.settings.allowed_hosts:
60+
if host_lower in allowed_hosts:
5861
return True
5962

6063
# Check wildcard port patterns
61-
for allowed in self.settings.allowed_hosts:
64+
for allowed in allowed_hosts:
6265
if allowed.endswith(":*"):
6366
# Extract base host from pattern
6467
base_host = allowed[:-2]
6568
# Check if the actual host starts with base host and has a port
66-
if host.startswith(base_host + ":"):
69+
if host_lower.startswith(base_host + ":"):
6770
return True
6871

6972
logger.warning(f"Invalid Host header: {host}")
@@ -75,17 +78,20 @@ def _validate_origin(self, origin: str | None) -> bool:
7578
if not origin:
7679
return True
7780

81+
origin_lower = origin.lower()
82+
allowed_origins = [allowed.lower() for allowed in self.settings.allowed_origins]
83+
7884
# Check exact match first
79-
if origin in self.settings.allowed_origins:
85+
if origin_lower in allowed_origins:
8086
return True
8187

8288
# Check wildcard port patterns
83-
for allowed in self.settings.allowed_origins:
89+
for allowed in allowed_origins:
8490
if allowed.endswith(":*"):
8591
# Extract base origin from pattern
8692
base_origin = allowed[:-2]
8793
# Check if the actual origin starts with base origin and has a port
88-
if origin.startswith(base_origin + ":"):
94+
if origin_lower.startswith(base_origin + ":"):
8995
return True
9096

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

tests/server/test_transport_security.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ 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+
pytest.param("GOOD.EXAMPLE", None, None, id="host-exact-uppercase"),
49+
pytest.param("Good.Example", "HTTP://GOOD.EXAMPLE", None, id="host-origin-mixedcase"),
50+
pytest.param("WILD.EXAMPLE:9000", "http://WILD.EXAMPLE:9000", None, id="wildcard-uppercase"),
4851
],
4952
)
5053
async def test_validate_request_checks_host_then_origin(
@@ -56,6 +59,33 @@ async def test_validate_request_checks_host_then_origin(
5659
assert (None if response is None else response.status_code) == expected
5760

5861

62+
@pytest.mark.anyio
63+
async def test_validate_request_case_insensitive_with_uppercase_allowed_settings() -> None:
64+
"""Uppercase entries in allowed_hosts and allowed_origins match lowercase and mixed-case requests."""
65+
settings = TransportSecuritySettings(
66+
enable_dns_rebinding_protection=True,
67+
allowed_hosts=["MYHOST", "MYWILD:*"],
68+
allowed_origins=["HTTP://MYORIGIN", "HTTP://MYWILDORIGIN:*"],
69+
)
70+
middleware = TransportSecurityMiddleware(settings)
71+
72+
# Exact host lowercase & mixed-case
73+
assert await middleware.validate_request(_request("myhost", None)) is None
74+
assert await middleware.validate_request(_request("MyHost", None)) is None
75+
76+
# Wildcard host lowercase & mixed-case
77+
assert await middleware.validate_request(_request("mywild:8000", None)) is None
78+
assert await middleware.validate_request(_request("MyWild:8000", None)) is None
79+
80+
# Exact origin lowercase & mixed-case
81+
assert await middleware.validate_request(_request("myhost", "http://myorigin")) is None
82+
assert await middleware.validate_request(_request("myhost", "http://MyOrigin")) is None
83+
84+
# Wildcard origin lowercase & mixed-case
85+
assert await middleware.validate_request(_request("myhost", "http://mywildorigin:8000")) is None
86+
assert await middleware.validate_request(_request("myhost", "http://MyWildOrigin:8000")) is None
87+
88+
5989
@pytest.mark.anyio
6090
async def test_validate_request_skips_host_and_origin_when_protection_is_disabled() -> None:
6191
"""With DNS-rebinding protection off, any Host/Origin is accepted."""

0 commit comments

Comments
 (0)