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

@@ -1,4 +1,5 @@
import io
import re
import base64
from pathlib import Path
from typing import Optional
@@ -8,7 +9,7 @@ import qrcode
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, Response
from PIL import Image
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import func
@@ -27,9 +28,16 @@ ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
# ── Schemas ───────────────────────────────────────────────
class ProfileUpdate(BaseModel):
email: Optional[str] = None
display_name: Optional[str] = None
username: Optional[str] = None # login name (stored lowercase)
# Length caps (SQLite ignores VARCHAR limits, so enforce here).
email: Optional[str] = Field(default=None, max_length=120)
display_name: Optional[str] = Field(default=None, max_length=80)
username: Optional[str] = Field(default=None, max_length=50) # login name (stored lowercase)
def _strip_controls(s: str) -> str:
"""Remove control characters (defends against injected newlines / NULs that
could be reflected in other clients' calendar/sharing/group views)."""
return re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
class PasswordChange(BaseModel):
@@ -67,12 +75,26 @@ def update_profile(
):
result = {"ok": True}
if data.email is not None:
current_user.email = data.email or None
email = _strip_controls(data.email)
if email:
if "@" not in email or "." not in email.split("@")[-1]:
raise HTTPException(422, "Invalid email address")
clash = (
db.query(models.User)
.filter(func.lower(models.User.email) == email.lower(),
models.User.id != current_user.id)
.first()
)
if clash:
raise HTTPException(400, "Email already in use")
current_user.email = email
else:
current_user.email = None
if data.display_name is not None:
dn = data.display_name.strip()
dn = _strip_controls(data.display_name)
current_user.display_name = dn or current_user.username
if data.username is not None:
new_login = data.username.strip().lower()
new_login = _strip_controls(data.username).lower()
if not new_login:
raise HTTPException(422, "Login name cannot be empty")
if new_login != current_user.username: