Confine calendar connectors to configured origins

This commit is contained in:
2026-07-21 03:16:45 +02:00
parent 68b165db7b
commit a36a9e8c19
6 changed files with 467 additions and 41 deletions

View File

@@ -246,12 +246,21 @@ class CalDAVSyncTests(unittest.TestCase):
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session(self):
session = self.Session()
self.sessions.append(session)
return session
def test_source_creation_encrypts_credential_and_resolves_client_secret(self) -> None:
session = self.Session()
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"))
@@ -278,7 +287,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(client.password, "secret")
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"))
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
source = create_caldav_source(
@@ -304,7 +313,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(list_caldav_sources(session, tenant_id="tenant-1"), [])
def test_create_source_retires_orphaned_source_for_deleted_calendar(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
old_calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Old"))
old_source = create_caldav_source(
@@ -330,7 +339,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual([source.id for source in list_caldav_sources(session, tenant_id="tenant-1")], [new_source.id])
def test_create_source_reports_active_duplicate_as_calendar_error(self) -> None:
session = self.Session()
session = self.session()
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant"))
first = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="First"))
second = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Second"))
@@ -351,7 +360,7 @@ class CalDAVSyncTests(unittest.TestCase):
)
def test_due_sync_runs_due_sources_and_reschedules(self) -> None:
session = self.Session()
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(
@@ -378,7 +387,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertGreater(next_sync_at, datetime(2026, 7, 7, 8, 1, tzinfo=timezone.utc))
def test_due_sync_emits_recovery_notification_after_previous_error(self) -> None:
session = self.Session()
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(
@@ -406,7 +415,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(provider.requests[0].event_kind, "calendar.sync.ok")
def test_create_and_update_event_coalesce_to_committed_outbox_state(self) -> None:
session = self.Session()
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"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -441,7 +450,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIn("SUMMARY:Updated", event.raw_ics or "")
def test_update_event_reports_remote_etag_conflict(self) -> None:
session = self.Session()
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"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -467,7 +476,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIn("changed remotely", operation.last_error or "")
def test_delete_one_component_puts_remaining_resource_instead_of_deleting_object(self) -> None:
session = self.Session()
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"))
create_caldav_source(session, tenant_id="tenant-1", user_id=None, payload=CalendarCalDavSourceCreateRequest(calendar_id=calendar.id, collection_url="https://dav.example.test/cal"))
@@ -490,7 +499,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(override.deleted_at)
def test_freebusy_expands_recurring_events_and_skips_transparent_items(self) -> None:
session = self.Session()
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="Local"))
create_event(
@@ -532,7 +541,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(busy[0]["start_at"], datetime(2026, 7, 8, 9, 0, tzinfo=timezone.utc))
def test_full_sync_imports_multi_vevent_resource_and_fetches_missing_calendar_data(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(
@@ -573,7 +582,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertEqual(calendar.metadata_["source_kind"], "caldav")
def test_sync_token_deletion_soft_deletes_remote_resource_events(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))
@@ -622,7 +631,7 @@ class CalDAVSyncTests(unittest.TestCase):
self.assertIsNotNone(event.deleted_at)
def test_missing_resource_during_fetch_is_treated_as_remote_delete(self) -> None:
session = self.Session()
session = self.session()
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant")
session.add(tenant)
calendar = create_calendar(session, tenant_id="tenant-1", user_id=None, payload=CalendarCollectionCreateRequest(name="Remote"))

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.caldav import (
CalDAVClient,
CalDAVError,
absolute_dav_url,
urllib_transport,
)
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalDAVUrlSecurityTests(unittest.TestCase):
def test_discovery_href_must_remain_on_configured_origin(self) -> None:
base_url = "https://dav.example.test/calendars/ada/"
self.assertEqual(
absolute_dav_url(base_url, "/principals/users/ada/"),
"https://dav.example.test/principals/users/ada/",
)
with self.assertRaisesRegex(CalDAVError, "configured collection origin"):
absolute_dav_url(base_url, "https://evil.example.test/steal/")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
absolute_dav_url(base_url, "/principals/users/ada/?token=secret")
def test_object_href_rejects_userinfo_query_and_fragment(self) -> None:
client = CalDAVClient(collection_url="https://dav.example.test/calendars/ada")
with self.assertRaisesRegex(CalDAVError, "embedded credentials"):
client.object_url("https://user:secret@dav.example.test/calendars/ada/event.ics")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics?download=1")
with self.assertRaisesRegex(CalDAVError, "query or fragment"):
client.object_url("/calendars/ada/event.ics#fragment")
self.assertEqual(
client.object_url("/calendars/ada/event.ics"),
"https://dav.example.test/calendars/ada/event.ics",
)
def test_transport_refuses_redirect_before_forwarding_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen.ics")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as redirect_url:
status, _headers, _body = urllib_transport(
"GET",
f"{redirect_url}/event.ics",
{"Authorization": "Bearer top-secret"},
None,
2,
)
self.assertEqual(status, 302)
self.assertEqual(forwarded_authorization, [])
def test_transport_preserves_same_origin_redirects(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/event.ics":
self.send_response(302)
self.send_header("Location", "/redirected.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = urllib_transport(
"GET",
f"{source_url}/event.ics",
{"Authorization": "Bearer expected"},
None,
2,
)
self.assertEqual(status, 200)
self.assertEqual(body, b"calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import contextlib
import threading
import unittest
from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.service import CalendarError, http_request
@contextlib.contextmanager
def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address
yield f"http://{host}:{port}"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
class CalendarHttpSecurityTests(unittest.TestCase):
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
forwarded_authorization: list[str | None] = []
class TargetHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(TargetHandler) as target_url:
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
self.send_response(302)
self.send_header("Location", f"{target_url}/stolen")
self.end_headers()
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
with self.assertRaisesRegex(CalendarError, "HTTP 302"):
http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer top-secret"},
timeout=2,
)
self.assertEqual(forwarded_authorization, [])
def test_same_origin_redirect_remains_supported(self) -> None:
forwarded_authorization: list[str | None] = []
class RedirectHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
if self.path == "/feed":
self.send_response(302)
self.send_header("Location", "/calendar.ics")
self.end_headers()
return
forwarded_authorization.append(self.headers.get("Authorization"))
self.send_response(200)
self.end_headers()
self.wfile.write(b"calendar")
def log_message(self, _format: str, *_args: object) -> None:
return
with running_http_server(RedirectHandler) as source_url:
status, _headers, body = http_request(
f"{source_url}/feed",
headers={"Authorization": "Bearer expected"},
timeout=2,
)
self.assertEqual(status, 200)
self.assertEqual(body, "calendar")
self.assertEqual(forwarded_authorization, ["Bearer expected"])
if __name__ == "__main__":
unittest.main()

View File

@@ -23,12 +23,17 @@ class CalendarSyncSourceTests(unittest.TestCase):
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.sessions = []
def tearDown(self) -> None:
for session in reversed(self.sessions):
session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def session_with_calendar(self):
session = self.Session()
self.sessions.append(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"))
session.flush()
@@ -166,6 +171,74 @@ END:VCALENDAR
self.assertEqual(event.summary, "Graph item")
self.assertIn("Bearer graph-token", request.call_args.kwargs["headers"]["Authorization"])
def test_graph_sync_rejects_cross_origin_continuation_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.nextLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_graph_sync_rejects_cross_origin_delta_url(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(
session,
tenant_id="tenant-1",
user_id=None,
payload=CalendarSyncSourceCreateRequest(
source_kind="graph",
calendar_id=calendar.id,
collection_url="me/calendar",
auth_type="bearer",
bearer_token="graph-token",
),
)
payload = {
"value": [],
"@odata.deltaLink": "https://attacker.example.test/collect?token=secret",
}
with patch(
"govoplan_calendar.backend.service.http_request",
return_value=(200, {}, json.dumps(payload)),
) as request:
with self.assertRaisesRegex(CalendarError, "configured source origin"):
sync_source(
session,
tenant_id="tenant-1",
user_id=None,
source_id=source.id,
)
self.assertEqual(request.call_count, 1)
self.assertEqual(source.last_status, "error")
def test_ews_sync_imports_calendar_view_items(self) -> None:
session, calendar = self.session_with_calendar()
source = create_sync_source(