[Bug 296277] mail/py-spf-engine pyspf-milter: eom() raises ValueError when arresults[0] is None, causing untrapped exception and mail tempfail

From: <bugzilla-noreply_at_freebsd.org>
Date: Thu, 25 Jun 2026 14:30:51 UTC
https://bugs.freebsd.org/bugzilla/show_bug.cgi?id=296277

            Bug ID: 296277
           Summary: mail/py-spf-engine pyspf-milter: eom() raises
                    ValueError when arresults[0] is None, causing
                    untrapped exception and mail tempfail
           Product: Ports & Packages
           Version: Latest
          Hardware: Any
                OS: Any
            Status: New
          Severity: Affects Some People
          Priority: ---
         Component: Individual Port(s)
          Assignee: ports-bugs@FreeBSD.org
          Reporter: gessel@blackrosetech.com
 Attachment #272125 text/plain
         mime type:

Created attachment 272125
  --> https://bugs.freebsd.org/bugzilla/attachment.cgi?id=272125&action=edit
Proposed updated patch to milter_spf.py

Synopsis: mail/py-spf-engine: bugfix of pyspf-milter eom() crash on null SPF
result causing repeatable unwanted mail tempfail where the sender has
misconfigured DNS (hi, emailout.cacert.org, [waves])

Summary

The pyspf-milter end-of-message handler in spf_engine/milter_spf.py crashes
with an unhandled ValueError when the SPF engine yields a null result, causing
deterministic non-delivery of the affected message. In eom(), the handler
formats an Authentication-Results header from self.arresults[0]. When that
element is the None singleton, str(h) is the string 'None', which contains no
': ' separator, and the existing name, val = str(h).split(': ', 1) raises
ValueError: not enough values to unpack (expected 2, got 1). pymilter converts
the untrapped exception at the callback boundary into a tempfail, so Postfix
logs milter-reject ... 4.3.0 pymilter: untrapped exception in pyspf-filter and
cancels the message. Because the triggering condition is deterministic for a
given message, every sender retry reproduces it and the mail eventually
bounces.

This was observed in production against CAcert mailing-list traffic due to a
misconfiguration of their outbound MTA (probably an incomplete migration): the
sending host's HELO name (emailout.cacert.org) had no DNS A record and the
message failed SPF (-all) while arriving unauthenticated, and that degenerate
sender context drives the engine to a None result. Capturing h at debugLevel >=
2 immediately before the crash logs 'None' [sic].

Proposed Fix:

The attached patch regenerates files/patch-spf__engine_milter__spf.py to carry
two hunks against spf_engine/milter_spf.py. The functional change is the eom()
guard: it replaces the fragile unpack with str(h).partition(': '), adds the
header only when a separator is present, and otherwise emits a single
LOG_WARNING and falls through to the existing return Milter.CONTINUE. The
result is that a message which previously tempfailed now delivers without an
Authentication-Results header, with the condition logged.

The second hunk — the configFile default '/usr/local/etc/...' to
'%%PREFIX%%/etc/...' — is materially the same change already present in the
port and is not a behavior change. The line anchor moves from 211 to 227 to
match current 3.1.0 source, a non-functional technical correction (the best
kind of correct). The token is expanded to ${PREFIX} by the port's existing
post-patch REINPLACE, which already lists this file, so the combined patch and
the Makefile continue to mesh as before. The only functional change is the
added eom guard.

How to test:

Deliver to a pyspf-milter-filtered Postfix instance a message whose sender
context causes the SPF engine to return a null result — in practice, a sender
whose HELO does not resolve and which fails SPF. The maillog shows the pymilter
untrapped exception reject and removed (canceled), and at debugLevel >= 2 the
preceding syslog line shows h as 'None'. For now, the CAcert mailing list
messages are known to trigger the error if milter_spf is called by postfix and
if the patch is applied, should pass through to whatever additional spam
testing is configured. If CAcert updates their DNS entries, it won't be a
useful test. I've posted to the mailing list at
https://lists.cacert.org/wws/arc/cacert-support/2026-06/msg00005.html, but I
only have viewer status on bugs.cacert.org.

Testing

Built and tested in poudriere on FreeBSD 14.3-RELEASE-p5 amd64, py311 flavor.
Both hunks apply cleanly. The staged file confirms configFile resolved to
/usr/local/etc/python-policyd-spf/policyd-spf.conf (REINPLACE ran) and the
partition guard is present. The package installs and pyspf-milter restarts
cleanly and works as expected. Validated against the live triggering message:
the formerly-fatal message now delivers via the local LMTP/dovecot path with
the new malformed AR result warning logged and no ValueError as I don't hard
fail on spf misconfiguration. Versions: py311-spf-engine-3.1.0,
py311-pyspf-2.0.14_3, py311-pymilter-1.0.6_1, Python 3.11.

Upstream on Launchpad

The null-result handling defect is reported upstream to spf-engine on Launchpad
as bug https://bugs.launchpad.net/spf-engine/+bug/2158212. The partition guard
is the symptom fix; the deeper question of why arresults[0] becomes None is
upstream's to resolve from the engine internals, and the report carries the
'None' capture for that purpose.

Security review

The change should be security-positive and introduces no obvious exploitable
mechanism. Pre-patch, the impact is bounded to a single message: pymilter
converts the eom exception to a per-message tempfail, so the daemon does not
crash, the per-connection context is not corrupted, and other messages are
unaffected, but you don't get your CAcert messages. The condition is not
amplifiable — an attacker can only cause deterministic deferral of their own
message, at the cost of a full SMTP transaction the MTA already rate-limits —
so it carries no useful DoS value and primarily harms the operator through
deferral and eventual bounce of good mail, like CAcert's, which is how it
surfaced. It is not an SPF-enforcement bypass: HELO and Mail-From reject
decisions execute in the envelope-stage callbacks, upstream of eom, so a crash
in eom cannot pass a message past a reject gate; with respect to delivery the
crash is fail-closed, holding the message.

Post-patch, the one semantic shift is that the affected message delivers
without an Authentication-Results header rather than being held — fail-closed
to fail-open for the header's presence, not for enforcement. The shift is
narrow because the trigger is a null result, so the counterfactual header would
not have asserted spf=fail; a downstream spam check treating a missing AR
header as benign would not have quarantined on the header that would otherwise
have appeared. Delivering with an honest absence plus a logged warning seems
like the desirable process, and operators wanting strict behavior retain the
envelope-stage reject options, which this patch doesn't touch.

Two implementation details: the warning uses %r, so attacker-controlled control
characters in h (CR/LF) render escaped and cannot forge syslog lines; the
format template is a constant with the value passed as an argument, so there is
no format-string exposure. Log size should be smaller versus the status quo,
which logged a full Python traceback per occurrence. On the success path,
partition(': ') splits on the first separator identically to split(': ', 1), so
addheader receives byte-identical inputs so there shouldn't be any new
header-injection opportunities. The impact of the patch is only to eliminate a
crash with a logged fail-safe degradation, with no new DoS or bypass
opportunities.

There's no maintainer for this port, I'm not confident of my abilities but am
willing to learn. I use this port in production.

-- 
You are receiving this mail because:
You are the assignee for the bug.