Skip to content

6.0.3 regression: SPF include/redirect targets rejected when TXT strings split at the version prefix #286

Description

@milkyjoe90

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions