Skip to content

Commit 220e111

Browse files
committed
review fixes
1 parent a53e0b2 commit 220e111

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

src/backend/core/services/calendar/service.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import logging
1010
import uuid
1111
from datetime import timezone
12-
from urllib.parse import urljoin
12+
from urllib.parse import urljoin, urlparse
1313

1414
from django.conf import settings as django_settings
1515

@@ -254,19 +254,40 @@ def _update_partstat(cal, attendee_email, new_partstat):
254254
if not isinstance(attendees, list):
255255
attendees = [attendees]
256256
for att in attendees:
257-
if email_lower not in str(att).lower():
257+
addr = str(att).strip().lower()
258+
if addr.startswith("mailto:"):
259+
addr = addr[len("mailto:") :]
260+
if addr != email_lower:
258261
continue
259262
att.params["PARTSTAT"] = new_partstat
260263
att.params.pop("RSVP", None)
261264

262265
def _pick_calendar_url(self, calendar_id):
263-
if calendar_id:
264-
return calendar_id
266+
if calendar_id and not self._same_origin(calendar_id):
267+
raise CalDAVError(
268+
"Calendar URL host does not match the configured CalDAV server."
269+
)
265270
calendars = self.list_calendars()
266271
if not calendars:
267272
raise CalDAVError("No calendars available on this CalDAV server.")
273+
if calendar_id:
274+
valid_ids = {c["id"] for c in calendars}
275+
if calendar_id not in valid_ids:
276+
raise CalDAVError("Calendar is not in this user's calendar list.")
277+
return calendar_id
268278
return calendars[0]["id"]
269279

280+
def _same_origin(self, candidate_url):
281+
"""Whether ``candidate_url`` shares scheme + host + port with ``self.url``."""
282+
cand = urlparse(candidate_url)
283+
base = urlparse(self.url)
284+
if not cand.scheme or not cand.netloc:
285+
return False
286+
return (cand.scheme.lower(), cand.netloc.lower()) == (
287+
base.scheme.lower(),
288+
base.netloc.lower(),
289+
)
290+
270291
def _put_event(self, calendar_url, ics_data):
271292
uid = ""
272293
try:

src/backend/core/tests/api/test_calendar.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from core import factories
1818
from core.enums import MailboxRoleChoices
19-
from core.services.calendar.service import CalDAVService
19+
from core.services.calendar.service import CalDAVError, CalDAVService
2020

2121

2222
class _SilentHandler(WSGIRequestHandler):
@@ -726,6 +726,93 @@ def test_leaves_other_attendees_untouched(self):
726726
assert by_email["mailto:me@example.com"].params["PARTSTAT"] == "ACCEPTED"
727727
assert by_email["mailto:other@example.com"].params["PARTSTAT"] == "NEEDS-ACTION"
728728

729+
def test_substring_email_does_not_match(self):
730+
"""An attendee whose address contains the target as a substring must
731+
not be updated; only an exact email match is."""
732+
ics = (
733+
"BEGIN:VCALENDAR\r\n"
734+
"VERSION:2.0\r\n"
735+
"PRODID:-//Test//Test//EN\r\n"
736+
"BEGIN:VEVENT\r\n"
737+
"UID:x@example.com\r\n"
738+
"DTSTART:20260101T120000Z\r\n"
739+
"DTEND:20260101T130000Z\r\n"
740+
"SUMMARY:X\r\n"
741+
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notme@example.com\r\n"
742+
"ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:me@example.com\r\n"
743+
"END:VEVENT\r\n"
744+
"END:VCALENDAR\r\n"
745+
)
746+
cal = ICalendar.from_ical(ics)
747+
CalDAVService._update_partstat(cal, "me@example.com", "ACCEPTED")
748+
749+
attendees = cal.walk("VEVENT")[0].get("ATTENDEE")
750+
by_email = {str(a).lower(): a for a in attendees}
751+
assert by_email["mailto:me@example.com"].params["PARTSTAT"] == "ACCEPTED"
752+
assert by_email["mailto:notme@example.com"].params["PARTSTAT"] == "NEEDS-ACTION"
753+
754+
755+
class TestPickCalendarUrl:
756+
"""Direct tests for calendar URL selection / SSRF guard."""
757+
758+
def _service_with_calendars(self, calendar_ids):
759+
service = CalDAVService(url="https://caldav.example.com/")
760+
service.list_calendars = lambda: [ # type: ignore[method-assign]
761+
{"id": cid, "name": cid} for cid in calendar_ids
762+
]
763+
return service
764+
765+
def test_returns_first_when_no_id_given(self):
766+
service = self._service_with_calendars(
767+
["https://caldav.example.com/u/cal1/", "https://caldav.example.com/u/cal2/"]
768+
)
769+
assert service._pick_calendar_url(None) == "https://caldav.example.com/u/cal1/"
770+
771+
def test_accepts_known_calendar_id(self):
772+
service = self._service_with_calendars(
773+
["https://caldav.example.com/u/cal1/", "https://caldav.example.com/u/cal2/"]
774+
)
775+
assert (
776+
service._pick_calendar_url("https://caldav.example.com/u/cal2/")
777+
== "https://caldav.example.com/u/cal2/"
778+
)
779+
780+
def test_rejects_arbitrary_url(self):
781+
"""An attacker-controlled URL must not be used as a calendar target."""
782+
service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"])
783+
with pytest.raises(CalDAVError):
784+
service._pick_calendar_url("https://attacker.example.org/evil/")
785+
786+
def test_rejects_cross_origin_before_listing(self):
787+
"""The origin check must reject foreign hosts even if list_calendars()
788+
somehow returned a matching entry — defense in depth."""
789+
service = CalDAVService(url="https://caldav.example.com/")
790+
called = {"n": 0}
791+
792+
def _spy():
793+
called["n"] += 1
794+
return [{"id": "https://attacker.example.org/evil/", "name": "evil"}]
795+
796+
service.list_calendars = _spy # type: ignore[method-assign]
797+
with pytest.raises(CalDAVError):
798+
service._pick_calendar_url("https://attacker.example.org/evil/")
799+
assert called["n"] == 0
800+
801+
def test_rejects_scheme_relative_or_malformed(self):
802+
service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"])
803+
with pytest.raises(CalDAVError):
804+
service._pick_calendar_url("/u/cal1/")
805+
806+
def test_rejects_unknown_calendar_on_same_host(self):
807+
service = self._service_with_calendars(["https://caldav.example.com/u/cal1/"])
808+
with pytest.raises(CalDAVError):
809+
service._pick_calendar_url("https://caldav.example.com/u/other/")
810+
811+
def test_raises_when_no_calendars(self):
812+
service = self._service_with_calendars([])
813+
with pytest.raises(CalDAVError):
814+
service._pick_calendar_url(None)
815+
729816

730817
# ---------------------------------------------------------------------------
731818
# Credential contract: Basic Auth user = mailbox email, password = setting

0 commit comments

Comments
 (0)