Skip to content

Commit d786f11

Browse files
authored
feat: add configurable walk-mode fallback and fix S 4.10.2 secondary alignment walk (#433)
opendmarc_policy_query_dmarc() and the new opendmarc_policy_check_alignment() dispatch both now consult a configurable walk_mode_fallback (DMARCbisWalkModeFallback in opendmarc.conf, -f in opendmarc-check) when the primary walk_mode strategy finds nothing. Any of PSL, RFC7489, or RFC9989 can serve as either the primary or fallback strategy; AUTO's existing PSL-then-RFC7489 combinator is unaffected. This matters because RFC 9989 deprecates PSL-based discovery but RFC 9990's discovery_method report field still has to be able to say "psl", so PSL has to stay a live, selectable strategy rather than something to delete. opendmarc_policy_check_alignment() also now performs the S 4.10.2 secondary alignment walk for the SPF/DKIM identifier (via the same walk_mode/fallback dispatch) instead of always reducing it through the PSL. Per direct correspondence with an RFC 9989 co-author (DMARCBIS-EDITOR-EMAIL.txt), reusing the single policy-discovery walk's org domain for every identifier, as before, was confirmed non-compliant rather than an optional reading of "might". DMARCBIS-WALK-NOTES.txt is reconciled against that correspondence: items 1, 4, and 5 are confirmed-correct/resolved with no code change, item 2 (above) is fixed, item 3 (multiple records at one level) was already fixed upstream, and a new item 7 documents the fallback feature. Also rewrites the live-DNS test suite (test_dns_lookup.c, test_spf.c, and new test_dmarc_walk.c) against gushi.org and its dmarcwalk.gushi.org fixture zone, replacing cases that depended on flaky third-party domains (bcx.com, facebook.com, agari.com, linkedin.com, csh.rit.edu, web.de, zalando-lounge.de) that nobody on this project controls.
1 parent 016c425 commit d786f11

16 files changed

Lines changed: 1270 additions & 86 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ HOURS
2424
tags
2525
.vscode
2626
libopendmarc/dmarc.h
27+
compile_commands.json

DMARCBIS-EDITOR-EMAIL.txt

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
Subject: RFC 9989 S4.10 DNS Tree Walk - a few questions from an implementer
2+
3+
Todd, John,
4+
5+
I've been implementing the S4.10 DNS Tree Walk in OpenDMARC (replacing
6+
the old PSL-based lookup for the Organizational Domain) and hit a few
7+
spots where the text doesn't quite pin down what to do. Figured it was
8+
worth asking rather than guessing and shipping it.
9+
10+
1. Walk exhaustion without psd= (S4.10.1)
11+
12+
If the walk runs out of labels without ever finding a record with
13+
psd=n or psd=y, but it passed one or more valid DMARC Policy Records
14+
(no psd= tag) along the way, does that count as "the set produced by
15+
the DNS Tree Walk contains no DMARC Policy Record" (S4.10.1), or do
16+
those earlier records still count for something? Right now we're
17+
treating exhaustion-without-psd= the same as no-record-found at all,
18+
which seemed like the safe reading, but it's not stated either way as
19+
far as I can tell.
20+
21+
2. "might" in S4.10.2
22+
23+
S4.10.2 says alignment-evaluation tree walks "might" start at the
24+
Author Domain, the SPF-Authenticated Identifier, or a DKIM-Authenticated
25+
Identifier. Since "might" isn't 2119 language, is it actually optional
26+
whether an implementation runs separate walks per identifier, or is
27+
reusing the org domain from policy discovery for all of them not
28+
really compliant? S5.3.4 points back at 4.10.2 normatively, so I want
29+
to make sure we're not quietly skipping something that's meant to be
30+
required.
31+
32+
3. "one label below" plus the 8-label shortcut (S4.10 step 4, S4.10.2 step 2)
33+
34+
S4.10.2 step 2 says when psd=y shows up somewhere other than the
35+
walk's starting domain, the Organizational Domain is the domain one
36+
label below it in the DNS hierarchy. But if the walk got there via the
37+
8-label shortcut, it may never have actually queried that intermediate
38+
label. We're reconstructing "one label below" from the original
39+
starting domain rather than from whatever the walk's previous step
40+
happened to be, since the previous step may not be that label at all
41+
in shortcut cases. Does that match what you intended?
42+
43+
4. What happens to the non-psd= records collected along the way (S4.10.2)
44+
45+
The step 3 selection ("found at the name with the fewest number of
46+
labels") only makes sense if the walk is accumulating every valid
47+
record it sees, not just whatever it has when it stops. We're
48+
accumulating all of them and running the psd=n / psd=y / fewest-labels
49+
selection after the walk finishes. Wanted to confirm that's the model
50+
rather than, say, only the stopping record being eligible.
51+
52+
Happy to send over the relevant bit of our implementation (C, in
53+
libopendmarc) if that's easier than describing it in prose. Thanks for
54+
any clarification.
55+
56+
Dan
57+
58+
========================================================================
59+
Reply: John Levine <johnl@ietf.email>, dmarc@ietf.org, 2026-06-20
60+
========================================================================
61+
62+
We tried hard to make the tree walk produce the same result as the old
63+
PSL check in nearly every case, because we did not want to introduce
64+
gratuitous incompatibilities, and because we know that most people are
65+
not going to change the DMARC records they've already published. So
66+
you know that most DMARC records will not need to change and if you're
67+
usually getting different results from the old PSL approach, you're
68+
reading the spec wrong.
69+
70+
> 1. Walk exhaustion without psd= (S4.10.1)
71+
>
72+
> If the walk runs out of labels without ever finding a record with
73+
> psd=n or psd=y, but it passed one or more valid DMARC Policy Records
74+
> (no psd= tag) along the way, does that count as "the set produced by
75+
> the DNS Tree Walk contains no DMARC Policy Record" (S4.10.1), or do
76+
> those earlier records still count for something?
77+
78+
A DMARC policy record is defined in section 3.2.6. In section 4.7 you
79+
will find the description of the psd= flag, with the word OPTIONAL in
80+
bold face capitals, and text telling you that the default value is "u".
81+
82+
> Right now we're treating exhaustion-without-psd= the same as
83+
> no-record-found at all,
84+
85+
That is completely wrong. See above. Every record without an explicit
86+
psd value has an implicit psd=u, if you really really want to find psd
87+
values everywhere.
88+
89+
> 2. "might" in S4.10.2
90+
>
91+
> ... is it actually optional whether an implementation runs separate
92+
> walks per identifier,
93+
94+
No. When looking for an organizational domain, the tree walk starts at
95+
one of the places it looked for but did not find a strictly aligned
96+
record. That point might be the author domain, or it might be the SPF
97+
domain, or it might be the DKIM domain. But it's definitely one of
98+
those three.
99+
100+
> 3. "one label below" plus the 8-label shortcut (S4.10 step 4, S4.10.2
101+
> step 2)
102+
>
103+
> ... if the walk got there via the 8-label shortcut, it may never have
104+
> actually queried that intermediate label.
105+
106+
That's OK. You do what it says. I would be very surprised if there were
107+
any situations where this particular thing happened other than
108+
contrived examples.
109+
110+
> 4. What happens to the non-psd= records collected along the way
111+
> (S4.10.2)
112+
>
113+
> The step 3 selection ... only makes sense if the walk is accumulating
114+
> every valid record it sees, not just whatever it has when it stops.
115+
116+
You're in luck. It's accumulating every valid record it sees, whether
117+
or not they contain the optional psd= flag.
118+
119+
R's,
120+
John

DMARCBIS-WALK-NOTES.txt

Lines changed: 106 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ Open questions and implementation notes for RFC 9989 DNS Tree Walk
33

44
This file tracks uncertainties identified while reading RFC 9989 and
55
designing the walk-mode refactor in libopendmarc. Each item references
6-
the RFC section where the ambiguity was found.
6+
the RFC section where the ambiguity was found. Several items below were
7+
resolved by direct correspondence with an RFC 9989 co-author; see
8+
DMARCBIS-EDITOR-EMAIL.txt for the full question/answer thread.
79

810
------------------------------------------------------------------------
911
1. Walk exhaustion without psd= (SS 4.10, 4.10.1)
@@ -27,8 +29,15 @@ This could be read to imply that records-without-psd= found during the
2729
walk are applicable as org-domain records when the walk exhausts, but
2830
this is not stated explicitly.
2931

30-
Status: deferred. Current implementation treats walk exhaustion without
31-
psd= as no-record-found.
32+
Status: resolved. Per John Levine (RFC 9989 co-author), dmarc@ietf.org,
33+
2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): psd= is OPTIONAL (S 4.7) and
34+
defaults to psd=u; a record with no explicit psd= tag is a valid DMARC
35+
Policy Record, not an absent one. The status note previously here,
36+
describing the implementation as treating walk exhaustion without psd=
37+
as no-record-found, was inaccurate: query_dmarc_rfc9989_walk() already
38+
returns the last accumulated record (best_domain) when the walk exhausts
39+
without an explicit psd=y/psd=n, and only reports no-record when zero
40+
valid records were found at any level (found_any == 0).
3241

3342
------------------------------------------------------------------------
3443
2. "might" in secondary alignment walks (S 4.10.2)
@@ -48,24 +57,60 @@ tree walks for each authenticated identifier to determine its
4857
organizational domain for relaxed alignment comparison. A strict reading
4958
permits using only the org domain found during policy discovery.
5059

51-
Status: secondary alignment walks not implemented in this pass. Relaxed
52-
alignment continues to compare org domains using the single walk
53-
performed during policy discovery.
60+
Status: resolved. Per John Levine (RFC 9989 co-author), dmarc@ietf.org,
61+
2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): the tree walk for Identifier
62+
Alignment Evaluation starts at whichever of the Author Domain, the
63+
SPF-Authenticated Identifier, or the DKIM-Authenticated Identifier failed
64+
strict alignment -- it is definitely one of those three, not an optional
65+
refinement. Reusing the single policy-discovery walk's org domain for
66+
all three identifiers was confirmed non-compliant.
67+
68+
Fixed: opendmarc_policy_check_alignment() now reduces the SPF/DKIM
69+
identifier (the 'tld' argument) to its own organizational domain via
70+
reduce_to_org_domain_for_alignment(), which dispatches on the configured
71+
walk_mode/walk_mode_fallback exactly like policy discovery does (PSL and
72+
AUTO keep the pre-existing direct opendmarc_get_tld() lookup; RFC7489 and
73+
RFC9989 run the same DNS walk used for policy discovery, started at the
74+
identifier instead of the Author Domain, via a throwaway DMARC_POLICY_T
75+
so the caller's real policy context is untouched). Covered by the
76+
secondary-alignment-walk cases in test_dmarc_walk.c.
5477

5578
------------------------------------------------------------------------
56-
3. Multiple DMARC records at one level (SS 4.10 steps 2 and 6)
79+
3. Multiple DMARC records at one level (SS 4.10 steps 2 and 6; RFC 7489
80+
S 6.6.3 step 5)
5781
------------------------------------------------------------------------
5882

59-
The walk algorithm says: "if multiple DMARC Policy Records are returned
60-
for a single target, they are all discarded."
61-
62-
The current dmarc_dns_get_record() implementation returns only the first
63-
DMARC TXT record found at a name; it cannot detect the presence of
64-
additional records. Full compliance with this requirement would need a
65-
DNS-layer change to count matching records before returning.
66-
67-
Status: known gap. Current implementation returns the first record;
68-
multi-record discard is not enforced.
83+
Both RFC 7489 and RFC 9989 require that when multiple valid DMARC records
84+
are found at a single DNS name, they are all discarded and policy
85+
discovery terminates without applying DMARC.
86+
87+
RFC 7489 S 6.6.3 step 5: "If the remaining set contains multiple records
88+
or no records, policy discovery terminates and DMARC processing is not
89+
applied to this message."
90+
91+
RFC 9989 SS 4.10 steps 2 and 6: "If multiple DMARC Policy Records are
92+
returned for a single target, they are all discarded."
93+
94+
Status: fixed. dmarc_dns_get_record() scans all TXT records in the
95+
answer (and the fake-DNS test table) before returning, counts those
96+
containing "v=DMARC", and returns the record only when exactly one is
97+
found; zero or more than one yields NO_DATA / DMARC_DNS_ERROR_NO_RECORD.
98+
Covered by a fake-DNS test case (_dmarc.multi.example, two v=DMARC1
99+
entries) in test_subdomain_fallback.c.
100+
101+
Note on wording: RFC 7489 step 5 says discovery "terminates" on multiple
102+
records, while RFC 9989 steps 2/6 say the records are "discarded" and
103+
(per step 7) the walk continues to the next label. This is not a
104+
behavioral conflict requiring different handling: RFC 7489's algorithm
105+
has only two levels (From domain, then Organizational Domain), so its
106+
"terminate" is just "no more levels to try" -- the same outcome the 9989
107+
walk reaches when it runs out of labels. query_dmarc_rfc9989_walk()
108+
treats any NULL return from dmarc_query_at() (whether caused by zero
109+
records or by the multiple-records discard) identically: strip the
110+
leftmost label and continue, since that "no usable record at this level,
111+
try the next one" loop already existed for the plain-absent case. The
112+
fix at the DNS layer therefore satisfies both RFCs' wording without any
113+
walk-level changes.
69114

70115
------------------------------------------------------------------------
71116
4. "One label below" psd=y with the 8-label skip (SS 4.10, 4.10.2)
@@ -81,9 +126,13 @@ the level immediately below the psd=y domain. "One label below in the
81126
DNS hierarchy" must therefore be reconstructed from the original starting
82127
domain, not inferred from the walk's previous step.
83128

84-
Status: tracked. Implementation computes "one label below" by finding
85-
the child of the psd=y domain that is an ancestor of the original
86-
starting domain.
129+
Status: confirmed correct. Per John Levine (RFC 9989 co-author),
130+
dmarc@ietf.org, 2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): reconstructing
131+
"one label below" from the original starting domain is the intended
132+
approach; divergence from the walk's literal previous step in
133+
8-label-skip cases is expected to be rare to the point of contrived.
134+
Implementation computes "one label below" by finding the child of the
135+
psd=y domain that is an ancestor of the original starting domain.
87136

88137
------------------------------------------------------------------------
89138
5. Fate of records-without-psd= collected during the walk (S 4.10.2)
@@ -104,10 +153,14 @@ the name with the fewest number of labels") only makes sense if multiple
104153
records were collected. This implies the walk accumulates all valid
105154
single records encountered, stopping only when psd= is hit.
106155

107-
Status: walk implementation accumulates records. The selection process
108-
from 4.10.2 is applied post-walk. Policy discovery (4.10.1) uses the
109-
psd= stopping record when found; falls back to fewest-labels selection
110-
when the walk exhausts without psd=.
156+
Status: confirmed correct. Per John Levine (RFC 9989 co-author),
157+
dmarc@ietf.org, 2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): the walk
158+
accumulates every valid record it sees regardless of an explicit psd=
159+
tag, confirming the S 4.10.2 fewest-labels selection should run over all
160+
accumulated records. Walk implementation accumulates records; the
161+
selection process from 4.10.2 is applied post-walk. Policy discovery
162+
(4.10.1) uses the psd= stopping record when found; falls back to
163+
fewest-labels selection when the walk exhausts without psd=.
111164

112165
------------------------------------------------------------------------
113166
6. AUTO mode fallback scope when a PSL boundary is found but unqueryable
@@ -126,3 +179,32 @@ identified and queried unsuccessfully.
126179

127180
Status: intentional. AUTO favors finding a record over strict adherence
128181
to the PSL boundary when one was identified but had nothing there.
182+
183+
------------------------------------------------------------------------
184+
7. Configurable default/fallback walk strategy (not an RFC ambiguity --
185+
an operational requirement raised independently of the items above)
186+
------------------------------------------------------------------------
187+
188+
RFC 9989 deprecates PSL-based discovery as the recommended mechanism, but
189+
RFC 9990's aggregate-report schema still defines policy_published's
190+
discovery_method as exactly { psl, treewalk } -- meaning reports must be
191+
able to truthfully declare PSL as the discovery method, so PSL has to
192+
remain a live, selectable strategy rather than something to delete once
193+
the tree walk works. Operators transitioning between the two also need
194+
to choose, independently, which strategy is the default and which (if
195+
any) is the fallback when the default finds nothing, rather than being
196+
stuck with AUTO's fixed PSL-then-RFC7489 combinator.
197+
198+
Status: implemented. walk_mode_fallback (OPENDMARC_LIB_T) /
199+
DMARCbisWalkModeFallback (opendmarc.conf) lets any one of PSL, RFC7489,
200+
or RFC9989 serve as either the primary (DMARCbisWalkMode) or fallback
201+
strategy. Consulted by opendmarc_policy_query_dmarc() for policy
202+
discovery and by opendmarc_policy_check_alignment() (via
203+
reduce_to_org_domain_for_alignment(), item 2 above) for the secondary
204+
alignment walk, so both code paths agree on strategy. AUTO's own
205+
PSL-then-RFC7489 combinator (item 6 above) is unaffected: walk_mode_fallback
206+
is only consulted when DMARCbisWalkMode names a concrete strategy, not
207+
when it is Auto. Defaults to no fallback (OPENDMARC_WALK_MODE_NONE,
208+
which aliases AUTO's zero value so a zero-initialized OPENDMARC_LIB_T --
209+
the common case -- never silently enables a fallback nobody configured).
210+
Covered by test_dmarc_walk.c.

libopendmarc/dmarc.h.in

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ typedef struct {
100100
int nscount;
101101
struct sockaddr_in nsaddr_list[MAXNS];
102102
int walk_mode; /* OPENDMARC_WALK_MODE_* */
103+
int walk_mode_fallback; /* OPENDMARC_WALK_MODE_*, tried if walk_mode finds nothing */
103104
} OPENDMARC_LIB_T;
104105

105106
#define OPENDMARC_TLD_TYPE_NONE (0) /* Will not use a tld file */
@@ -112,6 +113,11 @@ typedef struct {
112113
#define OPENDMARC_WALK_MODE_RFC7489 (2) /* Label-strip walk, stops before TLD */
113114
#define OPENDMARC_WALK_MODE_RFC9989 (3) /* RFC 9989 S 4.10 DNS Tree Walk */
114115

116+
/* For walk_mode_fallback only: no fallback strategy configured. Aliases
117+
* AUTO's zero value so a zero-initialized OPENDMARC_LIB_T (the common case)
118+
* defaults to "no fallback" rather than silently enabling one. */
119+
#define OPENDMARC_WALK_MODE_NONE OPENDMARC_WALK_MODE_AUTO
120+
115121
/* psd= tag values (RFC 9989) */
116122
#define DMARC_RECORD_PSD_UNSPECIFIED (0) /* psd= absent */
117123
#define DMARC_RECORD_PSD_N (1) /* psd=n: organizational domain */

0 commit comments

Comments
 (0)