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:
parent
2f45220c5d
commit
925c9caf91
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user