While comparing 6.0.2 and 6.0.3, I found a regression in SPF include and redirect lookups following the TXT string-boundary change in #282 (related to #281).
A target published as one TXT record containing "v=spf1" " -all" is accepted in 6.0.2 but reported as missing in 6.0.3. Splitting inside the version token ("v=sp" "f1 -all") has the same effect. Moving the space into the first string ("v=spf1 " "-all") makes both versions accept it.
Expected behavior
All three representations concatenate to v=spf1 -all and should be accepted. RFC 7208 section 3.3 requires the character-strings within one TXT record to be concatenated without inserting spaces.
This concerns multiple strings in a single TXT resource record, not multiple SPF records.
Reproduction
Tested against the upstream 6.0.2 and 6.0.3 tags using the same Python 3.14.7 and dnspython 2.8.0 environment.
Run this script with each version. It mocks DNS responses at _resolve_with_failover, using real dnspython TXT records; the actual TXT decoding, SPF record selection, and recursive parsing are exercised. It needs no live DNS records.
from unittest.mock import patch
import dns.rrset
from checkdmarc import _constants, spf, utils
print(f"checkdmarc {_constants.__version__}")
for target_txt in (
'"v=spf1" " -all"',
'"v=sp" "f1 -all"',
'"v=spf1 " "-all"', # Control: space is in the first string.
):
for root_spf in (
"v=spf1 include:_spf.example.net -all",
"v=spf1 redirect=_spf.example.net",
):
utils.DNS_CACHE.clear()
def resolve(domain, record_type, **kwargs):
if record_type != "TXT":
return []
txt = target_txt if domain == "_spf.example.net" else f'"{root_spf}"'
return dns.rrset.from_text(domain, 60, "IN", "TXT", txt)
with patch("checkdmarc.utils._resolve_with_failover", side_effect=resolve):
result = spf.check_spf("example.com")
print(f"{target_txt} | {root_spf} | valid={result['valid']}")
if not result["valid"]:
print(f" error: {result.get('error')}")
Actual results
Each of the three target TXT encodings is tested through both include: and redirect=, giving six cases per version:
| Lookup path |
Target TXT strings |
6.0.2 |
6.0.3 |
include: |
"v=spf1" " -all" |
valid=True |
valid=False |
redirect= |
"v=spf1" " -all" |
valid=True |
valid=False |
include: |
"v=sp" "f1 -all" |
valid=True |
valid=False |
redirect= |
"v=sp" "f1 -all" |
valid=True |
valid=False |
include: |
"v=spf1 " "-all" |
valid=True |
valid=True |
redirect= |
"v=spf1 " "-all" |
valid=True |
valid=True |
6.0.2 accepts all six cases. 6.0.3 rejects the first four and accepts the two control cases. For the failing redirect cases, the error is:
An SPF record does not exist.
For the failing include cases, the error reports that the target has no SPF record and the include produces a permanent error.
Likely cause
In 6.0.3, query_spf_record() always requests quoted_txt_segments=True. query_dns() therefore represents the first example internally as "v=spf1"" -all".
The selection code strips only the surrounding quotes, leaving v=spf1"" -all. _is_spf_record() then checks for exactly v=spf1 or a v=spf1 prefix, so this valid record is discarded before the later concatenation step.
For reference: 6.0.3 SPF record selection and version recognition.
Could record selection operate on the concatenated TXT content while retaining the original segments for the per-string size checks?
Scope
The new regression demonstrated here affects include/redirect targets. Top-level check_spf() already rejects these boundary cases in 6.0.2, so I am not reporting that behavior as newly introduced in 6.0.3. The new split-record tests use a first segment ending in v=spf1 and do not cover these two boundaries.
While comparing 6.0.2 and 6.0.3, I found a regression in SPF include and redirect lookups following the TXT string-boundary change in #282 (related to #281).
A target published as one TXT record containing
"v=spf1" " -all"is accepted in 6.0.2 but reported as missing in 6.0.3. Splitting inside the version token ("v=sp" "f1 -all") has the same effect. Moving the space into the first string ("v=spf1 " "-all") makes both versions accept it.Expected behavior
All three representations concatenate to
v=spf1 -alland should be accepted. RFC 7208 section 3.3 requires the character-strings within one TXT record to be concatenated without inserting spaces.This concerns multiple strings in a single TXT resource record, not multiple SPF records.
Reproduction
Tested against the upstream 6.0.2 and 6.0.3 tags using the same Python 3.14.7 and dnspython 2.8.0 environment.
Run this script with each version. It mocks DNS responses at
_resolve_with_failover, using real dnspython TXT records; the actual TXT decoding, SPF record selection, and recursive parsing are exercised. It needs no live DNS records.Actual results
Each of the three target TXT encodings is tested through both
include:andredirect=, giving six cases per version:include:"v=spf1" " -all"valid=Truevalid=Falseredirect="v=spf1" " -all"valid=Truevalid=Falseinclude:"v=sp" "f1 -all"valid=Truevalid=Falseredirect="v=sp" "f1 -all"valid=Truevalid=Falseinclude:"v=spf1 " "-all"valid=Truevalid=Trueredirect="v=spf1 " "-all"valid=Truevalid=True6.0.2 accepts all six cases. 6.0.3 rejects the first four and accepts the two control cases. For the failing redirect cases, the error is:
For the failing include cases, the error reports that the target has no SPF record and the include produces a permanent error.
Likely cause
In 6.0.3,
query_spf_record()always requestsquoted_txt_segments=True.query_dns()therefore represents the first example internally as"v=spf1"" -all".The selection code strips only the surrounding quotes, leaving
v=spf1"" -all._is_spf_record()then checks for exactlyv=spf1or av=spf1prefix, so this valid record is discarded before the later concatenation step.For reference: 6.0.3 SPF record selection and version recognition.
Could record selection operate on the concatenated TXT content while retaining the original segments for the per-string size checks?
Scope
The new regression demonstrated here affects include/redirect targets. Top-level
check_spf()already rejects these boundary cases in 6.0.2, so I am not reporting that behavior as newly introduced in 6.0.3. The new split-record tests use a first segment ending inv=spf1and do not cover these two boundaries.