Harden Calendar connector trust boundaries
This commit is contained in:
@@ -17,6 +17,13 @@ from defusedxml import ElementTree as SafeElementTree
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.security.outbound_http import (
|
||||
OutboundHttpError,
|
||||
bounded_response_bytes,
|
||||
build_outbound_http_opener,
|
||||
validate_outbound_http_url,
|
||||
)
|
||||
|
||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVReportResult, CalDAVSyncUnsupported, ensure_collection_url
|
||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
||||
@@ -198,11 +205,12 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
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 = validate_outbound_http_url(candidate, label="Calendar source redirect URL")
|
||||
candidate_origin = _parsed_http_origin(
|
||||
urllib.parse.urlparse(candidate),
|
||||
label="Calendar source redirect URL",
|
||||
)
|
||||
except CalendarError:
|
||||
except (CalendarError, OutboundHttpError):
|
||||
return None
|
||||
if candidate_origin != self._source_origin:
|
||||
return None
|
||||
@@ -696,10 +704,13 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
auth_type = payload.auth_type or (source.auth_type if source else "none")
|
||||
username = payload.username if payload.username is not None else (source.username if source else None)
|
||||
credential_ref = payload.credential_ref if payload.credential_ref is not None else (source.credential_ref if source else None)
|
||||
if payload.credential_ref is not None and (source is None or payload.credential_ref != source.credential_ref):
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a password/token "
|
||||
"or select an existing sync source"
|
||||
)
|
||||
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||
if secret is None and credential_ref:
|
||||
secret = resolve_caldav_credential_ref(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
||||
if secret is None and source is not None:
|
||||
if secret is None and source is not None and credential_ref == source.credential_ref:
|
||||
secret = resolve_caldav_secret(session, source=source)
|
||||
|
||||
if auth_type == "basic":
|
||||
@@ -729,6 +740,10 @@ def discover_caldav_calendars(session: Session, *, tenant_id: str, payload: Cale
|
||||
|
||||
|
||||
def create_sync_source(session: Session, *, tenant_id: str, user_id: str | None, payload: CalendarSyncSourceCreateRequest) -> CalendarSyncSource:
|
||||
if payload.credential_ref is not None:
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a password or bearer token"
|
||||
)
|
||||
calendar = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||
calendar = (
|
||||
session.query(CalendarCollection)
|
||||
@@ -812,6 +827,10 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
if payload.credential_ref is not None and payload.credential_ref != source.credential_ref:
|
||||
raise CalendarError(
|
||||
"Caller-supplied credential references are not accepted; provide a replacement password or bearer token"
|
||||
)
|
||||
normalized_collection_url = (
|
||||
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||
if payload.collection_url is not None
|
||||
@@ -1064,7 +1083,12 @@ def retire_sync_source(session: Session, *, tenant_id: str, source: CalendarSync
|
||||
reason="CalDAV source was retired before queued external changes were delivered",
|
||||
)
|
||||
source.deleted_at = deleted_at
|
||||
delete_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
||||
delete_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
|
||||
|
||||
def retire_caldav_source(session: Session, *, tenant_id: str, source: CalendarSyncSource, deleted_at: datetime) -> None:
|
||||
@@ -1095,12 +1119,12 @@ def store_caldav_credential(
|
||||
source: CalendarSyncSource,
|
||||
secret: str,
|
||||
) -> str:
|
||||
provider = secret_provider()
|
||||
name = f"caldav:{source.id}:{source.auth_type}"
|
||||
if provider is not None:
|
||||
return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
|
||||
|
||||
existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
||||
existing = internal_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
if existing is None:
|
||||
existing = CalendarSyncCredential(
|
||||
tenant_id=tenant_id,
|
||||
@@ -1111,25 +1135,47 @@ def store_caldav_credential(
|
||||
)
|
||||
session.add(existing)
|
||||
session.flush()
|
||||
|
||||
provider = secret_provider()
|
||||
name = f"caldav:{source.id}:{source.auth_type}"
|
||||
if provider is not None:
|
||||
provider_ref = str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
|
||||
existing.secret_encrypted = None
|
||||
existing.metadata_ = {"source_id": source.id, "provider_ref": provider_ref}
|
||||
else:
|
||||
existing.secret_encrypted = encrypt_secret(secret)
|
||||
existing.metadata_ = {"source_id": source.id}
|
||||
existing.credential_kind = credential_kind_for_auth_type(source.auth_type)
|
||||
existing.label = source.display_name or source.collection_url
|
||||
existing.secret_encrypted = encrypt_secret(secret)
|
||||
existing.deleted_at = None
|
||||
existing.metadata_ = {"source_id": source.id}
|
||||
session.flush()
|
||||
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
|
||||
|
||||
|
||||
def delete_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> None:
|
||||
def delete_caldav_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str,
|
||||
credential_ref: str | None,
|
||||
) -> None:
|
||||
if not credential_ref:
|
||||
return
|
||||
provider = secret_provider()
|
||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
||||
provider.delete_secret(credential_ref)
|
||||
credential = internal_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source_id,
|
||||
credential_ref=credential_ref,
|
||||
)
|
||||
if credential is None:
|
||||
# Legacy external/env references have no locally provable ownership and
|
||||
# must never be passed to a provider delete operation.
|
||||
return
|
||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
||||
if credential is not None:
|
||||
credential.deleted_at = utcnow()
|
||||
provider_ref = _credential_provider_ref(credential)
|
||||
provider = secret_provider()
|
||||
if provider is not None and provider_ref:
|
||||
provider.delete_secret(provider_ref)
|
||||
credential.deleted_at = utcnow()
|
||||
|
||||
|
||||
def resolve_caldav_secret(
|
||||
@@ -1147,27 +1193,59 @@ def resolve_caldav_secret(
|
||||
return bearer_token
|
||||
if not source.credential_ref:
|
||||
return None
|
||||
if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
||||
return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
||||
provider = secret_provider()
|
||||
if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
||||
secret = provider.read_secret(source.credential_ref)
|
||||
if secret:
|
||||
return secret
|
||||
credential = internal_caldav_credential(session, tenant_id=source.tenant_id, credential_ref=source.credential_ref)
|
||||
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None
|
||||
credential = internal_caldav_credential(
|
||||
session,
|
||||
tenant_id=source.tenant_id,
|
||||
source_id=source.id,
|
||||
credential_ref=source.credential_ref,
|
||||
)
|
||||
if credential is None:
|
||||
raise CalendarError(
|
||||
"The sync source credential reference is not a server-owned credential for this tenant/source"
|
||||
)
|
||||
provider_ref = _credential_provider_ref(credential)
|
||||
if provider_ref:
|
||||
provider = secret_provider()
|
||||
if provider is None:
|
||||
return None
|
||||
return provider.read_secret(provider_ref)
|
||||
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||
|
||||
|
||||
def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None:
|
||||
if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
||||
return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
||||
provider = secret_provider()
|
||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
||||
secret = provider.read_secret(credential_ref)
|
||||
if secret:
|
||||
return secret
|
||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
||||
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else None
|
||||
def resolve_caldav_credential_ref(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_id: str,
|
||||
credential_ref: str,
|
||||
) -> str | None:
|
||||
credential = internal_caldav_credential(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_id=source_id,
|
||||
credential_ref=credential_ref,
|
||||
)
|
||||
if credential is None:
|
||||
return None
|
||||
provider_ref = _credential_provider_ref(credential)
|
||||
if provider_ref:
|
||||
provider = secret_provider()
|
||||
return provider.read_secret(provider_ref) if provider is not None else None
|
||||
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||
|
||||
|
||||
def resolve_trusted_deployment_caldav_credential_ref(credential_ref: str) -> str | None:
|
||||
"""Resolve an env reference only for trusted, out-of-band deployment code.
|
||||
|
||||
HTTP/API source configuration deliberately never calls this function.
|
||||
"""
|
||||
|
||||
if not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
||||
raise CalendarError("Trusted deployment credential references must use the env: prefix")
|
||||
env_name = credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX)
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name):
|
||||
raise CalendarError("Trusted deployment credential reference contains an invalid environment variable name")
|
||||
return os.environ.get(env_name)
|
||||
|
||||
|
||||
def caldav_client_for_source(
|
||||
@@ -1242,22 +1320,37 @@ def http_request(
|
||||
url,
|
||||
label="Calendar source request URL",
|
||||
)
|
||||
try:
|
||||
url = validate_outbound_http_url(url, label="Calendar source request URL")
|
||||
except OutboundHttpError as exc:
|
||||
raise CalendarError(str(exc)) from exc
|
||||
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))
|
||||
opener = build_outbound_http_opener(_SameOriginRedirectHandler(credential_origin))
|
||||
try:
|
||||
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")
|
||||
payload = bounded_response_bytes(
|
||||
response,
|
||||
headers=response_headers,
|
||||
label="Calendar source response",
|
||||
).decode(response_headers.get("content-charset") or "utf-8", errors="replace")
|
||||
return int(response.status), response_headers, payload
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 304:
|
||||
return 304, {key.lower(): value for key, value in exc.headers.items()}, ""
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
detail = bounded_response_bytes(
|
||||
exc,
|
||||
headers={key.lower(): value for key, value in exc.headers.items()},
|
||||
label="Calendar source error response",
|
||||
).decode("utf-8", errors="replace")
|
||||
except OutboundHttpError as policy_exc:
|
||||
raise CalendarError(str(policy_exc)) from policy_exc
|
||||
raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
|
||||
@@ -1287,11 +1380,17 @@ def source_auth_headers(
|
||||
return {}
|
||||
|
||||
|
||||
def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None:
|
||||
if not credential_ref:
|
||||
def internal_caldav_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_ref: str | None,
|
||||
source_id: str | None = None,
|
||||
) -> CalendarSyncCredential | None:
|
||||
if not credential_ref or not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
||||
return None
|
||||
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||
return (
|
||||
credential = (
|
||||
session.query(CalendarSyncCredential)
|
||||
.filter(
|
||||
CalendarSyncCredential.tenant_id == tenant_id,
|
||||
@@ -1300,6 +1399,16 @@ def internal_caldav_credential(session: Session, *, tenant_id: str, credential_r
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if credential is None or source_id is None:
|
||||
return credential
|
||||
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
|
||||
return credential if metadata.get("source_id") == source_id else None
|
||||
|
||||
|
||||
def _credential_provider_ref(credential: CalendarSyncCredential) -> str | None:
|
||||
metadata = credential.metadata_ if isinstance(credential.metadata_, dict) else {}
|
||||
value = metadata.get("provider_ref")
|
||||
return str(value) if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def credential_kind_for_auth_type(auth_type: str) -> str:
|
||||
@@ -2390,6 +2499,9 @@ def mark_calendar_sync_source(calendar: CalendarCollection, source: CalendarSync
|
||||
|
||||
|
||||
def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||
has_server_owned_credential = bool(
|
||||
source.credential_ref and source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||
)
|
||||
return {
|
||||
"id": source.id,
|
||||
"tenant_id": source.tenant_id,
|
||||
@@ -2399,8 +2511,8 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
||||
"display_name": source.display_name,
|
||||
"auth_type": source.auth_type,
|
||||
"username": source.username,
|
||||
"credential_ref": source.credential_ref,
|
||||
"has_credential": bool(source.credential_ref),
|
||||
"credential_ref": None,
|
||||
"has_credential": has_server_owned_credential,
|
||||
"sync_enabled": bool(source.sync_enabled),
|
||||
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
||||
"sync_direction": source.sync_direction,
|
||||
|
||||
Reference in New Issue
Block a user