From 925c9caf91bdc00784bfe306baa907d647e2e441 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Tue, 17 Mar 2026 01:28:01 +0800 Subject: [PATCH] Fix QA and pentest findings for event invitations C-01: Use func.count() for invitation cap instead of loading all rows C-02: Remove unused display_calendar_id from EventInvitationResponse F-01: Add field allowlist for invited editors (blocks is_starred, recurrence_rule, calendar_id mutations) W-02: Memoize existingInviteeIds Set in EventDetailPanel W-03: Block per-occurrence overrides on declined/pending invitations S-01: Make can_modify non-optional in EventInvitation TypeScript type Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/events.py | 8 +++++--- backend/app/schemas/event_invitation.py | 1 - backend/app/services/event_invitation.py | 10 ++++++---- frontend/src/components/calendar/EventDetailPanel.tsx | 5 +++-- frontend/src/types/index.ts | 2 +- 5 files changed, 15 insertions(+), 11 deletions(-) diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index 5a9525e..b89ec9f 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -488,13 +488,15 @@ async def update_event( if is_invited_editor: # Invited editor restrictions — enforce BEFORE any data mutation + # Field allowlist: invited editors can only modify event content, not structure + INVITED_EDITOR_ALLOWED = {"title", "description", "start_datetime", "end_datetime", "all_day", "color", "edit_scope", "location_id"} + disallowed = set(update_data.keys()) - INVITED_EDITOR_ALLOWED + if disallowed: + raise HTTPException(status_code=403, detail="Invited editors cannot modify: " + ", ".join(sorted(disallowed))) scope_peek = update_data.get("edit_scope") # Block all bulk-scope edits on recurring events (C-01/F-01) if event.is_recurring and scope_peek != "this": raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences") - # Block calendar moves (C-02) - if "calendar_id" in update_data and update_data["calendar_id"] != event.calendar_id: - raise HTTPException(status_code=403, detail="Invited editors cannot move events between calendars") else: # Standard calendar-access path: require create_modify+ permission await require_permission(db, event.calendar_id, current_user.id, "create_modify") diff --git a/backend/app/schemas/event_invitation.py b/backend/app/schemas/event_invitation.py index f59bb9a..024704a 100644 --- a/backend/app/schemas/event_invitation.py +++ b/backend/app/schemas/event_invitation.py @@ -40,5 +40,4 @@ class EventInvitationResponse(BaseModel): responded_at: Optional[datetime] invitee_name: Optional[str] = None invitee_umbral_name: Optional[str] = None - display_calendar_id: Optional[int] = None can_modify: bool = False diff --git a/backend/app/services/event_invitation.py b/backend/app/services/event_invitation.py index 5e4a872..d6b6c02 100644 --- a/backend/app/services/event_invitation.py +++ b/backend/app/services/event_invitation.py @@ -7,7 +7,7 @@ import logging from datetime import datetime from fastapi import HTTPException -from sqlalchemy import delete, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -67,11 +67,11 @@ async def send_event_invitations( ) existing_ids = {r[0] for r in existing_result.all()} - # Cap: max 20 pending invitations per event + # Cap: max 20 invitations per event count_result = await db.execute( - select(EventInvitation.id).where(EventInvitation.event_id == event_id) + select(func.count(EventInvitation.id)).where(EventInvitation.event_id == event_id) ) - current_count = len(count_result.all()) + current_count = count_result.scalar_one() new_ids = [uid for uid in user_ids if uid not in existing_ids] if current_count + len(new_ids) > 20: raise HTTPException(status_code=400, detail="Maximum 20 invitations per event") @@ -219,6 +219,8 @@ async def override_occurrence_status( invitation = inv_result.scalar_one_or_none() if not invitation: raise HTTPException(status_code=404, detail="Invitation not found") + if invitation.status not in ("accepted", "tentative"): + raise HTTPException(status_code=400, detail="Must accept or tentatively accept the invitation first") # Verify occurrence belongs to the invited event's series occ_result = await db.execute( diff --git a/frontend/src/components/calendar/EventDetailPanel.tsx b/frontend/src/components/calendar/EventDetailPanel.tsx index 0781656..b8e08e8 100644 --- a/frontend/src/components/calendar/EventDetailPanel.tsx +++ b/frontend/src/components/calendar/EventDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, parseISO } from 'date-fns'; @@ -269,6 +269,7 @@ export default function EventDetailPanel({ const isInvitedEvent = !!event?.is_invited; const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify; + const existingInviteeIds = useMemo(() => new Set(invitees.map((i) => i.user_id)), [invitees]); const myInvitationStatus = event?.invitation_status ?? null; const myInvitationId = event?.invitation_id ?? null; @@ -1105,7 +1106,7 @@ export default function EventDetailPanel({ {!isInvitedEvent && canEdit && ( i.user_id))} + existingInviteeIds={existingInviteeIds} onInvite={(userIds) => invite(userIds)} isInviting={isInviting} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 0bbc8d1..2442629 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -504,7 +504,7 @@ export interface EventInvitation { responded_at: string | null; invitee_name: string; invitee_umbral_name: string; - can_modify?: boolean; + can_modify: boolean; } export interface PendingEventInvitation {