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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user