Files
odysseus/tests/test_calendar_owner_scope.py

346 lines
11 KiB
Python
Raw Normal View History

Scope email calendar extraction to account owner 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.
2026-06-01 10:12:32 -04:00
"""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).
"""
2026-06-02 05:48:59 +09:00
import ast
import asyncio
import sys
import types
2026-06-02 05:48:59 +09:00
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import HTTPException
Scope email calendar extraction to account owner 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.
2026-06-01 10:12:32 -04:00
def test_get_upcoming_events_is_owner_scoped():
2026-06-02 05:48:59 +09:00
source = Path("core/database.py").read_text()
tree = ast.parse(source)
fn = next(
node for node in tree.body
if isinstance(node, ast.FunctionDef) and node.name == "get_upcoming_events"
)
body = ast.unparse(fn)
assert "join(CalendarCal)" in body
assert "if owner is not None:" in body
assert "q.filter(CalendarCal.owner == owner)" in body
class _Expr:
def __init__(self, op, field=None, value=None, children=()):
self.op = op
self.field = field
self.value = value
self.children = tuple(children)
def __or__(self, other):
return _Expr("or", children=(self, other))
def __and__(self, other):
return _Expr("and", children=(self, other))
class _Column:
def __init__(self, field):
self.field = field
def __eq__(self, value):
return _Expr("eq", self.field, value)
def __ne__(self, value):
return _Expr("ne", self.field, value)
def __lt__(self, value):
return _Expr("lt", self.field, value)
def __gt__(self, value):
return _Expr("gt", self.field, value)
def is_(self, value):
return _Expr("is", self.field, value)
def isnot(self, value):
return _Expr("isnot", self.field, value)
def _expr_contains(expr, field, value):
if isinstance(expr, _Expr):
if expr.field == field and expr.value == value:
return True
return any(_expr_contains(child, field, value) for child in expr.children)
return False
class _CalendarCal:
id = _Column("CalendarCal.id")
owner = _Column("CalendarCal.owner")
name = _Column("CalendarCal.name")
class _CalendarEvent:
uid = _Column("CalendarEvent.uid")
status = _Column("CalendarEvent.status")
rrule = _Column("CalendarEvent.rrule")
dtstart = _Column("CalendarEvent.dtstart")
dtend = _Column("CalendarEvent.dtend")
calendar_id = _Column("CalendarEvent.calendar_id")
class _FakeQuery:
def __init__(self, rows):
self.rows = rows
self.filter_calls = []
self.owner_filter = None
self.all_called = False
def join(self, *_args, **_kwargs):
return self
def filter(self, *exprs):
self.filter_calls.append(exprs)
for expr in exprs:
if _expr_contains(expr, "CalendarCal.owner", "alice"):
self.owner_filter = "alice"
return self
def order_by(self, *_args, **_kwargs):
return self
def first(self):
return self.rows[0] if self.rows else None
def all(self):
self.all_called = True
if self.owner_filter is None:
return list(self.rows)
return [
row for row in self.rows
if getattr(getattr(row, "calendar", None), "owner", None) == self.owner_filter
]
class _FakeSession:
def __init__(self, *, calendars=(), events=()):
self.calendar_query = _FakeQuery(list(calendars))
self.event_query = _FakeQuery(list(events))
self.add = MagicMock()
self.commit = MagicMock()
self.rollback = MagicMock()
self.close = MagicMock()
def query(self, model):
if model is _CalendarCal:
return self.calendar_query
if model is _CalendarEvent:
return self.event_query
raise AssertionError(f"unexpected query model: {model!r}")
def _install_calendar_db_stub(monkeypatch):
db = types.ModuleType("core.database")
db.SessionLocal = MagicMock()
db.CalendarCal = _CalendarCal
db.CalendarDeletedEvent = MagicMock()
db.CalendarEvent = _CalendarEvent
for name in [
"Base",
"Document",
"DocumentVersion",
"Session",
"ChatMessage",
"GalleryImage",
"GalleryAlbum",
"Note",
"ScheduledTask",
"TaskRun",
"ModelEndpoint",
"Webhook",
]:
setattr(db, name, MagicMock())
monkeypatch.setitem(sys.modules, "core.database", db)
return db
def _install_multipart_stub(monkeypatch):
multipart = types.ModuleType("python_multipart")
multipart.__version__ = "0.0.20"
monkeypatch.setitem(sys.modules, "python_multipart", multipart)
def _import_calendar_routes(monkeypatch):
_install_calendar_db_stub(monkeypatch)
_install_multipart_stub(monkeypatch)
monkeypatch.delitem(sys.modules, "routes.calendar_routes", raising=False)
mod = __import__("routes.calendar_routes", fromlist=["setup_calendar_routes"])
monkeypatch.setattr(mod, "or_", lambda *args: _Expr("or", children=args))
monkeypatch.setattr(mod, "and_", lambda *args: _Expr("and", children=args))
return mod
def _route_endpoint(calendar_routes, path, method):
router = calendar_routes.setup_calendar_routes()
full_path = f"/api/calendar{path}"
for route in router.routes:
if route.path == full_path and method in route.methods:
return route.endpoint
raise AssertionError(f"route not found: {method} {full_path}")
def _request(user="alice"):
return SimpleNamespace(state=SimpleNamespace(current_user=user))
def _calendar(owner, cal_id="cal-target"):
return SimpleNamespace(id=cal_id, owner=owner, name=f"{owner or 'null'} calendar")
def _event(owner, uid):
return SimpleNamespace(
uid=uid,
calendar=_calendar(owner, cal_id=f"{owner or 'null'}-cal"),
calendar_id=f"{owner or 'null'}-cal",
dtstart=SimpleNamespace(isoformat=lambda: f"{uid}-start"),
dtend=SimpleNamespace(isoformat=lambda: f"{uid}-end"),
summary=uid,
description="",
location="",
all_day=False,
is_utc=False,
rrule="",
color=None,
event_type=None,
importance="normal",
)
def test_create_event_rejects_null_owner_calendar_href_at_route_boundary(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
session = _FakeSession(calendars=[_calendar(None)])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
create_event = _route_endpoint(calendar_routes, "/events", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(create_event(
_request(),
calendar_routes.EventCreate(
summary="blocked",
dtstart="2026-06-02T10:00:00",
calendar_href="cal-target",
),
))
assert exc.value.status_code == 404
session.add.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
def test_create_event_rejects_cross_owner_calendar_href_at_route_boundary(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
session = _FakeSession(calendars=[_calendar("bob")])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
create_event = _route_endpoint(calendar_routes, "/events", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(create_event(
_request(),
calendar_routes.EventCreate(
summary="blocked",
dtstart="2026-06-02T10:00:00",
calendar_href="cal-target",
),
))
assert exc.value.status_code == 404
session.add.assert_not_called()
session.commit.assert_not_called()
session.close.assert_called_once()
def test_list_events_filters_by_calendar_owner_before_output(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
session = _FakeSession(events=[
_event(None, "null-owner"),
_event("bob", "bob-event"),
_event("alice", "alice-event"),
])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
expanded = []
def fake_expand(event, _start, _end):
assert event.calendar.owner == "alice"
expanded.append(event.uid)
return [{"uid": event.uid, "dtstart": "2026-06-02T10:00:00"}]
monkeypatch.setattr(calendar_routes, "_expand_rrule", fake_expand)
list_events = _route_endpoint(calendar_routes, "/events", "GET")
out = asyncio.run(list_events(
_request(),
start="2026-06-01T00:00:00",
end="2026-06-03T00:00:00",
))
assert out == {"events": [{"uid": "alice-event", "dtstart": "2026-06-02T10:00:00"}]}
assert expanded == ["alice-event"]
assert session.event_query.owner_filter == "alice"
session.close.assert_called_once()
def test_export_ics_rejects_null_owner_calendar_at_route_boundary(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
session = _FakeSession(calendars=[_calendar(None)])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
export_ics = _route_endpoint(calendar_routes, "/export/{cal_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(export_ics(_request(), cal_id="cal-target"))
assert exc.value.status_code == 404
assert not session.event_query.all_called
session.close.assert_called_once()
def test_export_ics_rejects_cross_owner_calendar_at_route_boundary(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
session = _FakeSession(calendars=[_calendar("bob")])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
export_ics = _route_endpoint(calendar_routes, "/export/{cal_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(export_ics(_request(), cal_id="cal-target"))
assert exc.value.status_code == 404
assert not session.event_query.all_called
session.close.assert_called_once()
def test_export_ics_sanitizes_calendar_name_for_download_header(monkeypatch):
calendar_routes = _import_calendar_routes(monkeypatch)
cal = _calendar("alice")
cal.name = 'Work\r\nX-Injected: yes";/..\\evil'
session = _FakeSession(calendars=[cal])
monkeypatch.setattr(calendar_routes, "SessionLocal", lambda: session)
export_ics = _route_endpoint(calendar_routes, "/export/{cal_id}", "GET")
response = asyncio.run(export_ics(_request(), cal_id="cal-target"))
assert (
response.headers["content-disposition"]
== 'attachment; filename="Work__X-Injected__yes___.._evil.ics"'
)
assert response.headers["x-content-type-options"] == "nosniff"
session.close.assert_called_once()