Files
hermes-caldav-mcp/src/hermes_caldav_mcp/transport.py
xeno 93b9c9f913
Some checks failed
Offline checks / test (3.11) (push) Has been cancelled
Offline checks / test (3.13) (push) Has been cancelled
feat: publish Hermes Nextcloud calendar MCP under AGPL-3.0
2026-09-30 20:27:25 +00:00

283 lines
11 KiB
Python

"""Constrain every authenticated request before constructing its headers."""
import base64
import re
import ssl
from urllib.parse import quote, unquote, urlsplit
import httpx
from .types import MAX_DAV_BYTES, ErrorCode, HTTPResult, SafeError
def path(value: str) -> str:
if (
not isinstance(value, str)
or not value.startswith("/")
or value.startswith("//")
):
raise SafeError(ErrorCode.INVALID_INPUT)
if re.search(r"[\x00-\x1f\x7f\\?#]", value) or re.search(
r"%(?![0-9a-fA-F]{2})", value
):
raise SafeError(ErrorCode.INVALID_INPUT)
segments = value.split("/")[1:]
normalized = []
for i, segment in enumerate(segments):
try:
decoded = unquote(segment, encoding="utf-8", errors="strict")
except UnicodeError:
raise SafeError(ErrorCode.INVALID_INPUT) from None
if decoded in (".", "..") or re.search(r"[%/\\\x00-\x1f\x7f]", decoded):
raise SafeError(ErrorCode.INVALID_INPUT)
if not decoded and i != len(segments) - 1:
raise SafeError(ErrorCode.INVALID_INPUT)
normalized.append(quote(decoded, safe="-._~"))
return "/" + "/".join(normalized)
def strong_etag(value: str | None) -> bool:
return (
isinstance(value, str)
and len(value) <= 256
and bool(re.fullmatch(r'"[\x21\x23-\x7e]*"', value))
)
def event_id(calendar, href: str) -> str:
resource = path(href)
root = path(calendar.href)
if not root.endswith("/") or not resource.startswith(root):
raise SafeError(ErrorCode.INVALID_INPUT)
filename = resource[len(root) :]
if not filename or "/" in filename:
raise SafeError(ErrorCode.INVALID_INPUT)
return base64.urlsafe_b64encode(filename.encode()).decode().rstrip("=")
def event_href(calendar, identifier: str) -> str:
if not isinstance(identifier, str) or not re.fullmatch(
r"[A-Za-z0-9_-]{1,2048}", identifier
):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
filename = base64.b64decode(
identifier + "=" * (-len(identifier) % 4), altchars=b"-_", validate=True
).decode()
href = path(calendar.href) + filename
if event_id(calendar, href) != identifier:
raise ValueError("noncanonical")
return href
except (ValueError, UnicodeError):
raise SafeError(ErrorCode.INVALID_INPUT) from None
def endpoint(value: str):
if not isinstance(value, str) or re.search(r"[\x00-\x20\x7f\\]", value):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
parts = urlsplit(value)
if (
parts.scheme != "https"
or not parts.hostname
or parts.username is not None
or parts.password is not None
or parts.query
or parts.fragment
or "?" in value
or "#" in value
):
raise ValueError("url")
host = parts.hostname.encode("idna").decode().lower()
if "%" in host:
raise ValueError("host")
port = parts.port or 443
if not 1 <= port <= 65535:
raise ValueError("port")
host_url = f"[{host}]" if ":" in host else host
origin = f"https://{host_url}" + (f":{port}" if port != 443 else "")
return origin, path(parts.path or "/"), (host, port)
except (ValueError, UnicodeError):
raise SafeError(ErrorCode.INVALID_INPUT) from None
class CalDAVClient:
def __init__(self, profile, *, transport=None, discovery=False):
self.write_started = False
self.profile = profile
self.discovery = discovery
self.origin, base, self.authority = endpoint(profile.base_url)
self.home = path(profile.calendar_home)
if not self.home.startswith(
base.rstrip("/") + "/remote.php/dav/calendars/"
) or not self.home.endswith("/"):
raise SafeError(ErrorCode.INVALID_INPUT)
self.roots = tuple(path(c.href) for c in profile.calendars)
if any(
not root.startswith(self.home)
or not root.endswith("/")
or "/" in root[len(self.home) : -1]
or root == self.home
for root in self.roots
):
raise SafeError(ErrorCode.INVALID_INPUT)
if (
not profile.username
or ":" in profile.username
or any(ord(c) < 32 for c in profile.username)
):
raise SafeError(ErrorCode.INVALID_INPUT)
try:
self.ssl_context = ssl.create_default_context(cafile=profile.ca_bundle)
except (OSError, ssl.SSLError):
raise SafeError(ErrorCode.NETWORK_ERROR) from None
self.http = httpx.AsyncClient(
verify=self.ssl_context,
trust_env=False,
follow_redirects=False,
transport=transport,
timeout=httpx.Timeout(20, connect=10),
limits=httpx.Limits(max_connections=2, max_keepalive_connections=0),
)
async def __aenter__(self):
return self
async def __aexit__(self, *args):
await self.http.aclose()
def validated_href(self, href: str, *, discovered=False) -> str:
if href.startswith("https://"):
origin, resource, authority = endpoint(href)
if authority != self.authority or origin != self.origin:
raise SafeError(ErrorCode.INVALID_INPUT)
else:
resource = path(href)
if discovered:
if resource == self.home:
return resource
if (
resource.startswith(self.home)
and resource.endswith("/")
and "/" not in resource[len(self.home) : -1]
and resource != self.home
):
return resource
for root in self.roots:
if resource == root or (
resource.startswith(root)
and resource[len(root) :]
and "/" not in resource[len(root) :]
):
return resource
raise SafeError(ErrorCode.INVALID_INPUT)
async def request(
self,
method: str,
href: str,
*,
body=None,
expected_revision=None,
create=False,
max_bytes=MAX_DAV_BYTES,
) -> HTTPResult:
if (
method not in {"PROPFIND", "REPORT", "GET", "PUT"}
or not 1 <= max_bytes <= MAX_DAV_BYTES
):
raise SafeError(ErrorCode.INVALID_INPUT)
resource = self.validated_href(href, discovered=self.discovery)
if self.discovery:
if method != "PROPFIND" or resource != self.home:
raise SafeError(ErrorCode.INVALID_INPUT)
elif (method in {"REPORT", "PROPFIND"}) != resource.endswith("/"):
raise SafeError(ErrorCode.INVALID_INPUT)
if method != "PUT" and (create or expected_revision is not None):
raise SafeError(ErrorCode.INVALID_INPUT)
headers = {"Accept-Encoding": "identity"}
if method == "PROPFIND":
headers.update(
{
"Depth": "1" if self.discovery else "0",
"Content-Type": "application/xml; charset=utf-8",
}
)
elif method == "REPORT":
headers.update(
{"Depth": "1", "Content-Type": "application/xml; charset=utf-8"}
)
elif method == "PUT":
if create == (expected_revision is not None) or (
expected_revision is not None and not strong_etag(expected_revision)
):
raise SafeError(ErrorCode.INVALID_INPUT)
headers["Content-Type"] = "text/calendar; charset=utf-8"
headers["If-None-Match" if create else "If-Match"] = (
"*" if create else expected_revision
)
try:
if method == "PUT":
self.write_started = True
async with self.http.stream(
method,
self.origin + resource,
headers=headers,
content=body,
auth=httpx.BasicAuth(self.profile.username, self.profile.password),
) as response:
status = response.status_code
failures = {
401: ErrorCode.AUTH_FAILED,
403: ErrorCode.PERMISSION_DENIED,
404: ErrorCode.NOT_FOUND,
412: ErrorCode.CONFLICT,
}
if status in failures:
raise SafeError(failures[status])
allowed = {
"PROPFIND": {207},
"REPORT": {207},
"GET": {200},
"PUT": {200, 201, 204},
}
if status not in allowed[method]:
if method == "PUT" and status >= 500:
raise SafeError(ErrorCode.UNKNOWN_WRITE_OUTCOME)
raise SafeError(ErrorCode.BAD_RESPONSE)
etag = response.headers.get("ETag")
if method == "PUT":
# Status and ETag suffice. Consuming an unused response body
# could turn an acknowledged write into a false failure.
return HTTPResult(status, b"", etag if strong_etag(etag) else None)
# Reject compressed responses to bound decompression memory as well as bytes.
if (
response.headers.get("Content-Encoding", "identity").lower()
!= "identity"
):
raise SafeError(ErrorCode.BAD_RESPONSE)
length = response.headers.get("Content-Length")
if length is not None:
try:
if int(length) < 0:
raise ValueError("length")
if int(length) > max_bytes:
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
except ValueError:
raise SafeError(ErrorCode.BAD_RESPONSE) from None
data = bytearray()
async for chunk in response.aiter_bytes():
if len(data) + len(chunk) > max_bytes:
raise SafeError(ErrorCode.LIMIT_EXCEEDED)
data.extend(chunk)
etag = response.headers.get("ETag")
return HTTPResult(
status, bytes(data), etag if strong_etag(etag) else None
)
except httpx.HTTPError:
raise SafeError(
ErrorCode.UNKNOWN_WRITE_OUTCOME
if method == "PUT"
else ErrorCode.NETWORK_ERROR
) from None