The email auto-calendar pass (settings.email_auto_calendar / the extract_email_events task) scans recently received mail and lets an LLM create / update / cancel calendar events. Two problems made it a cross-tenant, remotely triggerable hole: 1. No owner scoping. _auto_summarize_pass(account_id=None) fans out over EVERY enabled account of EVERY user. For each message it fetched an upcoming-events snapshot with NO owner filter (all tenants' events) and handed those uids + titles to the extraction LLM, then executed the model's ops via do_manage_calendar(...) with owner=None. do_manage_calendar only filters by owner when owner is not None, so create/update/delete ran across ALL users' calendars. Net: every user's event titles/times were disclosed to the model, and the model could cancel/move/duplicate any tenant's events by uid. 2. No prompt-injection wrapping. The raw email From/Subject/body were interpolated straight into an instruction-shaped extraction prompt (unlike the chat path, which wraps external text via src/prompt_security). Anyone who can email a user whose instance has auto-calendar enabled could inject operations: create attacker-controlled "meeting" events (the path even auto-harvests URLs from the body into the event location/description — a phishing primitive) or cancel/modify the victim's real events, with zero human in the loop. Fix: - Add core.database.get_upcoming_events(owner) and use it for the snapshot, so the LLM only ever sees the processed account owner's events. - Look up the EmailAccount owner in _auto_summarize_pass_single and pass owner= to every do_manage_calendar call, so create/update/delete are scoped to that user (owner=None stays the single-user / legacy escape hatch). - Tell the extraction model the email is untrusted data and not to follow instructions inside it (defense-in-depth against injection). Add tests/test_calendar_owner_scope.py: get_upcoming_events returns only the given owner's events (and everything when owner is None). Fails against the old unscoped query.
49 lines
2.1 KiB
Python
49 lines
2.1 KiB
Python
"""Pin owner-scoping of the autonomous email->calendar event snapshot.
|
|
|
|
The email auto-calendar pass fans out over EVERY user's mailbox and used to
|
|
feed an *unscoped* upcoming-events snapshot to the extraction LLM, then execute
|
|
the model's create/update/delete ops via do_manage_calendar with owner=None —
|
|
so processing one tenant's mail could read AND mutate another tenant's calendar
|
|
(and leak every tenant's event titles to the LLM endpoint).
|
|
|
|
The fix routes the snapshot through core.database.get_upcoming_events(owner)
|
|
and passes the account owner to do_manage_calendar. This test pins that
|
|
get_upcoming_events scopes to the owner; it fails if the owner filter is
|
|
dropped (the original cross-tenant behavior).
|
|
"""
|
|
import os
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from core import database as db
|
|
|
|
|
|
def test_get_upcoming_events_is_owner_scoped():
|
|
db.Base.metadata.create_all(bind=db.engine)
|
|
soon = datetime.utcnow() + timedelta(days=2)
|
|
end = soon + timedelta(hours=1)
|
|
|
|
s = db.SessionLocal()
|
|
try:
|
|
s.merge(db.CalendarCal(id="cal-alice", owner="alice", name="Alice"))
|
|
s.merge(db.CalendarCal(id="cal-bob", owner="bob", name="Bob"))
|
|
s.merge(db.CalendarEvent(uid="ev-alice", calendar_id="cal-alice",
|
|
summary="Alice 1:1", dtstart=soon, dtend=end))
|
|
s.merge(db.CalendarEvent(uid="ev-bob", calendar_id="cal-bob",
|
|
summary="Bob 1:1", dtstart=soon, dtend=end))
|
|
s.commit()
|
|
finally:
|
|
s.close()
|
|
|
|
alice = {e["uid"] for e in db.get_upcoming_events("alice")}
|
|
bob = {e["uid"] for e in db.get_upcoming_events("bob")}
|
|
everyone = {e["uid"] for e in db.get_upcoming_events(None)}
|
|
|
|
# An owner sees ONLY their own events — never the other tenant's.
|
|
assert alice == {"ev-alice"}, alice
|
|
assert bob == {"ev-bob"}, bob
|
|
assert "ev-bob" not in alice and "ev-alice" not in bob
|
|
# owner=None is the explicit single-user / legacy escape hatch (unscoped).
|
|
assert {"ev-alice", "ev-bob"} <= everyone
|