Harden Calendar connector trust boundaries

This commit is contained in:
2026-07-21 12:11:58 +02:00
parent a52e35b52b
commit 1df2a1a730
6 changed files with 415 additions and 57 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View File

@@ -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"))

View File

@@ -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/"