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