Skip to content

Commit a05946d

Browse files
committed
fix(server): require a numeric port for Host/Origin :* allowlist entries
The wildcard matcher used startswith(base + ":"), so wild.example:9000.evil was accepted for wild.example:*. Require the suffix to be digits. Fixes #3463
1 parent 08a3bc8 commit a05946d

2 files changed

Lines changed: 24 additions & 14 deletions

File tree

src/mcp/server/transport_security.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@
1616
"""Default maximum HTTP request body size in bytes (4 MiB)."""
1717

1818

19+
def _matches_wildcard_port(value: str, allowed: str) -> bool:
20+
"""Return True when ``allowed`` is ``base:*`` and ``value`` is ``base:<digits>``.
21+
22+
A prefix check alone accepts ``127.0.0.1:8080.evil`` for ``127.0.0.1:*``.
23+
The port suffix must be digits so the wildcard cannot match a longer host
24+
or origin.
25+
"""
26+
if not allowed.endswith(":*"):
27+
return False
28+
prefix = allowed[:-1] # "base:"
29+
if not value.startswith(prefix):
30+
return False
31+
return value[len(prefix) :].isdigit()
32+
33+
1934
# TODO(Marcelo): We should flatten these settings. To be fair, I don't think we should even have this middleware.
2035
class TransportSecuritySettings(BaseModel):
2136
"""Settings for MCP transport security features.
@@ -57,14 +72,10 @@ def _validate_host(self, host: str | None) -> bool:
5772
if host in self.settings.allowed_hosts:
5873
return True
5974

60-
# Check wildcard port patterns
75+
# Check wildcard port patterns (base:* matches only base:<digits>)
6176
for allowed in self.settings.allowed_hosts:
62-
if allowed.endswith(":*"):
63-
# Extract base host from pattern
64-
base_host = allowed[:-2]
65-
# Check if the actual host starts with base host and has a port
66-
if host.startswith(base_host + ":"):
67-
return True
77+
if _matches_wildcard_port(host, allowed):
78+
return True
6879

6980
logger.warning(f"Invalid Host header: {host}")
7081
return False
@@ -79,14 +90,10 @@ def _validate_origin(self, origin: str | None) -> bool:
7990
if origin in self.settings.allowed_origins:
8091
return True
8192

82-
# Check wildcard port patterns
93+
# Check wildcard port patterns (base:* matches only base:<digits>)
8394
for allowed in self.settings.allowed_origins:
84-
if allowed.endswith(":*"):
85-
# Extract base origin from pattern
86-
base_origin = allowed[:-2]
87-
# Check if the actual origin starts with base origin and has a port
88-
if origin.startswith(base_origin + ":"):
89-
return True
95+
if _matches_wildcard_port(origin, allowed):
96+
return True
9097

9198
logger.warning(f"Invalid Origin header: {origin}")
9299
return False

tests/server/test_transport_security.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,13 @@ def _request(host: str | None, origin: str | None, content_type: str | None = "a
4141
pytest.param("evil.example:9000", None, 421, id="host-wildcard-base-mismatch"),
4242
pytest.param("good.example", None, None, id="host-exact-no-origin"),
4343
pytest.param("wild.example:9000", None, None, id="host-wildcard-match"),
44+
pytest.param("wild.example:9000.evil", None, 421, id="host-wildcard-suffix-rejected"),
45+
pytest.param("wild.example:", None, 421, id="host-wildcard-empty-port"),
4446
pytest.param("good.example", "http://evil.example", 403, id="origin-no-match"),
4547
pytest.param("good.example", "http://evil.example:9000", 403, id="origin-wildcard-base-mismatch"),
4648
pytest.param("good.example", "http://good.example", None, id="origin-exact"),
4749
pytest.param("good.example", "http://wild.example:9000", None, id="origin-wildcard-match"),
50+
pytest.param("good.example", "http://wild.example:9000.evil", 403, id="origin-wildcard-suffix-rejected"),
4851
],
4952
)
5053
async def test_validate_request_checks_host_then_origin(

0 commit comments

Comments
 (0)