fix(email): warn with the opt-out hint only when Authentication-Results is missing
Under open access every stranger is granted, so each forged or DMARC-failing message logged a WARNING telling the operator to disable sender authentication. Keep that warning (and hint) for the one verdict that points at a server that never stamps the header; log every other unauthenticated drop at debug, including an explicit failing verdict for an allowlisted address. The reason string is lifted to _NO_AUTH_RESULTS_REASON so the parser and the check share it.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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": "<m301@example.com>", "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"}},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user