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) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 01:28:01 +08:00
parent 2f45220c5d
commit 925c9caf91
5 changed files with 15 additions and 11 deletions

View File

@ -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")

View File

@ -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

View File

@ -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(

View File

@ -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 && (
<InviteSearch
connections={connections}
existingInviteeIds={new Set(invitees.map((i) => i.user_id))}
existingInviteeIds={existingInviteeIds}
onInvite={(userIds) => invite(userIds)}
isInviting={isInviting}
/>

View File

@ -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 {