Skip to content
Closed
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
43 changes: 40 additions & 3 deletions nitrostack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@
OAuthService,
generate_www_authenticate_header,
)
from nitrostack.auth.cimd import (
CimdFetchError,
CimdValidationError,
is_blocked_ip,
resolve_cimd,
validate_client_identifier_url,
)
from nitrostack.auth.oauth_security import (
AuthorizationIssuerMismatchError,
validate_authorization_iss,
)
from nitrostack.auth.pkce import (
generate_code_challenge,
generate_code_verifier,
Expand Down Expand Up @@ -118,9 +129,13 @@
from nitrostack.testing import (
NitroTestingModule,
)
from nitrostack.testing import (
NitroTestingModule,
)
from nitrostack.protocol.version import MODERN_PROTOCOL_VERSION
from nitrostack.protocol.errors import JsonRpcErrorCode
from nitrostack.protocol.mrtr import InputRequest, InputRequiredResult, accepted_content, input_required
from nitrostack.tasks import InMemoryTaskStore, TaskAccessContext, TaskStore
from nitrostack.tasks.authorization import check_task_access, extract_task_access_context
from nitrostack.runtime import StatelessInvariants, assert_stateless_headers
from nitrostack.transports import wrap_stateless_transport, StatelessIngressPipeline


__all__ = [
Expand Down Expand Up @@ -165,6 +180,13 @@
"OAuthModule",
"OAuthService",
"generate_www_authenticate_header",
"validate_client_identifier_url",
"resolve_cimd",
"is_blocked_ip",
"CimdValidationError",
"CimdFetchError",
"validate_authorization_iss",
"AuthorizationIssuerMismatchError",
"generate_code_challenge",
"generate_code_verifier",
"generate_pkce_params",
Expand Down Expand Up @@ -207,4 +229,19 @@
"RESOURCE_MIME_TYPE_MCP_APP",
"RESOURCE_MIME_TYPE_OPENAI",
"NitroTestingModule",
"MODERN_PROTOCOL_VERSION",
"JsonRpcErrorCode",
"InputRequest",
"InputRequiredResult",
"accepted_content",
"input_required",
"TaskAccessContext",
"TaskStore",
"InMemoryTaskStore",
"check_task_access",
"extract_task_access_context",
"StatelessInvariants",
"assert_stateless_headers",
"wrap_stateless_transport",
"StatelessIngressPipeline",
]
223 changes: 223 additions & 0 deletions nitrostack/auth/cimd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Client ID Metadata Document (CIMD) resolution with SSRF defenses (Doc 08)."""

from __future__ import annotations

import asyncio
import ipaddress
import json
import socket
import urllib.error
import urllib.parse
import urllib.request
from typing import Any

from nitrostack.protocol.constants import MAX_CIMD_BYTES

CIMD_FETCH_TIMEOUT_SEC = 5.0
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})

_BLOCKED_IPV4_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
)
)

_BLOCKED_IPV6_NETWORKS = tuple(
ipaddress.ip_network(cidr)
for cidr in (
"::1/128",
"fc00::/7",
"fe80::/10",
"ff00::/8",
"100::/64",
"2001:db8::/32",
)
)


class CimdValidationError(ValueError):
"""Raised when a client identifier URL or metadata document is invalid."""


class CimdFetchError(ValueError):
"""Raised when a CIMD document cannot be fetched safely."""


def validate_client_identifier_url(client_id_url: str, *, allow_loopback: bool = False) -> str:
"""
Validate a CIMD ``client_id`` URL before any network fetch (Doc 08 §3).

Returns the normalized URL string on success.
"""
if not client_id_url or not isinstance(client_id_url, str):
raise CimdValidationError("client_id must be a non-empty URL string")

parsed = urllib.parse.urlparse(client_id_url.strip())
scheme = (parsed.scheme or "").lower()
hostname = (parsed.hostname or "").lower()

if scheme == "https":
pass
elif scheme == "http" and allow_loopback and hostname in _LOOPBACK_HOSTS:
pass
else:
raise CimdValidationError("client_id must use https:// (or http:// on loopback when allowed)")

if parsed.username or parsed.password:
raise CimdValidationError("client_id URL must not contain userinfo")

if parsed.fragment:
raise CimdValidationError("client_id URL must not contain a fragment")

path = parsed.path or ""
if not path or path == "/":
raise CimdValidationError("client_id URL must include a non-root path")

segments = [segment for segment in path.split("/") if segment]
if any(segment in {".", ".."} for segment in segments):
raise CimdValidationError("client_id URL path must not contain '.' or '..' segments")

if not hostname:
raise CimdValidationError("client_id URL must include a hostname")

return client_id_url.strip()


def is_blocked_ip(ip_str: str) -> bool:
"""Return True when ``ip_str`` resolves to a RFC 6890 special-use address."""
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return True

if isinstance(ip, ipaddress.IPv4Address):
return any(ip in network for network in _BLOCKED_IPV4_NETWORKS)

if ip.ipv4_mapped is not None:
return is_blocked_ip(str(ip.ipv4_mapped))

if any(ip in network for network in _BLOCKED_IPV6_NETWORKS):
return True

return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast or ip.is_reserved


async def assert_safe_fetch_target(url_str: str, *, allow_loopback: bool = False) -> None:
"""DNS pre-resolution and IP range filtering (Doc 08 §4.1)."""
validate_client_identifier_url(url_str, allow_loopback=allow_loopback)
parsed = urllib.parse.urlparse(url_str)
hostname = parsed.hostname
if not hostname:
raise CimdFetchError(f"Invalid hostname in URL: {url_str}")

if allow_loopback and hostname.lower() in _LOOPBACK_HOSTS:
return

loop = asyncio.get_running_loop()
try:
addr_info = await loop.run_in_executor(
None,
lambda: socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM),
)
except socket.gaierror as exc:
raise CimdFetchError(f"DNS resolution failed for {hostname}: {exc}") from exc

resolved_ips = {info[4][0] for info in addr_info if info and info[4]}
if not resolved_ips:
raise CimdFetchError(f"DNS resolution returned no addresses for {hostname}")

for ip in resolved_ips:
if is_blocked_ip(ip):
raise CimdFetchError(f"Destination {hostname} resolved to blocked IP: {ip}")


def _fetch_cimd_bytes(url_str: str, *, timeout_sec: float) -> bytes:
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(
url_str,
code,
"HTTP redirects are not allowed for CIMD fetch",
headers,
fp,
)

opener = urllib.request.build_opener(_NoRedirectHandler)
request = urllib.request.Request(url_str, headers={"Accept": "application/json"}, method="GET")

try:
with opener.open(request, timeout=timeout_sec) as response:
content_length = response.headers.get("Content-Length")
if content_length is not None:
try:
if int(content_length) > MAX_CIMD_BYTES:
raise CimdFetchError(f"CIMD exceeds maximum size of {MAX_CIMD_BYTES} bytes")
except ValueError as exc:
raise CimdFetchError("Invalid Content-Length header") from exc

chunks: list[bytes] = []
total = 0
while True:
chunk = response.read(MAX_CIMD_BYTES - total + 1)
if not chunk:
break
total += len(chunk)
if total > MAX_CIMD_BYTES:
raise CimdFetchError(f"CIMD exceeds maximum size of {MAX_CIMD_BYTES} bytes")
chunks.append(chunk)
return b"".join(chunks)
except urllib.error.HTTPError as exc:
if 300 <= exc.code < 400:
raise CimdFetchError("HTTP redirects are not allowed for CIMD fetch") from exc
raise CimdFetchError(f"CIMD fetch failed with HTTP {exc.code}") from exc
except urllib.error.URLError as exc:
raise CimdFetchError(f"CIMD fetch failed: {exc.reason}") from exc


def _validate_cimd_document(doc: Any, fetched_url: str) -> dict[str, Any]:
if not isinstance(doc, dict):
raise CimdValidationError("CIMD document must be a JSON object")
if doc.get("client_id") != fetched_url:
raise CimdValidationError(
f"CIMD client_id '{doc.get('client_id')}' does not match URL '{fetched_url}'"
)
return doc


async def resolve_cimd(
client_id_url: str,
*,
allow_loopback: bool = False,
timeout_sec: float = CIMD_FETCH_TIMEOUT_SEC,
) -> dict[str, Any]:
"""
Fetch and validate a Client ID Metadata Document (Doc 08 §4).

Applies URL validation, DNS/IP filtering, redirect blocking, timeout,
payload size bounding, and anti-impersonation ``client_id`` checks.
"""
normalized = validate_client_identifier_url(client_id_url, allow_loopback=allow_loopback)
await assert_safe_fetch_target(normalized, allow_loopback=allow_loopback)

body = await asyncio.to_thread(_fetch_cimd_bytes, normalized, timeout_sec=timeout_sec)
if len(body) > MAX_CIMD_BYTES:
raise CimdFetchError(f"CIMD exceeds maximum size of {MAX_CIMD_BYTES} bytes")
try:
document = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CimdValidationError("CIMD document must be valid UTF-8 JSON") from exc

return _validate_cimd_document(document, normalized)
3 changes: 3 additions & 0 deletions nitrostack/auth/oauth_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def build_protected_resource_metadata(service: "OAuthService") -> Dict[str, Any]
"resource": service.resource_uri,
"authorization_servers": service.authorization_servers,
"scopes_supported": service.scopes_supported,
"bearer_methods_supported": ["header"],
}


Expand All @@ -72,6 +73,8 @@ def is_client_registration_enabled(service: "OAuthService") -> bool:
Requires BOTH an explicit opt-in (`enable_client_registration`, from config or
`OAUTH_ENABLE_CLIENT_REGISTRATION=true`) AND a configured client id — never a
literal default. Without a configured client id there is nothing to hand back.

Deprecated on MCP 2026-07-28 in favor of Client ID Metadata Documents (CIMD).
"""
return bool(service.enable_client_registration and service.static_client_id)

Expand Down
28 changes: 28 additions & 0 deletions nitrostack/auth/oauth_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""OAuth 2.1 security helpers (Doc 08)."""

from __future__ import annotations


class AuthorizationIssuerMismatchError(ValueError):
"""Raised when RFC 9207 ``iss`` does not match the expected authorization server."""


def validate_authorization_iss(response_iss: str | None, expected_issuer: str) -> None:
"""
Validate RFC 9207 ``iss`` anti-mixup parameter (Doc 08 §5.1).

Authorization responses MUST include ``iss`` and it MUST match the intended
authorization server's issuer identifier.
"""
if not expected_issuer or not expected_issuer.strip():
raise ValueError("expected_issuer is required")

if not response_iss or not str(response_iss).strip():
raise AuthorizationIssuerMismatchError("Authorization response missing required iss parameter")

normalized_expected = expected_issuer.rstrip("/")
normalized_actual = str(response_iss).strip().rstrip("/")
if normalized_actual != normalized_expected:
raise AuthorizationIssuerMismatchError(
f"Authorization iss mismatch: expected '{normalized_expected}', got '{normalized_actual}'"
)
2 changes: 2 additions & 0 deletions nitrostack/core/additional_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def cache(ttl: int = 60):
"""
def decorator(func: Callable):
cache_store: Dict[Tuple[Any, ...], Tuple[Any, float]] = {}
setattr(func, "_mcp_cache_ttl", ttl)

@wraps(func)
async def wrapper(*args, **kwargs):
Expand Down Expand Up @@ -47,6 +48,7 @@ async def wrapper(*args, **kwargs):
return result

copy_mcp_attributes(func, wrapper)
setattr(wrapper, "_mcp_cache_ttl", ttl)
return wrapper
return decorator

Expand Down
Loading