mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-18 10:45:31 -04:00
f78539ba15
validate_caldav_url resolves and vets the initial host, but caldav's niquests session follows 3xx redirects by default, so a validated public URL can be redirected at request time to loopback/link-local/private space, re-opening the SSRF the host check closes. The existing redirect guard only covered the settings test-connection path. Add a shared _build_dav_client helper that pins the session to zero redirects (any 3xx then raises instead of silently following an attacker-chosen Location), and route both the pull (_sync_blocking) and write-back (_writeback_blocking) paths through it. Mirrors the follow_redirects=False already used on the test-connection path. Tests exercise the real DAVClient request path (a 302 toward an internal host is refused, the sink is never contacted; the PROPFIND is asserted to reach the public server first so the check can't pass vacuously), confirm the helper disables redirects on the installed client, guard against a raw DAVClient creeping back in, cover mixed public/internal DNS results in both orderings, and add the resolves-to-no-usable-records fail-closed branch.
213 lines
8.3 KiB
Python
213 lines
8.3 KiB
Python
"""CalDAV write-back: push local create/update/delete out to the remote (#800).
|
|
|
|
``src/caldav_sync.py`` is a one-way pull (remote → local). So events created,
|
|
edited, or deleted in Odysseus on a CalDAV-backed calendar only changed the local
|
|
SQLite copy and never reached the server (iCloud/Nextcloud/Radicale/Fastmail) —
|
|
they'd silently disappear on the next pull and never show on the user's phone.
|
|
|
|
This adds the missing write half. The remote calendar URL isn't stored locally
|
|
(the local calendar id is a one-way hash of it), so we re-discover the remote
|
|
calendar by matching that same hash, then PUT/DELETE the VEVENT by its UID via
|
|
the `caldav` lib. Writes are best-effort: the local DB stays the source of truth,
|
|
and a remote failure is reported, never fatal to the local operation.
|
|
|
|
The pure pieces (``build_event_ical``, ``find_remote_calendar``, ``push_event``)
|
|
take their inputs by argument so they unit-test against a fake client with no
|
|
network.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import timezone
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
|
# Reuse the sync module's hashing so owner+account_id scoping stays consistent.
|
|
from src.caldav_sync import _stable_cal_id as _sync_id
|
|
return _sync_id(remote_url, owner=owner, account_id=account_id)
|
|
|
|
|
|
def build_event_ical(ev: dict) -> str:
|
|
"""Serialize a local event dict to a VCALENDAR/VEVENT iCalendar string.
|
|
|
|
``ev`` keys: uid, summary, description, location, dtstart (datetime),
|
|
dtend (datetime), all_day (bool), is_utc (bool), rrule (str).
|
|
Mirrors how the pull path interprets is_utc/all_day so a round-trip is stable.
|
|
"""
|
|
from icalendar import Calendar, Event as iEvent
|
|
from icalendar.prop import vRecur
|
|
|
|
cal = Calendar()
|
|
cal.add("prodid", "-//Odysseus//CalDAV write-back//EN")
|
|
cal.add("version", "2.0")
|
|
|
|
ve = iEvent()
|
|
ve.add("uid", ev["uid"])
|
|
ve.add("summary", ev.get("summary") or "")
|
|
if ev.get("description"):
|
|
ve.add("description", ev["description"])
|
|
if ev.get("location"):
|
|
ve.add("location", ev["location"])
|
|
|
|
dtstart = ev["dtstart"]
|
|
dtend = ev["dtend"]
|
|
if ev.get("all_day"):
|
|
ve.add("dtstart", dtstart.date())
|
|
ve.add("dtend", dtend.date())
|
|
elif ev.get("is_utc"):
|
|
# Stored as naive-UTC instants — re-attach UTC so the server gets a Z time.
|
|
ve.add("dtstart", dtstart.replace(tzinfo=timezone.utc))
|
|
ve.add("dtend", dtend.replace(tzinfo=timezone.utc))
|
|
else:
|
|
# Legacy naive-local ("floating") time — emit without a TZ.
|
|
ve.add("dtstart", dtstart)
|
|
ve.add("dtend", dtend)
|
|
|
|
if ev.get("rrule"):
|
|
try:
|
|
ve.add("rrule", vRecur.from_ical(ev["rrule"]))
|
|
except Exception:
|
|
logger.debug("CalDAV write-back: skipping unparseable rrule %r", ev.get("rrule"))
|
|
|
|
cal.add_component(ve)
|
|
return cal.to_ical().decode("utf-8")
|
|
|
|
|
|
def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_id: str = ""):
|
|
"""Find the remote calendar whose URL hashes to ``local_cal_id``, or None.
|
|
|
|
``owner`` and ``account_id`` must match what was used when the local calendar
|
|
id was originally computed in ``_sync_blocking`` so the hash round-trips."""
|
|
for cal in calendars:
|
|
try:
|
|
if _stable_cal_id(str(cal.url), owner=owner, account_id=account_id) == local_cal_id:
|
|
return cal
|
|
except Exception:
|
|
continue
|
|
return None
|
|
|
|
|
|
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
|
owner: str = "", account_id: str = "") -> dict:
|
|
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
|
|
|
Returns ``{"ok": bool, ...}``. ``calendars`` is the discovered caldav
|
|
calendar list (injected so this is unit-testable with fakes).
|
|
``owner`` and ``account_id`` are forwarded to ``find_remote_calendar``
|
|
so the URL hash round-trips correctly (#2765).
|
|
"""
|
|
uid = (ev or {}).get("uid") if isinstance(ev, dict) else None
|
|
if not uid:
|
|
return {"ok": False, "error": "event uid is required"}
|
|
|
|
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
|
if remote is None:
|
|
return {"ok": False, "error": "remote calendar not found"}
|
|
|
|
try:
|
|
existing = remote.event_by_uid(uid)
|
|
except Exception:
|
|
existing = None
|
|
|
|
if delete:
|
|
if existing is None:
|
|
return {"ok": True, "note": "already absent on remote"}
|
|
existing.delete()
|
|
return {"ok": True}
|
|
|
|
ical = build_event_ical(ev)
|
|
if existing is not None:
|
|
existing.data = ical
|
|
existing.save()
|
|
return {"ok": True, "updated": True}
|
|
remote.save_event(ical)
|
|
return {"ok": True, "created": True}
|
|
|
|
|
|
def _discover_calendars(client):
|
|
"""Discover the principal's calendars, falling back to the URL itself —
|
|
same strategy as the pull path."""
|
|
from caldav.lib.error import AuthorizationError, NotFoundError
|
|
try:
|
|
return client.principal().calendars()
|
|
except (AuthorizationError, NotFoundError):
|
|
raise
|
|
except Exception:
|
|
try:
|
|
return [client.calendar(url=str(client.url))]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
|
owner="", account_id="") -> dict:
|
|
from src.caldav_sync import _build_dav_client
|
|
# Redirects disabled here too: the write-back path opens its own DAVClient,
|
|
# so it needs the same SSRF-via-redirect protection as the pull path.
|
|
client = _build_dav_client(url, username, password)
|
|
calendars = _discover_calendars(client)
|
|
if not calendars:
|
|
return {"ok": False, "error": "no remote calendars discovered"}
|
|
return push_event(calendars, local_cal_id, ev, delete=delete,
|
|
owner=owner, account_id=account_id)
|
|
|
|
|
|
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
|
ev: dict, *, delete: bool = False) -> dict:
|
|
"""Best-effort push of a local change to the remote CalDAV server.
|
|
|
|
No-ops (``{"skipped": ...}``) when the calendar isn't CalDAV-backed or no
|
|
credentials are configured. Never raises — a remote failure is logged and
|
|
returned, the local DB remaining the source of truth.
|
|
"""
|
|
if calendar_source != "caldav":
|
|
return {"skipped": "not a caldav calendar"}
|
|
try:
|
|
from src.caldav_sync import _load_caldav_accounts
|
|
from src.secret_storage import decrypt
|
|
from core.database import CalendarCal, SessionLocal
|
|
|
|
accounts = _load_caldav_accounts(owner)
|
|
if not accounts:
|
|
return {"skipped": "caldav not configured"}
|
|
|
|
# Find which account owns this calendar.
|
|
acc = None
|
|
if len(accounts) > 1:
|
|
db = SessionLocal()
|
|
try:
|
|
cal_row = db.query(CalendarCal).filter(CalendarCal.id == calendar_id).first()
|
|
cal_account_id = cal_row.account_id if cal_row else None
|
|
finally:
|
|
db.close()
|
|
if cal_account_id:
|
|
acc = next((a for a in accounts if a.get("id") == cal_account_id), None)
|
|
# Fall back to first account (covers single-account and legacy rows with
|
|
# no account_id stamped).
|
|
if acc is None:
|
|
acc = accounts[0]
|
|
|
|
url = (acc.get("url") or "").strip()
|
|
user = (acc.get("username") or "").strip()
|
|
pw = decrypt(acc.get("password") or "")
|
|
if not (url and user and pw):
|
|
return {"skipped": "caldav account credentials incomplete"}
|
|
from src.caldav_sync import validate_caldav_url
|
|
try:
|
|
url = validate_caldav_url(url)
|
|
except ValueError as e:
|
|
logger.warning("CalDAV write-back URL rejected: %s", e)
|
|
return {"ok": False, "error": str(e)[:200]}
|
|
acc_id = acc.get("id") or ""
|
|
result = await asyncio.to_thread(
|
|
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
|
)
|
|
if not result.get("ok"):
|
|
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
|
return result
|
|
except Exception as e:
|
|
logger.exception("CalDAV write-back raised")
|
|
return {"ok": False, "error": str(e)[:200]}
|