Confine calendar connectors to configured origins

This commit is contained in:
2026-07-21 03:16:45 +02:00
parent 68b165db7b
commit a36a9e8c19
6 changed files with 467 additions and 41 deletions

View File

@@ -295,24 +295,13 @@ class CalDAVClient:
return CalDAVWriteResult(href=href, etag=response_etag(response_headers), status=status)
def object_url(self, href: str) -> str:
candidate = urllib.parse.urljoin(self.collection_url, href)
candidate = same_origin_dav_url(
self.collection_url,
href,
label="CalDAV object href",
)
collection_parts = urllib.parse.urlparse(self.collection_url)
candidate_parts = urllib.parse.urlparse(candidate)
if (
candidate_parts.username
or candidate_parts.password
or (
candidate_parts.scheme.lower(),
candidate_parts.hostname,
candidate_parts.port,
)
!= (
collection_parts.scheme.lower(),
collection_parts.hostname,
collection_parts.port,
)
):
raise CalDAVError("CalDAV object href must use the configured collection origin")
collection_path = posixpath.normpath(urllib.parse.unquote(collection_parts.path))
candidate_path = posixpath.normpath(urllib.parse.unquote(candidate_parts.path))
collection_prefix = collection_path.rstrip("/") + "/"
@@ -364,8 +353,14 @@ class CalDAVClient:
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
url = validate_http_url(url)
try:
request = urllib.request.Request(url, data=body, headers=dict(headers), method=method)
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV HTTP(S) URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=body,
headers=dict(headers),
method=method,
)
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated CalDAV URL; redirects remain on origin. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
return response.status, dict(response.headers.items()), response.read()
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers.items()), exc.read()
@@ -504,16 +499,72 @@ def ensure_collection_url(value: str) -> str:
def validate_http_url(value: str) -> str:
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
parsed = urllib.parse.urlparse(value.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise CalDAVError("CalDAV URL must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise CalDAVError("CalDAV URL must not include embedded credentials")
if parsed.query or parsed.fragment:
raise CalDAVError("CalDAV URL must not include a query or fragment")
_url_origin(parsed)
return urllib.parse.urlunparse(parsed)
def absolute_dav_url(base_url: str, href: str) -> str:
return urllib.parse.urljoin(ensure_collection_url(base_url), href)
return same_origin_dav_url(base_url, href, label="CalDAV discovery href")
def same_origin_dav_url(base_url: str, href: str, *, label: str) -> str:
base = ensure_collection_url(base_url)
candidate = validate_http_url(urllib.parse.urljoin(base, href))
if _url_origin(urllib.parse.urlparse(candidate)) != _url_origin(urllib.parse.urlparse(base)):
raise CalDAVError(f"{label} must use the configured collection origin")
return candidate
def _url_origin(parsed: urllib.parse.ParseResult) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalDAVError("CalDAV URL has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
self._source_origin = _url_origin(urllib.parse.urlparse(validate_http_url(source_url)))
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
del fp, msg, headers
try:
candidate = validate_http_url(newurl)
except CalDAVError:
return None
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
return None
method = req.get_method()
data = req.data
if code == 303 and method != "HEAD":
method, data = "GET", None
elif code in {301, 302} and method == "POST":
method, data = "GET", None
forwarded_headers = {
key: value
for key, value in req.header_items()
if key.casefold() not in {"host", "content-length"}
}
return urllib.request.Request( # noqa: S310 - candidate is validated and same-origin.
candidate,
data=data,
headers=forwarded_headers,
origin_req_host=req.origin_req_host,
unverifiable=True,
method=method,
)
def strip_weak_etag(value: str | None) -> str | None:

View File

@@ -158,14 +158,57 @@ def normalize_sync_source_url(source_kind: str, value: str) -> str:
def validate_http_url(url: str, *, label: str) -> str:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
parsed = urllib.parse.urlparse(url.strip())
if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise CalendarError(f"{label} must be an absolute HTTP(S) URL")
if parsed.username or parsed.password:
raise CalendarError(f"{label} must not include embedded credentials")
_parsed_http_origin(parsed, label=label)
return urllib.parse.urlunparse(parsed)
def same_origin_http_url(base_url: str, candidate_url: str, *, label: str) -> str:
base = validate_http_url(base_url, label=label)
candidate = validate_http_url(candidate_url, label=label)
if _parsed_http_origin(urllib.parse.urlparse(candidate), label=label) != _parsed_http_origin(
urllib.parse.urlparse(base),
label=label,
):
raise CalendarError(f"{label} must remain on the configured source origin")
return candidate
def _parsed_http_origin(parsed: urllib.parse.ParseResult, *, label: str) -> tuple[str, str, int]:
try:
port = parsed.port
except ValueError as exc:
raise CalendarError(f"{label} has an invalid port") from exc
scheme = parsed.scheme.lower()
if port is None:
port = 443 if scheme == "https" else 80
return scheme, (parsed.hostname or "").lower(), port
class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, source_url: str) -> None:
super().__init__()
parsed = urllib.parse.urlparse(validate_http_url(source_url, label="Calendar source URL"))
self._source_origin = _parsed_http_origin(parsed, label="Calendar source URL")
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
try:
candidate = validate_http_url(newurl, label="Calendar source redirect URL")
candidate_origin = _parsed_http_origin(
urllib.parse.urlparse(candidate),
label="Calendar source redirect URL",
)
except CalendarError:
return None
if candidate_origin != self._source_origin:
return None
return super().redirect_request(req, fp, code, msg, headers, candidate)
def normalize_graph_collection_url(value: str) -> str:
url = value.strip()
if url.startswith("/"):
@@ -1186,12 +1229,28 @@ def http_request(
headers: dict[str, str] | None = None,
body: str | bytes | None = None,
timeout: int = 30,
credential_origin: str | None = None,
) -> tuple[int, dict[str, str], str]:
data = body.encode("utf-8") if isinstance(body, str) else body
url = validate_http_url(url, label="Calendar source URL")
request = urllib.request.Request(url, data=data, method=method, headers=headers or {})
credential_origin = validate_http_url(
credential_origin or url,
label="Calendar credential origin",
)
url = same_origin_http_url(
credential_origin,
url,
label="Calendar source request URL",
)
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
url,
data=data,
method=method,
headers=headers or {},
)
opener = urllib.request.build_opener(_SameOriginRedirectHandler(credential_origin))
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 - validated admin-configured sync source URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
with opener.open(request, timeout=timeout) as response: # noqa: S310 - validated, origin-confined Calendar URL. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = {key.lower(): value for key, value in response.headers.items()}
payload = response.read().decode(response_headers.get("content-charset") or "utf-8", errors="replace")
return int(response.status), response_headers, payload
@@ -1478,7 +1537,16 @@ def sync_graph_source(
seen_hrefs: set[str] = set()
delta_link: str | None = None
for _page in range(50):
_status, _headers, body = http_request(next_url, headers=headers)
next_url = same_origin_http_url(
source.collection_url,
next_url,
label="Microsoft Graph continuation URL",
)
_status, _headers, body = http_request(
next_url,
headers=headers,
credential_origin=source.collection_url,
)
payload = json.loads(body or "{}")
for item in payload.get("value", []):
href = str(item.get("id") or "")
@@ -1493,10 +1561,20 @@ def sync_graph_source(
stats.updated += updated
stats.unchanged += unchanged
next_link = payload.get("@odata.nextLink")
delta_link = payload.get("@odata.deltaLink") or delta_link
raw_delta_link = payload.get("@odata.deltaLink")
if raw_delta_link:
delta_link = same_origin_http_url(
source.collection_url,
str(raw_delta_link),
label="Microsoft Graph delta URL",
)
if not next_link:
break
next_url = str(next_link)
next_url = same_origin_http_url(
source.collection_url,
str(next_link),
label="Microsoft Graph continuation URL",
)
else:
raise CalendarError("Microsoft Graph sync returned too many pages")
if stats.full_sync: