Files
Calendarr/backend/routers/caldav_router.py
Scarriffle 6869a15bb8 fix(security): stop private-event leak in merge read + harden busy masking, uploads, profile
Findings from the security review:
- HIGH: private local events leaked in full (title/location/description) to
  anyone who could READ a shared or group calendar via GET /api/caldav/events —
  the private_event_visibility rule was only enforced in /groups/{id}/combined.
  Now enforced in the merge read too, via a shared helper (apply_event_privacy)
  so the two paths can't drift.
- HIGH: 'busy' masking was a blacklist that still leaked creator identity,
  source-calendar name, recurrence rule and per-event colour. Replaced with a
  whitelist (mask_busy_event): only timing/identity/render fields survive.
- MEDIUM: .ics import had no size limit (raw = await file.read()) → memory DoS.
  Now capped at 5 MB (413), read before creating any calendar.
- LOW/INFO: profile email now checked for uniqueness + basic format; display
  name / username / email length-capped and control-chars stripped.

Deferred (tracked): RRULE expansion cap at the trust boundary, SQLite
PRAGMA foreign_keys + ON DELETE cascade, and JWT-by-user-id + token version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:49:56 +02:00

569 lines
18 KiB
Python

import logging
from datetime import datetime as dt_datetime, date as dt_date, timedelta, timezone as dt_timezone
from typing import Optional
from urllib.parse import urlparse
from dateutil.rrule import rrulestr
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import or_
import caldav_client
import models
import permissions
from auth import get_current_user
from database import get_db
from local_events_util import (
apply_event_privacy,
build_local_event_dict,
expand_recurring_local,
private_visibility_for,
resolve_creator,
)
from routers.ical_router import _refresh_if_needed, get_events_for_subscription
logger = logging.getLogger(__name__)
router = APIRouter()
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
class AccountCreate(BaseModel):
name: str
url: str
username: str
password: str
color: str = "#4285f4"
class CalendarUpdate(BaseModel):
enabled: Optional[bool] = None
color: Optional[str] = None
name: Optional[str] = None
sidebar_hidden: Optional[bool] = None
class EventCreate(BaseModel):
calendar_id: int
title: str
start: str
end: str
allDay: bool = False
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
class EventUpdate(BaseModel):
title: Optional[str] = None
start: Optional[str] = None
end: Optional[str] = None
allDay: Optional[bool] = None
location: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
rrule: Optional[str] = None
exdate: Optional[str] = None
def _account_dict(a: models.CalDAVAccount) -> dict:
return {
"id": a.id,
"name": a.name,
"url": a.url,
"username": a.username,
"color": a.color,
"enabled": a.enabled,
"calendars": [
{
"id": c.id,
"name": c.name,
"color": c.color or a.color,
"enabled": c.enabled,
"cal_id": c.cal_id,
"sidebar_hidden": bool(c.sidebar_hidden),
}
for c in a.calendars
],
}
def _normalize_url(url: str) -> str:
"""Normalize URL for comparison: lowercase scheme/host, strip trailing slash."""
parsed = urlparse(url)
scheme = parsed.scheme.lower()
host = (parsed.hostname or '').lower()
port = parsed.port
if (scheme == 'https' and port == 443) or (scheme == 'http' and port == 80):
port = None
netloc = f"{host}:{port}" if port else host
path = parsed.path.rstrip('/')
return f"{scheme}://{netloc}{path}"
def _find_account_for_event_url(
event_url: str, accounts: list[models.CalDAVAccount]
) -> Optional[models.CalDAVAccount]:
norm_event = _normalize_url(event_url)
# Primary: match against normalized account URL
for acc in accounts:
if norm_event.startswith(_normalize_url(acc.url)):
return acc
# Fallback: match against normalized calendar URLs
for acc in accounts:
for cal in acc.calendars:
if norm_event.startswith(_normalize_url(cal.cal_id)):
return acc
# Second fallback: path-only matching
event_path = urlparse(event_url).path.rstrip('/')
for acc in accounts:
acc_path = urlparse(acc.url).path.rstrip('/')
if acc_path and event_path.startswith(acc_path):
return acc
for acc in accounts:
for cal in acc.calendars:
cal_path = urlparse(cal.cal_id).path.rstrip('/')
if cal_path and event_path.startswith(cal_path):
return acc
return None
@router.get("/accounts")
def list_accounts(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
return [_account_dict(a) for a in accounts]
@router.post("/accounts")
def add_account(
data: AccountCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
try:
remote_cals = caldav_client.fetch_calendars(data.url, data.username, data.password)
except ValueError as e:
raise HTTPException(400, str(e))
account = models.CalDAVAccount(
user_id=current_user.id,
name=data.name,
url=data.url,
username=data.username,
password=data.password,
color=data.color,
)
db.add(account)
db.flush()
for idx, cal in enumerate(remote_cals):
db.add(
models.Calendar(
account_id=account.id,
cal_id=cal["url"],
name=cal["name"],
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
enabled=True,
)
)
db.commit()
db.refresh(account)
return _account_dict(account)
@router.delete("/accounts/{account_id}")
def delete_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
account = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.id == account_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not account:
raise HTTPException(404, "Account not found")
db.delete(account)
db.commit()
return {"ok": True}
@router.post("/accounts/{account_id}/sync")
def sync_account(
account_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
account = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.id == account_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not account:
raise HTTPException(404, "Account not found")
try:
remote_cals = caldav_client.fetch_calendars(
account.url, account.username, account.password
)
except ValueError as e:
raise HTTPException(400, str(e))
existing = {c.cal_id: c for c in account.calendars}
for idx, cal in enumerate(remote_cals):
if cal["url"] not in existing:
db.add(
models.Calendar(
account_id=account.id,
cal_id=cal["url"],
name=cal["name"],
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
enabled=True,
)
)
else:
existing[cal["url"]].name = cal["name"]
db.commit()
db.refresh(account)
return _account_dict(account)
@router.put("/calendars/{calendar_id}")
def update_calendar(
calendar_id: int,
data: CalendarUpdate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
calendar = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(
models.Calendar.id == calendar_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not calendar:
raise HTTPException(404, "Calendar not found")
if data.enabled is not None:
calendar.enabled = data.enabled
if data.color is not None:
calendar.color = data.color
if data.name is not None:
calendar.name = data.name
if data.sidebar_hidden is not None:
calendar.sidebar_hidden = data.sidebar_hidden
db.commit()
return {"ok": True}
@router.get("/events")
def get_events(
start: str = Query(...),
end: str = Query(...),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(400, "Invalid date format — use ISO 8601")
# Make timezone-aware
if start_dt.tzinfo is None:
start_dt = start_dt.replace(tzinfo=timezone.utc)
if end_dt.tzinfo is None:
end_dt = end_dt.replace(tzinfo=timezone.utc)
all_events = []
accounts = (
db.query(models.CalDAVAccount)
.filter(
models.CalDAVAccount.user_id == current_user.id,
models.CalDAVAccount.enabled == True,
)
.all()
)
for account in accounts:
for calendar in account.calendars:
if not calendar.enabled or calendar.sidebar_hidden:
continue
try:
events = caldav_client.fetch_events(
account.url,
account.username,
account.password,
calendar.cal_id,
start_dt,
end_dt,
)
cal_color = calendar.color or account.color
for ev in events:
ev["calendar_id"] = calendar.id
ev["calendar_name"] = calendar.name
ev["calendarColor"] = cal_color
ev["source"] = "caldav"
all_events.append(ev)
except Exception as exc:
logger.error(
"Error fetching calendar %s: %s", calendar.id, exc
)
# ── Local calendar events (own + shared + group calendars) ─────────────
readable_ids = permissions.readable_local_calendar_ids(db, current_user)
local_calendars = (
db.query(models.LocalCalendar)
.filter(
models.LocalCalendar.id.in_(readable_ids),
models.LocalCalendar.enabled == True,
)
.all()
) if readable_ids else []
name_cache = {u.id: (u.display_name or u.username) for u in db.query(models.User).all()}
# Cache each owner's private-event visibility (one lookup per owner, not per event).
vis_cache: dict = {}
def vis_for(uid: int) -> str:
if uid not in vis_cache:
vis_cache[uid] = private_visibility_for(db, uid)
return vis_cache[uid]
for local_cal in local_calendars:
local_events = (
db.query(models.LocalEvent)
.filter(
models.LocalEvent.calendar_id == local_cal.id,
or_(
# Non-recurring events in range
(models.LocalEvent.rrule == None) & (models.LocalEvent.start < end) & (models.LocalEvent.end > start),
# Recurring events: always include so we can expand
models.LocalEvent.rrule != None,
),
)
.all()
)
for ev in local_events:
creator = resolve_creator(ev, name_cache=name_cache)
# A private event belonging to someone else (shared calendar or group
# calendar) must honour that owner's private_event_visibility, exactly
# like the group combined view — otherwise private titles/locations
# leak through the ordinary calendar read.
owner_id = ev.creator_id or local_cal.user_id
is_priv = bool(ev.is_private)
foreign_private = is_priv and owner_id != current_user.id
visibility = vis_for(owner_id) if foreign_private else "busy"
if foreign_private and visibility == "hidden":
continue
if ev.rrule:
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)
else:
built = [build_local_event_dict(ev, local_cal, rrule=None, creator=creator)]
for b in built:
b = apply_event_privacy(
b, owner_id=owner_id, is_private=is_priv,
requester_id=current_user.id, visibility=visibility,
)
if b is not None:
all_events.append(b)
# ── iCal subscription events ──────────────────────────
ical_subs = (
db.query(models.ICalSubscription)
.filter(
models.ICalSubscription.user_id == current_user.id,
models.ICalSubscription.enabled == True,
)
.all()
)
for sub in ical_subs:
try:
_refresh_if_needed(sub, db)
all_events.extend(get_events_for_subscription(sub, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching iCal subscription %s: %s", sub.id, exc)
# ── Google Calendar events ───────────────────────────
from routers.google_router import get_google_events
google_accounts = (
db.query(models.GoogleAccount)
.filter(models.GoogleAccount.user_id == current_user.id)
.all()
)
google_errors = []
for g_acc in google_accounts:
try:
all_events.extend(get_google_events(g_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching Google Calendar for %s: %s", g_acc.email, exc)
google_errors.append({"email": g_acc.email})
# ── Home Assistant events ─────────────────────────────
from routers.homeassistant_router import get_ha_events
ha_accounts = (
db.query(models.HomeAssistantAccount)
.filter(models.HomeAssistantAccount.user_id == current_user.id)
.all()
)
for ha_acc in ha_accounts:
try:
all_events.extend(get_ha_events(ha_acc, start_dt, end_dt, db))
except Exception as exc:
logger.error("Error fetching HA events for %s: %s", ha_acc.name, exc)
return {"events": all_events, "errors": google_errors}
@router.post("/events")
def create_event(
data: EventCreate,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
calendar = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(
models.Calendar.id == data.calendar_id,
models.CalDAVAccount.user_id == current_user.id,
)
.first()
)
if not calendar:
raise HTTPException(404, "Calendar not found")
account = calendar.account
try:
uid = caldav_client.create_event(
account.url,
account.username,
account.password,
calendar.cal_id,
{
"title": data.title,
"start": data.start,
"end": data.end,
"allDay": data.allDay,
"location": data.location,
"description": data.description,
"color": data.color,
"rrule": data.rrule,
},
)
return {"uid": uid, "calendar_id": data.calendar_id}
except Exception as exc:
raise HTTPException(500, f"Could not create event: {exc}")
@router.put("/events/{event_id}")
def update_event(
event_id: str,
event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
data: EventUpdate = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = None
cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
# Try to find the calendar URL for the account
if account and not cal_url:
for c in account.calendars:
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
cal_url = c.cal_id
break
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
caldav_client.update_event(
account.url,
account.username,
account.password,
event_url,
data.model_dump(exclude_none=True) if data else {},
calendar_url=cal_url,
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"Could not update event: {exc}")
@router.delete("/events/{event_id}")
def delete_event(
event_id: str,
event_url: str = Query(...),
calendar_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
accounts = (
db.query(models.CalDAVAccount)
.filter(models.CalDAVAccount.user_id == current_user.id)
.all()
)
account = None
cal_url = None
if calendar_id is not None:
cal = (
db.query(models.Calendar)
.join(models.CalDAVAccount)
.filter(models.Calendar.id == calendar_id, models.CalDAVAccount.user_id == current_user.id)
.first()
)
if cal:
account = next((a for a in accounts if a.id == cal.account_id), None)
cal_url = cal.cal_id
if not account:
account = _find_account_for_event_url(event_url, accounts)
if account and not cal_url:
for c in account.calendars:
if event_url.startswith(c.cal_id) or event_url.startswith(_normalize_url(c.cal_id)):
cal_url = c.cal_id
break
if not account:
raise HTTPException(404, "Event not found or not authorized")
try:
caldav_client.delete_event(
account.url, account.username, account.password, event_url,
calendar_url=cal_url,
)
return {"ok": True}
except Exception as exc:
raise HTTPException(500, f"Could not delete event: {exc}")