fix: authenticate senders in public email mode

This commit is contained in:
dborosev
2026-09-25 17:59:38 -07:00
committed by kshitij
parent 05684c9f0f
commit c2f595d6af
2 changed files with 39 additions and 43 deletions

View File

@@ -686,15 +686,6 @@ class EmailAdapter(BasePlatformAdapter):
return any(_get_secret(name, "").strip().lower() in _TRUTHY
for name in ("EMAIL_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS"))
@staticmethod
def _open_access() -> bool:
"""True when the gateway admits any sender, so a forged From: gains nothing. The gateway's own order:
EMAIL_ALLOW_ALL_USERS wins over a list, GATEWAY_ALLOW_ALL_USERS applies only while no list is set."""
if _get_secret("EMAIL_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY:
return True
return (_get_secret("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in _TRUTHY
and not any(_get_secret(name, "").strip() for name in ("EMAIL_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS")))
def _answers_unknown_senders(self) -> bool:
"""True when ``platforms.email.unauthorized_dm_behavior`` opts into ``pair`` or ``decline``."""
behavior = (self.config.extra or {}).get("unauthorized_dm_behavior")
@@ -731,16 +722,15 @@ class EmailAdapter(BasePlatformAdapter):
if not granted and not self._answers_unknown_senders():
logger.debug("[Email] Dropping unauthorized sender at dispatch (unknown senders are ignored): %s", sender_addr)
return False
# Reject spoofed senders (GHSA-rxqh-5572-8m77): short of open access, every grant keys on the attacker-controlled
# From:, and a pairing code or decline is mailed back to it, open access or not; fail-closed. Only a granted
# sender's drop warns: forged mail from strangers is routine, and the opt-out hint would be wrong advice for it.
# Reject spoofed senders (GHSA-rxqh-5572-8m77): every grant keys on the attacker-controlled From:, and a pairing
# code or decline is mailed back to it; fail-closed. Open access is no exception: the session and every reply
# key on From:, so a forged one lands in that address's conversation and makes the agent mail it. Only a
# granted sender's drop warns: forged mail from strangers is routine, and the opt-out hint would be wrong advice.
if self._require_authenticated_sender and not msg_data.get("sender_authenticated", False):
if not granted:
logger.debug("[Email] Not answering unknown sender with unauthenticated From: %s (%s)",
sender_addr, msg_data.get("auth_reason", "no verdict"))
return False
if self._open_access():
return True
logger.warning("[Email] Dropping sender with unauthenticated From: %s (%s). If your mail server does not "
"stamp Authentication-Results, set platforms.email.require_authenticated_sender: false "
"(or EMAIL_TRUST_FROM_HEADER=true) to accept the risk.",

View File

@@ -160,7 +160,7 @@ class TestDispatchMessage(unittest.TestCase):
else:
os.environ["EMAIL_ALLOW_ALL_USERS"] = self._prev_allow_all
def _make_adapter(self):
def _make_adapter(self, extra=None):
"""Create an EmailAdapter with mocked env vars."""
from gateway.config import PlatformConfig
with patch.dict(os.environ, {
@@ -173,7 +173,7 @@ class TestDispatchMessage(unittest.TestCase):
"EMAIL_POLL_INTERVAL": "15",
}):
from plugins.platforms.email.adapter import EmailAdapter
adapter = EmailAdapter(PlatformConfig(enabled=True))
adapter = EmailAdapter(PlatformConfig(enabled=True, extra=dict(extra or {})))
return adapter
def test_self_message_filtered(self):
@@ -225,6 +225,7 @@ class TestDispatchMessage(unittest.TestCase):
"body": "How do I use lists?",
"attachments": [],
"date": "",
"sender_authenticated": True,
}
asyncio.run(adapter._dispatch_message(msg_data))
@@ -253,6 +254,7 @@ class TestDispatchMessage(unittest.TestCase):
"body": "Thanks for the help!",
"attachments": [],
"date": "",
"sender_authenticated": True,
}
asyncio.run(adapter._dispatch_message(msg_data))
@@ -283,6 +285,7 @@ class TestDispatchMessage(unittest.TestCase):
"body": "Check this photo",
"attachments": [{"path": "/tmp/img.jpg", "filename": "img.jpg", "type": "image", "media_type": "image/jpeg"}],
"date": "",
"sender_authenticated": True,
}
asyncio.run(adapter._dispatch_message(msg_data))
@@ -320,42 +323,45 @@ class TestDispatchMessage(unittest.TestCase):
adapter._message_handler.assert_not_called()
def test_unauthenticated_allowed_with_allow_all(self):
"""EMAIL_ALLOW_ALL_USERS=true makes sender identity moot — gate skipped.
With allow-all and no restrictive allowlist, an unauthenticated sender
is forwarded: the operator has explicitly chosen to accept anyone.
"""
def _dispatch_under_allow_all(self, allow_all_env, *, authenticated, extra=None, env=None):
"""Dispatch one mail from a stranger with open access on (no allowlist); return the events handed on."""
import asyncio
with patch.dict(os.environ, {
"EMAIL_ALLOW_ALL_USERS": "true",
}):
os.environ.pop("EMAIL_ALLOWED_USERS", None)
os.environ.pop("GATEWAY_ALLOWED_USERS", None)
adapter = self._make_adapter()
with patch.dict(os.environ, {allow_all_env: "true", **(env or {})}):
for key in ("EMAIL_ALLOWED_USERS", "GATEWAY_ALLOWED_USERS"):
os.environ.pop(key, None)
if allow_all_env != "EMAIL_ALLOW_ALL_USERS":
os.environ.pop("EMAIL_ALLOW_ALL_USERS", None)
adapter = self._make_adapter(extra)
captured = []
async def capture_handle(event):
captured.append(event)
adapter.handle_message = capture_handle
asyncio.run(adapter._dispatch_message({
"uid": b"203", "sender_addr": "victim@elsewhere.com", "sender_name": "Victim", "subject": "Hi",
"message_id": "<s@elsewhere.com>", "in_reply_to": "", "body": "Hello", "attachments": [], "date": "",
"sender_authenticated": authenticated,
"auth_reason": "dmarc=pass" if authenticated else "no Authentication-Results header"}))
return captured
msg_data = {
"uid": b"203",
"sender_addr": "stranger@elsewhere.com",
"sender_name": "Stranger",
"subject": "Hi",
"message_id": "<s@elsewhere.com>",
"in_reply_to": "",
"body": "Hello",
"attachments": [],
"date": "",
"sender_authenticated": False,
"auth_reason": "no Authentication-Results header",
}
def test_open_access_refuses_unauthenticated_from(self):
"""Open access admits any sender, not any From: — a forged one would land in that address's session and
make the agent mail it, so require_authenticated_sender still applies."""
for env in ("EMAIL_ALLOW_ALL_USERS", "GATEWAY_ALLOW_ALL_USERS"):
with self.subTest(env):
self.assertEqual(self._dispatch_under_allow_all(env, authenticated=False), [])
asyncio.run(adapter._dispatch_message(msg_data))
self.assertEqual(len(captured), 1)
def test_open_access_admits_authenticated_or_opted_out_sender(self):
cases = {
"authenticated From": {"authenticated": True},
"require_authenticated_sender: false": {"authenticated": False,
"extra": {"require_authenticated_sender": False}},
"EMAIL_TRUST_FROM_HEADER=true": {"authenticated": False, "env": {"EMAIL_TRUST_FROM_HEADER": "true"}},
}
for label, kwargs in cases.items():
with self.subTest(label):
self.assertEqual(len(self._dispatch_under_allow_all("EMAIL_ALLOW_ALL_USERS", **kwargs)), 1)
class TestDispatchDefersToGatewayAuthorization(unittest.TestCase):