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

@@ -19,7 +19,7 @@ from sqlalchemy.orm import Session
import models
from auth import get_current_user
from database import get_db
from local_events_util import build_local_event_dict, expand_recurring_local
from local_events_util import build_local_event_dict, expand_recurring_local, mask_busy_event
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -309,16 +309,6 @@ def delete_group(
return {"ok": True}
def _strip_busy(event: dict) -> dict:
"""Anonymise a private event for the 'busy' visibility mode."""
event = dict(event)
event["title"] = "Beschäftigt"
event["location"] = ""
event["description"] = ""
event["private"] = True
return event
def _first_name(name: Optional[str]) -> str:
if not name:
return ""
@@ -415,7 +405,7 @@ def combined_events(
for b in built:
if ev.is_private and creator_owner_id != current_user.id and visibility_for(creator_owner_id) == "busy":
b = _strip_busy(b)
b = mask_busy_event(b)
# Colour to render with: the group calendar's colour for group
# events, otherwise the owning member's group colour.
b["display_color"] = group_cal_color if is_group else member_color.get(owner_id)