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: if is_invited_editor:
# Invited editor restrictions — enforce BEFORE any data mutation # 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") scope_peek = update_data.get("edit_scope")
# Block all bulk-scope edits on recurring events (C-01/F-01) # Block all bulk-scope edits on recurring events (C-01/F-01)
if event.is_recurring and scope_peek != "this": if event.is_recurring and scope_peek != "this":
raise HTTPException(status_code=403, detail="Invited editors can only edit individual occurrences") 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: else:
# Standard calendar-access path: require create_modify+ permission # Standard calendar-access path: require create_modify+ permission
await require_permission(db, event.calendar_id, current_user.id, "create_modify") 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] responded_at: Optional[datetime]
invitee_name: Optional[str] = None invitee_name: Optional[str] = None
invitee_umbral_name: Optional[str] = None invitee_umbral_name: Optional[str] = None
display_calendar_id: Optional[int] = None
can_modify: bool = False can_modify: bool = False

View File

@ -7,7 +7,7 @@ import logging
from datetime import datetime from datetime import datetime
from fastapi import HTTPException 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.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -67,11 +67,11 @@ async def send_event_invitations(
) )
existing_ids = {r[0] for r in existing_result.all()} 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( 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] new_ids = [uid for uid in user_ids if uid not in existing_ids]
if current_count + len(new_ids) > 20: if current_count + len(new_ids) > 20:
raise HTTPException(status_code=400, detail="Maximum 20 invitations per event") 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() invitation = inv_result.scalar_one_or_none()
if not invitation: if not invitation:
raise HTTPException(status_code=404, detail="Invitation not found") 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 # Verify occurrence belongs to the invited event's series
occ_result = await db.execute( 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
@ -269,6 +269,7 @@ export default function EventDetailPanel({
const isInvitedEvent = !!event?.is_invited; const isInvitedEvent = !!event?.is_invited;
const canModifyAsInvitee = isInvitedEvent && !!event?.can_modify; 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 myInvitationStatus = event?.invitation_status ?? null;
const myInvitationId = event?.invitation_id ?? null; const myInvitationId = event?.invitation_id ?? null;
@ -1105,7 +1106,7 @@ export default function EventDetailPanel({
{!isInvitedEvent && canEdit && ( {!isInvitedEvent && canEdit && (
<InviteSearch <InviteSearch
connections={connections} connections={connections}
existingInviteeIds={new Set(invitees.map((i) => i.user_id))} existingInviteeIds={existingInviteeIds}
onInvite={(userIds) => invite(userIds)} onInvite={(userIds) => invite(userIds)}
isInviting={isInviting} isInviting={isInviting}
/> />

View File

@ -504,7 +504,7 @@ export interface EventInvitation {
responded_at: string | null; responded_at: string | null;
invitee_name: string; invitee_name: string;
invitee_umbral_name: string; invitee_umbral_name: string;
can_modify?: boolean; can_modify: boolean;
} }
export interface PendingEventInvitation { export interface PendingEventInvitation {