Skip to content

Commit c8169ad

Browse files
authored
fix(security): scope owner-less email accounts to a mailbox match in route guards (#5238)
The HTTP email route guard `_assert_owns_account` and the explicit-account_id path in `_get_email_config` gated cross-tenant access with `if row.owner and row.owner != owner` -- which skips the check entirely when the account row is owner-less (owner NULL or ""). `email_accounts` is the one owner-scoped table left out of the legacy-owner migration backfill (core/database.py), so such rows persist on multi-user deploys: an account configured while auth was disabled, or an imported legacy row. Any authenticated user could then pass that account's id to read/send/update-credentials/delete another tenant's mailbox and read its decrypted IMAP/SMTP creds. Both sibling paths already enforce the intended contract -- the same-file `_owner_or_matching_legacy_account` fallback and the MCP `_account_visible_to_owner` gate (whose comment says it mirrors "the HTTP email route fallback") only expose an owner-less account when its own mailbox (imap_user / from_address) is the caller's. Factor that row-level predicate into `_account_visible_to_owner` and use it in both guards, so owner-less accounts are visible only on a mailbox match. Owned accounts, the legacy-claim path, and single-user mode (owner == "") are unchanged. Complements #5234 (which fixes the same class on the MCP tool layer); this is the HTTP route layer it does not touch.
1 parent 5acd0ce commit c8169ad

2 files changed

Lines changed: 142 additions & 4 deletions

File tree

routes/email_helpers.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
349349
row = db.query(_EA).filter(_EA.id == account_id).first()
350350
if row is None:
351351
raise HTTPException(404, "Account not found")
352-
if row.owner and row.owner != owner:
352+
if not _account_visible_to_owner(row, owner):
353353
# Treat as 404 (not 403) so we don't leak existence.
354354
raise HTTPException(404, "Account not found")
355355
finally:
@@ -362,6 +362,26 @@ def _assert_owns_account(account_id: str, owner: str) -> None:
362362
logger.error(f"Account-owner check failed: {e}")
363363
raise HTTPException(503, "Account check failed")
364364

365+
366+
def _account_visible_to_owner(row, owner: str) -> bool:
367+
"""Whether an authenticated `owner` may act on this EmailAccount row.
368+
369+
Mirrors the SQL predicate in `_get_email_config`'s
370+
`_owner_or_matching_legacy_account`: a caller sees an account they own, or a
371+
legacy owner-less account (owner NULL/"") only when its own mailbox
372+
(`imap_user` / `from_address`) is the caller's. `email_accounts` is the one
373+
owner-scoped table deliberately left out of the legacy-owner migration
374+
backfill, so ownerless rows persist on multi-user deploys — making this the
375+
gate that keeps one tenant off another's imported mailbox and its decrypted
376+
IMAP/SMTP credentials."""
377+
row_owner = getattr(row, "owner", None) or ""
378+
if row_owner:
379+
return row_owner == owner
380+
return owner in {
381+
getattr(row, "imap_user", None) or "",
382+
getattr(row, "from_address", None) or "",
383+
}
384+
365385
def _q(name: str) -> str:
366386
"""Quote an IMAP mailbox name. Defensive: escapes `\\` and `"` and wraps
367387
in double quotes so user-supplied folder names with spaces or quotes can't
@@ -903,12 +923,13 @@ def _owner_or_matching_legacy_account(query):
903923
try:
904924
if account_id:
905925
row = db.query(_EA).filter(_EA.id == account_id, _EA.enabled == True).first() # noqa: E712
906-
# If the resolved row belongs to a different owner, treat as
926+
# If the resolved row isn't visible to this owner, treat as
907927
# not-found rather than silently serving it. This is a defense
908928
# in depth — `require_owner` already calls `_assert_owns_account`
909929
# for query-param account_ids, but other callers (cookbook
910-
# rules, scheduled poller) may not.
911-
if row is not None and owner and row.owner and row.owner != owner:
930+
# rules, scheduled poller) may not. Ownerless legacy rows are
931+
# only visible on a mailbox match, same as the fallback below.
932+
if row is not None and owner and not _account_visible_to_owner(row, owner):
912933
row = None
913934
# Fallback path — restrict to this owner's accounts so we don't
914935
# leak another user's default mailbox to an unconfigured user.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Cross-tenant access control for legacy owner-less email accounts.
2+
3+
`email_accounts` is the one owner-scoped table left out of the legacy-owner
4+
migration backfill (core/database.py), so rows with owner NULL/"" persist on a
5+
multi-user deploy — e.g. an account configured while auth was disabled, or an
6+
imported legacy row. The HTTP route guards (`_assert_owns_account` and the
7+
explicit-account_id path in `_get_email_config`) must scope such rows to a
8+
mailbox match, exactly like the `_owner_or_matching_legacy_account` fallback and
9+
the MCP `_account_visible_to_owner` gate. Otherwise any authenticated user can
10+
read/send/update-credentials/delete another tenant's imported mailbox.
11+
"""
12+
13+
from unittest import mock
14+
15+
import pytest
16+
from fastapi import HTTPException
17+
18+
19+
def _make_db():
20+
from sqlalchemy import create_engine
21+
from sqlalchemy.orm import sessionmaker
22+
from core.database import Base
23+
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
24+
Base.metadata.create_all(engine)
25+
Factory = sessionmaker(bind=engine)
26+
return Factory
27+
28+
29+
def _make_account(Factory, account_id, owner, imap_user, from_address="", is_default=False):
30+
from core.database import EmailAccount
31+
db = Factory()
32+
row = EmailAccount(
33+
id=account_id,
34+
owner=owner,
35+
name="Test",
36+
enabled=True,
37+
is_default=is_default,
38+
imap_host="imap.example.com",
39+
imap_port=993,
40+
imap_user=imap_user,
41+
smtp_host="smtp.example.com",
42+
smtp_port=587,
43+
smtp_user=imap_user,
44+
from_address=from_address or imap_user,
45+
)
46+
db.add(row)
47+
db.commit()
48+
db.close()
49+
50+
51+
def test_assert_owns_account_rejects_ownerless_account_for_other_tenant():
52+
"""The core regression: a legacy owner-less mailbox is NOT accessible to an
53+
authenticated caller whose own mailbox does not match it."""
54+
from routes.email_helpers import _assert_owns_account
55+
Factory = _make_db()
56+
# owner="" (created while auth was disabled); mailbox belongs to victim.
57+
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com")
58+
59+
with mock.patch("core.database.SessionLocal", Factory):
60+
with pytest.raises(HTTPException) as exc:
61+
_assert_owns_account("acct-legacy", "attacker")
62+
assert exc.value.status_code == 404
63+
64+
65+
def test_assert_owns_account_allows_owned_account():
66+
from routes.email_helpers import _assert_owns_account
67+
Factory = _make_db()
68+
_make_account(Factory, "acct-bob", owner="bob", imap_user="bob@corp.com")
69+
with mock.patch("core.database.SessionLocal", Factory):
70+
_assert_owns_account("acct-bob", "bob") # no raise
71+
72+
73+
def test_assert_owns_account_allows_ownerless_account_on_mailbox_match():
74+
"""Legacy-claim path stays intact: the user whose mailbox matches an
75+
owner-less account may still act on it (imap_user or from_address)."""
76+
from routes.email_helpers import _assert_owns_account
77+
Factory = _make_db()
78+
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
79+
with mock.patch("core.database.SessionLocal", Factory):
80+
_assert_owns_account("acct-legacy", "alice@corp.com") # no raise
81+
82+
83+
def test_assert_owns_account_noop_for_single_user_mode():
84+
"""owner == "" (unconfigured / single-user) accepts any account, unchanged."""
85+
from routes.email_helpers import _assert_owns_account
86+
Factory = _make_db()
87+
_make_account(Factory, "acct-legacy", owner="", imap_user="whoever@corp.com")
88+
with mock.patch("core.database.SessionLocal", Factory):
89+
_assert_owns_account("acct-legacy", "") # no raise
90+
91+
92+
def test_get_email_config_does_not_resolve_ownerless_account_for_other_tenant(monkeypatch):
93+
"""`_get_email_config(account_id=..., owner=...)` must not serve an
94+
owner-less account (and its decrypted creds) to a non-matching tenant."""
95+
import routes.email_helpers as eh
96+
Factory = _make_db()
97+
_make_account(Factory, "acct-legacy", owner="", imap_user="victim@corp.com", is_default=True)
98+
99+
# Make the settings.json / env fallback empty and deterministic.
100+
monkeypatch.setattr(eh, "_load_settings", lambda: {}, raising=False)
101+
for var in ("IMAP_HOST", "SMTP_HOST", "IMAP_USER", "SMTP_USER"):
102+
monkeypatch.delenv(var, raising=False)
103+
104+
with mock.patch("core.database.SessionLocal", Factory):
105+
cfg = eh._get_email_config(account_id="acct-legacy", owner="attacker")
106+
107+
assert cfg.get("account_id") != "acct-legacy"
108+
109+
110+
def test_get_email_config_resolves_ownerless_account_on_mailbox_match():
111+
"""The mailbox owner still resolves their claimable legacy account by id."""
112+
import routes.email_helpers as eh
113+
Factory = _make_db()
114+
_make_account(Factory, "acct-legacy", owner="", imap_user="alice@corp.com")
115+
with mock.patch("core.database.SessionLocal", Factory):
116+
cfg = eh._get_email_config(account_id="acct-legacy", owner="alice@corp.com")
117+
assert cfg.get("account_id") == "acct-legacy"

0 commit comments

Comments
 (0)