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>
This commit is contained in:
Scarriffle
2026-06-01 17:49:56 +02:00
parent 0d15af736d
commit 6869a15bb8
5 changed files with 127 additions and 25 deletions

View File

@@ -443,6 +443,17 @@ def _import_ics_into(cal: models.LocalCalendar, raw: bytes, db: Session) -> dict
return {"imported": imported, "skipped": skipped, "errors": errors}
# Cap .ics uploads so a huge file can't exhaust memory (read fully into RAM).
MAX_ICS_BYTES = 5 * 1024 * 1024 # 5 MB — generous for calendars
async def _read_capped(file: UploadFile) -> bytes:
raw = await file.read(MAX_ICS_BYTES + 1)
if len(raw) > MAX_ICS_BYTES:
raise HTTPException(413, "Datei zu groß (max. 5 MB)")
return raw
@router.post("/calendars/{calendar_id}/import")
async def import_calendar(
calendar_id: int,
@@ -451,7 +462,7 @@ async def import_calendar(
current_user: models.User = Depends(get_current_user),
):
cal = permissions.accessible_local_calendar(db, current_user, calendar_id, require_write=True)
raw = await file.read()
raw = await _read_capped(file)
try:
return _import_ics_into(cal, raw, db)
except ValueError as e:
@@ -467,10 +478,12 @@ async def import_generic(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
# Read (capped) first so an oversized upload can't leave an empty calendar.
raw = await _read_capped(file)
if create_calendar:
cal = models.LocalCalendar(
user_id=current_user.id,
name=calendar_name or "Importiert",
name=(calendar_name or "Importiert")[:120],
)
db.add(cal)
db.commit()
@@ -480,7 +493,6 @@ async def import_generic(
else:
raise HTTPException(422, "Provide calendar_id or create_calendar=true")
raw = await file.read()
try:
result = _import_ics_into(cal, raw, db)
except ValueError as e: