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:
@@ -14,7 +14,13 @@ import models
|
||||
import permissions
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from local_events_util import build_local_event_dict, expand_recurring_local, resolve_creator
|
||||
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__)
|
||||
@@ -335,6 +341,14 @@ def get_events(
|
||||
.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)
|
||||
@@ -351,10 +365,27 @@ def get_events(
|
||||
)
|
||||
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:
|
||||
all_events.extend(expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator))
|
||||
built = expand_recurring_local(ev, local_cal, start_dt, end_dt, creator=creator)
|
||||
else:
|
||||
all_events.append(build_local_event_dict(ev, local_cal, rrule=None, creator=creator))
|
||||
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 = (
|
||||
|
||||
Reference in New Issue
Block a user