diff --git a/plugins/platforms/email/adapter.py b/plugins/platforms/email/adapter.py index 020695af0c..664e41010e 100644 --- a/plugins/platforms/email/adapter.py +++ b/plugins/platforms/email/adapter.py @@ -62,6 +62,9 @@ _COMMENT_RE = re.compile(r"\([^()]*\)") _MAX_FROM_LEN = 2048 # Authentication-Results clause head (``dmarc=pass``), matched only at the start of a clause. _AUTH_METHOD_RE = re.compile(r"\s*(dmarc|dkim|spf)\s*=\s*([a-z]+)", re.IGNORECASE) +# Verdict reason when the message carries no Authentication-Results at all: the one failure that points at a mail +# server which never stamps the header (the opt-out case), rather than at the message itself. +_NO_AUTH_RESULTS_REASON = "no Authentication-Results header" # One token of a clause: a property we read (``header.from=x``; the value may be or contain a quoted-string), or # any other whitespace-delimited token consumed whole, so text inside quotes or other values is never read as a prop. _QUOTED = r'"(?:[^"\\]|\\.)*"' @@ -343,7 +346,7 @@ def _verify_sender_authentication(msg: email_lib.message.Message, from_addr: str if not from_domain: return False, "missing From domain" if not (headers := msg.get_all("Authentication-Results")): - return False, "no Authentication-Results header" + return False, _NO_AUTH_RESULTS_REASON values = (" ".join(str(raw).split()) for raw in headers) # authserv-id precedes the first ';' trusted = next((v for v in values if not authserv_id or (serv := v.split(";", 1)[0].strip().lower()) == authserv_id.lower() or _domains_aligned(serv, authserv_id)), None) @@ -724,17 +727,19 @@ class EmailAdapter(BasePlatformAdapter): return False # 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. + # key on From:, so a forged one lands in that address's conversation and makes the agent mail it. Only a granted + # sender's mail with no Authentication-Results at all warns, since that suggests a server that never stamps it and + # the opt-out hint fits. An explicit failing verdict (dmarc=fail, misaligned SPF/DKIM, ...) is routine forgery, + # even when From: is an allowlisted address or open access grants everyone; the 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 - 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.", - sender_addr, msg_data.get("auth_reason", "no verdict")) + auth_reason = msg_data.get("auth_reason", "no verdict") + if granted and auth_reason == _NO_AUTH_RESULTS_REASON: + 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.", sender_addr, auth_reason) + else: + logger.debug("[Email] Dropping %s sender with unauthenticated From: %s (%s)", + "authorized" if granted else "unknown", sender_addr, auth_reason) return False return True diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 5328a39092..d9cc9748c7 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -380,7 +380,7 @@ class TestDispatchDefersToGatewayAuthorization(unittest.TestCase): def tearDown(self): self._env.stop() - def _reached_gateway(self, *, extra=None, env=None, paired=False, authenticated=True): + def _reached_gateway(self, *, extra=None, env=None, paired=False, authenticated=True, auth_reason=None): """Dispatch one mail from STRANGER with the real GatewayRunner auth callback wired, as startup does; return the events handed to the gateway. Each call gets its own pairing store.""" import asyncio @@ -389,7 +389,7 @@ class TestDispatchDefersToGatewayAuthorization(unittest.TestCase): from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.pairing import PairingStore from gateway.run import GatewayRunner - from plugins.platforms.email.adapter import EmailAdapter + from plugins.platforms.email.adapter import _NO_AUTH_RESULTS_REASON, EmailAdapter with tempfile.TemporaryDirectory() as pairing_dir, \ patch("gateway.pairing.PAIRING_DIR", Path(pairing_dir)), \ patch.dict(os.environ, {"EMAIL_ADDRESS": "hermes@test.com", "EMAIL_PASSWORD": "secret", @@ -414,9 +414,21 @@ class TestDispatchDefersToGatewayAuthorization(unittest.TestCase): "uid": b"301", "sender_addr": self.STRANGER, "sender_name": "Stranger", "subject": "Hello", "message_id": "", "in_reply_to": "", "body": "Hi there", "attachments": [], "date": "", "sender_authenticated": authenticated, - "auth_reason": "dmarc=pass" if authenticated else "no Authentication-Results header"})) + "auth_reason": auth_reason or ("dmarc=pass" if authenticated else _NO_AUTH_RESULTS_REASON)})) return captured + def test_only_a_missing_auth_results_header_warns_with_the_opt_out_hint(self): + """A granted sender's mail with no Authentication-Results suggests a server that never stamps it, so the drop + warns with the opt-out hint; an explicit failing verdict is routine forgery and stays at debug.""" + adapter_log = "plugins.platforms.email.adapter" + with self.assertLogs(adapter_log, level="WARNING") as logs: + self.assertEqual(self._reached_gateway(authenticated=False, env={"EMAIL_ALLOW_ALL_USERS": "true"}), []) + self.assertIn("require_authenticated_sender: false", logs.output[0]) + for label, env in {"open access": {"EMAIL_ALLOW_ALL_USERS": "true"}, + "listed sender": {"EMAIL_ALLOWED_USERS": self.STRANGER}}.items(): + with self.subTest(label), self.assertNoLogs(adapter_log, level="WARNING"): + self.assertEqual(self._reached_gateway(authenticated=False, auth_reason="dmarc=fail", env=env), []) + def test_mail_the_gateway_admits_or_answers_reaches_it(self): cases = { "pair opt-in": {"extra": {"unauthorized_dm_behavior": "pair"}}, diff --git a/website/docs/user-guide/messaging/email.md b/website/docs/user-guide/messaging/email.md index bd92591d16..95980d7cd0 100644 --- a/website/docs/user-guide/messaging/email.md +++ b/website/docs/user-guide/messaging/email.md @@ -177,7 +177,7 @@ Email access is stricter by default than chat-style platforms: Allowlist entries match whole addresses. A bare entry such as `alice` (a chat username in `GATEWAY_ALLOWED_USERS`, say) never admits `alice@` at any domain, and mail from such an address is dropped rather than paired or declined. -Hermes acts on a message only when the `Authentication-Results` header stamped by your receiving server authenticates its `From:` domain (DMARC, or aligned SPF/DKIM). This applies with open access (`EMAIL_ALLOW_ALL_USERS` / `GATEWAY_ALLOW_ALL_USERS`) too: open access admits any sender, but each conversation and every reply is keyed on `From:`, so a forged `From:` would put the attacker's text into that address's session and make Hermes mail it. Pairing codes and declines likewise need an authenticated `From:`, so neither is mailed to a forged address. If your mail server does not stamp that header, set `platforms.email.require_authenticated_sender: false` (or `EMAIL_TRUST_FROM_HEADER=true`) to accept the risk; otherwise such mail is dropped with a `Dropping sender with unauthenticated From` warning in the gateway log. +Hermes acts on a message only when the `Authentication-Results` header stamped by your receiving server authenticates its `From:` domain (DMARC, or aligned SPF/DKIM). This applies with open access (`EMAIL_ALLOW_ALL_USERS` / `GATEWAY_ALLOW_ALL_USERS`) too: open access admits any sender, but each conversation and every reply is keyed on `From:`, so a forged `From:` would put the attacker's text into that address's session and make Hermes mail it. Pairing codes and declines likewise need an authenticated `From:`, so neither is mailed to a forged address. If your mail server does not stamp that header, set `platforms.email.require_authenticated_sender: false` (or `EMAIL_TRUST_FROM_HEADER=true`) to accept the risk; otherwise mail from an admitted sender that carries no such header is dropped with a `Dropping sender with unauthenticated From` warning in the gateway log. Mail whose header records a failing verdict (a forged `From:`, say) is dropped quietly, logged at debug level only. :::warning **Use a dedicated inbox and configure `EMAIL_ALLOWED_USERS` for normal operation.** Email pairing is opt-in because shared inboxes often contain unrelated unread messages, and Hermes should not reply to those contacts by default.