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:
kshitijk4poor
2026-09-27 21:15:25 +05:30
committed by kshitij
parent fc8307f35b
commit 4c982e9ca4
3 changed files with 32 additions and 15 deletions

View File

@@ -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

View File

@@ -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"}},

View File

@@ -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.