From 206144d20d9af925123a1bdf66b109427f22d0db Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Fri, 6 Mar 2026 23:37:05 +0800 Subject: [PATCH] Fix 2 pentest findings: unlock permission check + permanent lock preservation SC-01: unlock_event now verifies caller has access to the calendar before revealing lock state. Previously any authenticated user could probe event existence via 404/204/403 response differences. SC-02: acquire_lock no longer overwrites permanent locks. If the owner holds a permanent lock and clicks Edit, the existing lock is returned as-is instead of being downgraded to a 5-minute temporary lock. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/shared_calendars.py | 5 +++++ backend/app/services/calendar_sharing.py | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/backend/app/routers/shared_calendars.py b/backend/app/routers/shared_calendars.py index 047e68e..3ad07ba 100644 --- a/backend/app/routers/shared_calendars.py +++ b/backend/app/routers/shared_calendars.py @@ -716,6 +716,11 @@ async def unlock_event( if not event: raise HTTPException(status_code=404, detail="Event not found") + # SC-01: Verify caller has access to this calendar before revealing lock state + perm = await get_user_permission(db, event.calendar_id, current_user.id) + if perm is None: + raise HTTPException(status_code=404, detail="Event not found") + lock_result = await db.execute( select(EventLock).where(EventLock.event_id == event_id) ) diff --git a/backend/app/services/calendar_sharing.py b/backend/app/services/calendar_sharing.py index 9be24d0..71bba15 100644 --- a/backend/app/services/calendar_sharing.py +++ b/backend/app/services/calendar_sharing.py @@ -66,8 +66,20 @@ async def acquire_lock(db: AsyncSession, event_id: int, user_id: int) -> EventLo """ Atomic INSERT ON CONFLICT — acquires a 5-minute lock on the event. Only succeeds if no unexpired lock exists or the existing lock is held by the same user. + Permanent locks are never overwritten — if the same user holds one, it is returned as-is. Returns the lock or raises 423 Locked. """ + # Check for existing permanent lock first + existing = await db.execute( + select(EventLock).where(EventLock.event_id == event_id) + ) + existing_lock = existing.scalar_one_or_none() + if existing_lock and existing_lock.is_permanent: + if existing_lock.locked_by == user_id: + # Owner holds permanent lock — return it without downgrading + return existing_lock + raise HTTPException(status_code=423, detail="Event is permanently locked by the calendar owner") + now = datetime.now() expires = now + timedelta(minutes=LOCK_DURATION_MINUTES)