Harden Calendar connector trust boundaries
This commit is contained in:
10
README.md
10
README.md
@@ -40,9 +40,13 @@ The first backend implementation stores VEVENT data in two layers:
|
|||||||
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
CalDAV sync is implemented as a calendar-owned backend primitive. A CalDAV source
|
||||||
records the remote collection URL, sync token, ETag/ctag state, username,
|
records the remote collection URL, sync token, ETag/ctag state, username,
|
||||||
credential reference, sync interval, sync direction, and conflict policy.
|
credential reference, sync interval, sync direction, and conflict policy.
|
||||||
Credentials can be supplied transiently for manual sync, referenced from
|
Credentials can be supplied transiently for manual sync. Persisted API-managed
|
||||||
environment variables with `env:NAME`, stored through a platform secret provider
|
sources accept only a password or token: Calendar stores an opaque, tenant- and
|
||||||
when one is available, or stored as encrypted calendar-owned credentials.
|
source-bound local reference, backed by the platform secret provider when one is
|
||||||
|
available or by an encrypted calendar-owned credential otherwise. Caller-selected
|
||||||
|
environment and external-provider references are rejected. Trusted deployment
|
||||||
|
code may resolve an `env:NAME` reference only through the separate deployment
|
||||||
|
configuration helper.
|
||||||
|
|
||||||
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
Inbound sync uses CalDAV `calendar-query` for full sync and `sync-collection`
|
||||||
when a sync token exists. It imports all VEVENT components in a resource and
|
when a sync token exists. It imports all VEVENT components in a resource and
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ requires-python = ">=3.12"
|
|||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.9",
|
||||||
"govoplan-access>=0.1.8",
|
"govoplan-access>=0.1.8",
|
||||||
"defusedxml>=0.7,<1",
|
"defusedxml>=0.7,<1",
|
||||||
"icalendar>=7.2",
|
"icalendar>=7.2",
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, Mapping, Protocol
|
from typing import Any, Mapping, Protocol
|
||||||
|
|
||||||
from defusedxml import ElementTree as SafeElementTree
|
from defusedxml import ElementTree as SafeElementTree
|
||||||
|
from govoplan_core.security.outbound_http import (
|
||||||
|
OutboundHttpError,
|
||||||
|
bounded_response_bytes,
|
||||||
|
build_outbound_http_opener,
|
||||||
|
validate_outbound_http_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalDAVError(RuntimeError):
|
class CalDAVError(RuntimeError):
|
||||||
@@ -353,20 +359,31 @@ class CalDAVClient:
|
|||||||
def urllib_transport(method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: int) -> tuple[int, Mapping[str, str], bytes]:
|
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)
|
url = validate_http_url(url)
|
||||||
try:
|
try:
|
||||||
|
url = validate_outbound_http_url(url, label="CalDAV URL")
|
||||||
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
url,
|
url,
|
||||||
data=body,
|
data=body,
|
||||||
headers=dict(headers),
|
headers=dict(headers),
|
||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(url))
|
opener = build_outbound_http_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
|
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()
|
response_headers = dict(response.headers.items())
|
||||||
|
return response.status, response_headers, bounded_response_bytes(
|
||||||
|
response,
|
||||||
|
headers=response_headers,
|
||||||
|
label="CalDAV response",
|
||||||
|
)
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
return exc.code, dict(exc.headers.items()), exc.read()
|
response_headers = dict(exc.headers.items())
|
||||||
|
try:
|
||||||
|
payload = bounded_response_bytes(exc, headers=response_headers, label="CalDAV error response")
|
||||||
|
except OutboundHttpError as policy_exc:
|
||||||
|
raise CalDAVError(f"{method} {url} failed: {policy_exc}") from policy_exc
|
||||||
|
return exc.code, response_headers, payload
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc.reason}") from exc
|
||||||
except ValueError as exc:
|
except (OutboundHttpError, ValueError) as exc:
|
||||||
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
raise CalDAVError(f"{method} {url} failed: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -542,7 +559,8 @@ class _SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|||||||
del fp, msg, headers
|
del fp, msg, headers
|
||||||
try:
|
try:
|
||||||
candidate = validate_http_url(newurl)
|
candidate = validate_http_url(newurl)
|
||||||
except CalDAVError:
|
candidate = validate_outbound_http_url(candidate, label="CalDAV redirect URL")
|
||||||
|
except (CalDAVError, OutboundHttpError):
|
||||||
return None
|
return None
|
||||||
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
if _url_origin(urllib.parse.urlparse(candidate)) != self._source_origin:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ from defusedxml import ElementTree as SafeElementTree
|
|||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
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.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.db.models import CalendarCollection, CalendarEvent, CalendarSyncCredential, CalendarSyncSource
|
||||||
from govoplan_calendar.backend.ical import expand_event_occurrences, parse_vevent, parse_vevents
|
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]
|
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
|
||||||
try:
|
try:
|
||||||
candidate = validate_http_url(newurl, label="Calendar source redirect URL")
|
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(
|
candidate_origin = _parsed_http_origin(
|
||||||
urllib.parse.urlparse(candidate),
|
urllib.parse.urlparse(candidate),
|
||||||
label="Calendar source redirect URL",
|
label="Calendar source redirect URL",
|
||||||
)
|
)
|
||||||
except CalendarError:
|
except (CalendarError, OutboundHttpError):
|
||||||
return None
|
return None
|
||||||
if candidate_origin != self._source_origin:
|
if candidate_origin != self._source_origin:
|
||||||
return None
|
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")
|
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)
|
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)
|
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)
|
secret = caldav_secret_from_payload(auth_type=auth_type, password=payload.password, bearer_token=payload.bearer_token)
|
||||||
if secret is None and credential_ref:
|
if secret is None and source is not None and credential_ref == source.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:
|
|
||||||
secret = resolve_caldav_secret(session, source=source)
|
secret = resolve_caldav_secret(session, source=source)
|
||||||
|
|
||||||
if auth_type == "basic":
|
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:
|
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 = get_calendar(session, tenant_id=tenant_id, calendar_id=payload.calendar_id)
|
||||||
calendar = (
|
calendar = (
|
||||||
session.query(CalendarCollection)
|
session.query(CalendarCollection)
|
||||||
@@ -812,6 +827,10 @@ def update_sync_source(session: Session, *, tenant_id: str, source_id: str, payl
|
|||||||
.with_for_update()
|
.with_for_update()
|
||||||
.one()
|
.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 = (
|
normalized_collection_url = (
|
||||||
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
normalize_sync_source_url(source.source_kind, payload.collection_url)
|
||||||
if payload.collection_url is not None
|
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",
|
reason="CalDAV source was retired before queued external changes were delivered",
|
||||||
)
|
)
|
||||||
source.deleted_at = deleted_at
|
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:
|
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,
|
source: CalendarSyncSource,
|
||||||
secret: str,
|
secret: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
provider = secret_provider()
|
existing = internal_caldav_credential(
|
||||||
name = f"caldav:{source.id}:{source.auth_type}"
|
session,
|
||||||
if provider is not None:
|
tenant_id=tenant_id,
|
||||||
return str(provider.store_secret(scope=f"calendar:{tenant_id}", name=name, value=secret))
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
existing = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=source.credential_ref)
|
)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
existing = CalendarSyncCredential(
|
existing = CalendarSyncCredential(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
@@ -1111,24 +1135,46 @@ def store_caldav_credential(
|
|||||||
)
|
)
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
session.flush()
|
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.credential_kind = credential_kind_for_auth_type(source.auth_type)
|
||||||
existing.label = source.display_name or source.collection_url
|
existing.label = source.display_name or source.collection_url
|
||||||
existing.secret_encrypted = encrypt_secret(secret)
|
|
||||||
existing.deleted_at = None
|
existing.deleted_at = None
|
||||||
existing.metadata_ = {"source_id": source.id}
|
|
||||||
session.flush()
|
session.flush()
|
||||||
return f"{CALDAV_INTERNAL_CREDENTIAL_PREFIX}{existing.id}"
|
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:
|
if not credential_ref:
|
||||||
return
|
return
|
||||||
provider = secret_provider()
|
credential = internal_caldav_credential(
|
||||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX) and not credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
session,
|
||||||
provider.delete_secret(credential_ref)
|
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
|
return
|
||||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
provider_ref = _credential_provider_ref(credential)
|
||||||
if credential is not None:
|
provider = secret_provider()
|
||||||
|
if provider is not None and provider_ref:
|
||||||
|
provider.delete_secret(provider_ref)
|
||||||
credential.deleted_at = utcnow()
|
credential.deleted_at = utcnow()
|
||||||
|
|
||||||
|
|
||||||
@@ -1147,27 +1193,59 @@ def resolve_caldav_secret(
|
|||||||
return bearer_token
|
return bearer_token
|
||||||
if not source.credential_ref:
|
if not source.credential_ref:
|
||||||
return None
|
return None
|
||||||
if source.credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
credential = internal_caldav_credential(
|
||||||
return os.environ.get(source.credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
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()
|
provider = secret_provider()
|
||||||
if provider is not None and not source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
if provider is None:
|
||||||
secret = provider.read_secret(source.credential_ref)
|
return None
|
||||||
if secret:
|
return provider.read_secret(provider_ref)
|
||||||
return secret
|
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_caldav_credential_ref(session: Session, *, tenant_id: str, credential_ref: str) -> str | None:
|
def resolve_caldav_credential_ref(
|
||||||
if credential_ref.startswith(CALDAV_ENV_CREDENTIAL_PREFIX):
|
session: Session,
|
||||||
return os.environ.get(credential_ref.removeprefix(CALDAV_ENV_CREDENTIAL_PREFIX))
|
*,
|
||||||
|
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()
|
provider = secret_provider()
|
||||||
if provider is not None and not credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX):
|
return provider.read_secret(provider_ref) if provider is not None else None
|
||||||
secret = provider.read_secret(credential_ref)
|
return decrypt_secret(credential.secret_encrypted) if credential.secret_encrypted else None
|
||||||
if secret:
|
|
||||||
return secret
|
|
||||||
credential = internal_caldav_credential(session, tenant_id=tenant_id, credential_ref=credential_ref)
|
def resolve_trusted_deployment_caldav_credential_ref(credential_ref: str) -> str | None:
|
||||||
return decrypt_secret(credential.secret_encrypted) if credential and credential.secret_encrypted else 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(
|
def caldav_client_for_source(
|
||||||
@@ -1242,22 +1320,37 @@ def http_request(
|
|||||||
url,
|
url,
|
||||||
label="Calendar source 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.
|
request = urllib.request.Request( # noqa: S310 - URL is validated and origin-confined.
|
||||||
url,
|
url,
|
||||||
data=data,
|
data=data,
|
||||||
method=method,
|
method=method,
|
||||||
headers=headers or {},
|
headers=headers or {},
|
||||||
)
|
)
|
||||||
opener = urllib.request.build_opener(_SameOriginRedirectHandler(credential_origin))
|
opener = build_outbound_http_opener(_SameOriginRedirectHandler(credential_origin))
|
||||||
try:
|
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
|
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()}
|
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
|
return int(response.status), response_headers, payload
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
if exc.code == 304:
|
if exc.code == 304:
|
||||||
return 304, {key.lower(): value for key, value in exc.headers.items()}, ""
|
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
|
raise CalendarError(f"HTTP {exc.code} from calendar source: {detail or exc.reason}") from exc
|
||||||
except urllib.error.URLError as exc:
|
except urllib.error.URLError as exc:
|
||||||
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
|
raise CalendarError(f"Calendar source request failed: {exc.reason}") from exc
|
||||||
@@ -1287,11 +1380,17 @@ def source_auth_headers(
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def internal_caldav_credential(session: Session, *, tenant_id: str, credential_ref: str | None) -> CalendarSyncCredential | None:
|
def internal_caldav_credential(
|
||||||
if not credential_ref:
|
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
|
return None
|
||||||
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
credential_id = credential_ref.removeprefix(CALDAV_INTERNAL_CREDENTIAL_PREFIX)
|
||||||
return (
|
credential = (
|
||||||
session.query(CalendarSyncCredential)
|
session.query(CalendarSyncCredential)
|
||||||
.filter(
|
.filter(
|
||||||
CalendarSyncCredential.tenant_id == tenant_id,
|
CalendarSyncCredential.tenant_id == tenant_id,
|
||||||
@@ -1300,6 +1399,16 @@ def internal_caldav_credential(session: Session, *, tenant_id: str, credential_r
|
|||||||
)
|
)
|
||||||
.first()
|
.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:
|
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]:
|
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 {
|
return {
|
||||||
"id": source.id,
|
"id": source.id,
|
||||||
"tenant_id": source.tenant_id,
|
"tenant_id": source.tenant_id,
|
||||||
@@ -2399,8 +2511,8 @@ def caldav_source_response(source: CalendarSyncSource) -> dict[str, Any]:
|
|||||||
"display_name": source.display_name,
|
"display_name": source.display_name,
|
||||||
"auth_type": source.auth_type,
|
"auth_type": source.auth_type,
|
||||||
"username": source.username,
|
"username": source.username,
|
||||||
"credential_ref": source.credential_ref,
|
"credential_ref": None,
|
||||||
"has_credential": bool(source.credential_ref),
|
"has_credential": has_server_owned_credential,
|
||||||
"sync_enabled": bool(source.sync_enabled),
|
"sync_enabled": bool(source.sync_enabled),
|
||||||
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
"sync_interval_seconds": int(source.sync_interval_seconds or CALDAV_DEFAULT_SYNC_INTERVAL_SECONDS),
|
||||||
"sync_direction": source.sync_direction,
|
"sync_direction": source.sync_direction,
|
||||||
|
|||||||
@@ -11,20 +11,32 @@ from govoplan_access.backend.db import models as access_models # noqa: F401 - p
|
|||||||
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
from govoplan_calendar.backend.caldav import CalDAVClient, CalDAVError, CalDAVNotFound, CalDAVObject, CalDAVPreconditionFailed, CalDAVReportResult, CalDAVWriteResult, parse_multistatus
|
||||||
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
from govoplan_calendar.backend.db.models import CalendarEvent, CalendarOutboxOperation, CalendarSyncCredential
|
||||||
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
from govoplan_calendar.backend.outbox import dispatch_calendar_outbox
|
||||||
from govoplan_calendar.backend.schemas import CalendarCalDavSourceCreateRequest, CalendarCollectionCreateRequest, CalendarEventCreateRequest, CalendarEventUpdateRequest
|
from govoplan_calendar.backend.schemas import (
|
||||||
|
CalendarCalDavDiscoveryRequest,
|
||||||
|
CalendarCalDavSourceCreateRequest,
|
||||||
|
CalendarCalDavSourceUpdateRequest,
|
||||||
|
CalendarCollectionCreateRequest,
|
||||||
|
CalendarEventCreateRequest,
|
||||||
|
CalendarEventUpdateRequest,
|
||||||
|
)
|
||||||
from govoplan_calendar.backend.service import (
|
from govoplan_calendar.backend.service import (
|
||||||
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
CALDAV_INTERNAL_CREDENTIAL_PREFIX,
|
||||||
CalendarError,
|
CalendarError,
|
||||||
caldav_client_for_source,
|
caldav_client_for_source,
|
||||||
|
caldav_source_response,
|
||||||
create_calendar,
|
create_calendar,
|
||||||
create_caldav_source,
|
create_caldav_source,
|
||||||
create_event,
|
create_event,
|
||||||
delete_calendar,
|
delete_calendar,
|
||||||
delete_event,
|
delete_event,
|
||||||
|
discover_caldav_calendars,
|
||||||
list_caldav_sources,
|
list_caldav_sources,
|
||||||
list_freebusy,
|
list_freebusy,
|
||||||
|
resolve_caldav_credential_ref,
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref,
|
||||||
sync_caldav_source,
|
sync_caldav_source,
|
||||||
sync_due_caldav_sources,
|
sync_due_caldav_sources,
|
||||||
|
update_caldav_source,
|
||||||
update_event,
|
update_event,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -286,6 +298,201 @@ class CalDAVSyncTests(unittest.TestCase):
|
|||||||
self.assertEqual(client.username, "ada")
|
self.assertEqual(client.username, "ada")
|
||||||
self.assertEqual(client.password, "secret")
|
self.assertEqual(client.password, "secret")
|
||||||
|
|
||||||
|
def test_api_source_and_discovery_reject_caller_selected_credential_references(self) -> None:
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://attacker.example.test/cal",
|
||||||
|
auth_type="bearer",
|
||||||
|
credential_ref="env:MASTER_KEY_B64",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
discover_caldav_calendars(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
payload=CalendarCalDavDiscoveryRequest(
|
||||||
|
url="https://attacker.example.test/cal",
|
||||||
|
auth_type="bearer",
|
||||||
|
credential_ref="vault:another-tenant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "Caller-supplied credential references"):
|
||||||
|
update_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_id=source.id,
|
||||||
|
payload=CalendarCalDavSourceUpdateRequest(
|
||||||
|
credential_ref="env:MASTER_KEY_B64",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_provider_credentials_are_wrapped_in_tenant_and_source_owned_rows(self) -> None:
|
||||||
|
class Provider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.values: dict[str, str] = {}
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
def store_secret(self, *, scope: str, name: str, value: str) -> str:
|
||||||
|
reference = f"vault:{scope}:{name}"
|
||||||
|
self.values[reference] = value
|
||||||
|
return reference
|
||||||
|
|
||||||
|
def read_secret(self, secret_ref: str) -> str | None:
|
||||||
|
return self.values.get(secret_ref)
|
||||||
|
|
||||||
|
def delete_secret(self, secret_ref: str) -> None:
|
||||||
|
self.deleted.append(secret_ref)
|
||||||
|
|
||||||
|
provider = Provider()
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
auth_type="basic",
|
||||||
|
username="ada",
|
||||||
|
password="secret",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
credential = session.query(CalendarSyncCredential).one()
|
||||||
|
provider_ref = str((credential.metadata_ or {})["provider_ref"])
|
||||||
|
|
||||||
|
self.assertTrue(source.credential_ref.startswith(CALDAV_INTERNAL_CREDENTIAL_PREFIX))
|
||||||
|
self.assertNotEqual(source.credential_ref, provider_ref)
|
||||||
|
self.assertEqual(caldav_client_for_source(session, source).password, "secret")
|
||||||
|
self.assertIsNone(caldav_source_response(source)["credential_ref"])
|
||||||
|
self.assertTrue(caldav_source_response(source)["has_credential"])
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_caldav_credential_ref(
|
||||||
|
session,
|
||||||
|
tenant_id="another-tenant",
|
||||||
|
source_id=source.id,
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
resolve_caldav_credential_ref(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_id="another-source",
|
||||||
|
credential_ref=source.credential_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
other_calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Other remote"),
|
||||||
|
)
|
||||||
|
other_source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=other_calendar.id,
|
||||||
|
collection_url="https://dav.example.test/other-cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
other_source.auth_type = "basic"
|
||||||
|
other_source.username = "mallory"
|
||||||
|
other_source.credential_ref = source.credential_ref
|
||||||
|
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
|
||||||
|
caldav_client_for_source(session, other_source)
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=other_calendar.id)
|
||||||
|
self.assertEqual(provider.deleted, [])
|
||||||
|
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
|
||||||
|
|
||||||
|
self.assertEqual(provider.deleted, [provider_ref])
|
||||||
|
|
||||||
|
def test_legacy_unowned_refs_are_neither_read_nor_deleted(self) -> None:
|
||||||
|
class Provider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.read: list[str] = []
|
||||||
|
self.deleted: list[str] = []
|
||||||
|
|
||||||
|
def read_secret(self, secret_ref: str) -> str | None:
|
||||||
|
self.read.append(secret_ref)
|
||||||
|
return "other-tenant-secret"
|
||||||
|
|
||||||
|
def delete_secret(self, secret_ref: str) -> None:
|
||||||
|
self.deleted.append(secret_ref)
|
||||||
|
|
||||||
|
provider = Provider()
|
||||||
|
session = self.session()
|
||||||
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
calendar = create_calendar(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCollectionCreateRequest(name="Remote"),
|
||||||
|
)
|
||||||
|
source = create_caldav_source(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id=None,
|
||||||
|
payload=CalendarCalDavSourceCreateRequest(
|
||||||
|
calendar_id=calendar.id,
|
||||||
|
collection_url="https://dav.example.test/cal",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
source.auth_type = "bearer"
|
||||||
|
source.credential_ref = "vault:another-tenant"
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch("govoplan_calendar.backend.service.secret_provider", return_value=provider):
|
||||||
|
with self.assertRaisesRegex(CalendarError, "not a server-owned credential"):
|
||||||
|
caldav_client_for_source(session, source)
|
||||||
|
delete_calendar(session, tenant_id="tenant-1", calendar_id=calendar.id)
|
||||||
|
|
||||||
|
self.assertEqual(provider.read, [])
|
||||||
|
self.assertEqual(provider.deleted, [])
|
||||||
|
|
||||||
|
def test_trusted_deployment_env_resolution_is_explicit_and_separate(self) -> None:
|
||||||
|
with patch.dict("os.environ", {"CALDAV_DEPLOYMENT_TOKEN": "trusted-token"}):
|
||||||
|
self.assertEqual(
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref("env:CALDAV_DEPLOYMENT_TOKEN"),
|
||||||
|
"trusted-token",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CalendarError, "must use the env: prefix"):
|
||||||
|
resolve_trusted_deployment_caldav_credential_ref("vault:token")
|
||||||
|
|
||||||
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
|
def test_delete_calendar_retires_caldav_source_and_credential(self) -> None:
|
||||||
session = self.session()
|
session = self.session()
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import threading
|
|||||||
import unittest
|
import unittest
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from govoplan_calendar.backend.caldav import (
|
from govoplan_calendar.backend.caldav import (
|
||||||
CalDAVClient,
|
CalDAVClient,
|
||||||
@@ -29,6 +30,22 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
|
|||||||
|
|
||||||
|
|
||||||
class CalDAVUrlSecurityTests(unittest.TestCase):
|
class CalDAVUrlSecurityTests(unittest.TestCase):
|
||||||
|
def test_transport_revalidates_dns_at_connection_time(self) -> None:
|
||||||
|
public = [(2, 1, 6, "", ("93.184.216.34", 443))]
|
||||||
|
private = [(2, 1, 6, "", ("127.0.0.1", 443))]
|
||||||
|
with patch.dict(
|
||||||
|
"os.environ",
|
||||||
|
{"APP_ENV": "production", "GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "false"},
|
||||||
|
), patch(
|
||||||
|
"govoplan_core.security.outbound_http.socket.getaddrinfo",
|
||||||
|
side_effect=(public, private),
|
||||||
|
), patch("govoplan_core.security.outbound_http.socket.socket") as socket_factory, self.assertRaisesRegex(
|
||||||
|
CalDAVError,
|
||||||
|
"non-public network",
|
||||||
|
):
|
||||||
|
urllib_transport("GET", "https://dav.example.test/event.ics", {}, None, 2)
|
||||||
|
socket_factory.assert_not_called()
|
||||||
|
|
||||||
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
|
||||||
base_url = "https://dav.example.test/calendars/ada/"
|
base_url = "https://dav.example.test/calendars/ada/"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user