fix: authenticate senders in public email mode
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user