Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,23 @@ class McpAuthPolicy:
deny_none: Whether to deny connections with auth_method="none".
Default True (fail-closed).
servers: Explicit per-server auth method allowlist.
default_require_tls: Whether the TLS floor applied to registered
entries also applies to servers not in the allowlist (the
fallback path below). Default True (fail-closed); an unregistered
server name should not get weaker transport guarantees than a
registered one.
"""

def __init__(
self,
default_allowed_methods: list[str] | None = None,
deny_none: bool = True,
servers: list[McpServerEntry] | None = None,
default_require_tls: bool = True,
Comment thread
MohammadHaroonAbuomar marked this conversation as resolved.
):
self._default_methods = set(default_allowed_methods or ["oauth2", "mtls", "bearer"])
self._deny_none = deny_none
self._default_require_tls = default_require_tls
self._servers: dict[str, McpServerEntry] = {}
for s in (servers or []):
self._servers[s.name] = s
Expand Down Expand Up @@ -186,6 +193,26 @@ def check(self, server_name: str, auth_method: str, url: str = "") -> AuthCheckR

# Fall back to default policy
if auth_method in self._default_methods:
# TLS check: an unregistered server name should not get weaker
# transport guarantees than a registered one. Only enforced when
# a URL is actually supplied — an omitted/empty URL stays
# allowed here too, matching the registered-entry gate above.
if self._default_require_tls and url:
try:
scheme = urlparse(url).scheme.lower()
except (ValueError, AttributeError):
scheme = ""
if scheme not in {"https", "wss"}:
return AuthCheckResult(
allowed=False,
server_name=server_name,
auth_method=auth_method,
reason=(
f"Server '{server_name}' is not in the allowlist; the default "
f"policy requires TLS but URL scheme {scheme!r} is not in the "
f"TLS allowlist (https, wss)"
),
)
return AuthCheckResult(
allowed=True,
server_name=server_name,
Expand Down Expand Up @@ -237,4 +264,5 @@ def from_yaml(cls, yaml_content: str) -> "McpAuthPolicy":
default_allowed_methods=policy_data.get("default_allowed_methods"),
deny_none=policy_data.get("deny_none", True),
servers=servers,
default_require_tls=policy_data.get("default_require_tls", True),
)
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,24 @@ def test_result_fields(self):
assert result.allowed
assert len(result.reason) > 0

def test_unregistered_server_with_url_still_requires_tls(self):
# Regression for #3814: a server name absent from the allowlist used
# to skip the TLS gate entirely, even with a plain-http URL.
policy = McpAuthPolicy()
result = policy.check("typo-d-server", auth_method="oauth2", url="http://mcp.internal/tools")
assert not result.allowed
assert "tls" in result.reason.lower()

def test_unregistered_server_with_https_url_is_allowed(self):
policy = McpAuthPolicy()
result = policy.check("new-server", auth_method="oauth2", url="https://mcp.internal/tools")
assert result.allowed

def test_unregistered_server_default_tls_floor_can_be_disabled(self):
policy = McpAuthPolicy(default_require_tls=False)
result = policy.check("legacy-server", auth_method="oauth2", url="http://mcp.internal/tools")
assert result.allowed


class TestFromYaml:
def test_parse_yaml(self):
Expand All @@ -130,3 +148,20 @@ def test_parse_yaml(self):
def test_empty_yaml(self):
policy = McpAuthPolicy.from_yaml("")
assert policy.check("s", auth_method="oauth2").allowed

def test_yaml_default_require_tls_defaults_true(self):
policy = McpAuthPolicy.from_yaml("""
mcp_auth_policy:
default_allowed_methods: [oauth2]
""")
result = policy.check("unregistered", auth_method="oauth2", url="http://mcp.internal/tools")
assert not result.allowed

def test_yaml_default_require_tls_false_disables_the_floor(self):
policy = McpAuthPolicy.from_yaml("""
mcp_auth_policy:
default_allowed_methods: [oauth2]
default_require_tls: false
""")
result = policy.check("legacy-server", auth_method="oauth2", url="http://mcp.internal/tools")
assert result.allowed
7 changes: 6 additions & 1 deletion docs/specs/MCP-SECURITY-GATEWAY-1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ An auth check result MUST contain:
| --- | --- | --- |
| `default_allowed_methods` | `["oauth2", "mtls", "bearer"]` | Must be subset of VALID_AUTH_METHODS |
| `deny_none` | `true` | When true, `none` is always rejected |
| `default_require_tls` | `true` | Applies the same TLS-scheme gate as a registered `McpServerEntry` to servers with no allowlist entry; set `false` to keep the pre-fix behavior for a deployment that depends on it |

**[Default Implementation]**

Expand All @@ -946,7 +947,10 @@ The `check(server_name, auth_method, url="")` method MUST evaluate:
5. **TLS version check:** If the URL uses TLS, verify the minimum
TLS version requirement is met.
6. **Default check:** If no per-server entry exists, check against
`default_allowed_methods`.
`default_allowed_methods`. If `default_require_tls` is true and a
URL is supplied, apply the same TLS-scheme gate as step 4 before
allowing; an omitted/empty URL is not gated, matching step 4's
behavior for that case.

**[Pure Specification]**

Expand All @@ -962,6 +966,7 @@ mcp_auth_policy:
- oauth2
- mtls
- bearer
default_require_tls: true # set false only for a deployment that needs the pre-fix behavior
servers:
- name: "example-server"
url: "https://mcp.example.com"
Expand Down
Loading