Confine calendar connectors to configured origins
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user