diff --git a/.gitignore b/.gitignore index e92a79be..21f466da 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ HOURS tags .vscode libopendmarc/dmarc.h +compile_commands.json diff --git a/DMARCBIS-EDITOR-EMAIL.txt b/DMARCBIS-EDITOR-EMAIL.txt new file mode 100644 index 00000000..1c94b465 --- /dev/null +++ b/DMARCBIS-EDITOR-EMAIL.txt @@ -0,0 +1,120 @@ +Subject: RFC 9989 S4.10 DNS Tree Walk - a few questions from an implementer + +Todd, John, + +I've been implementing the S4.10 DNS Tree Walk in OpenDMARC (replacing +the old PSL-based lookup for the Organizational Domain) and hit a few +spots where the text doesn't quite pin down what to do. Figured it was +worth asking rather than guessing and shipping it. + +1. Walk exhaustion without psd= (S4.10.1) + +If the walk runs out of labels without ever finding a record with +psd=n or psd=y, but it passed one or more valid DMARC Policy Records +(no psd= tag) along the way, does that count as "the set produced by +the DNS Tree Walk contains no DMARC Policy Record" (S4.10.1), or do +those earlier records still count for something? Right now we're +treating exhaustion-without-psd= the same as no-record-found at all, +which seemed like the safe reading, but it's not stated either way as +far as I can tell. + +2. "might" in S4.10.2 + +S4.10.2 says alignment-evaluation tree walks "might" start at the +Author Domain, the SPF-Authenticated Identifier, or a DKIM-Authenticated +Identifier. Since "might" isn't 2119 language, is it actually optional +whether an implementation runs separate walks per identifier, or is +reusing the org domain from policy discovery for all of them not +really compliant? S5.3.4 points back at 4.10.2 normatively, so I want +to make sure we're not quietly skipping something that's meant to be +required. + +3. "one label below" plus the 8-label shortcut (S4.10 step 4, S4.10.2 step 2) + +S4.10.2 step 2 says when psd=y shows up somewhere other than the +walk's starting domain, the Organizational Domain is the domain one +label below it in the DNS hierarchy. But if the walk got there via the +8-label shortcut, it may never have actually queried that intermediate +label. We're reconstructing "one label below" from the original +starting domain rather than from whatever the walk's previous step +happened to be, since the previous step may not be that label at all +in shortcut cases. Does that match what you intended? + +4. What happens to the non-psd= records collected along the way (S4.10.2) + +The step 3 selection ("found at the name with the fewest number of +labels") only makes sense if the walk is accumulating every valid +record it sees, not just whatever it has when it stops. We're +accumulating all of them and running the psd=n / psd=y / fewest-labels +selection after the walk finishes. Wanted to confirm that's the model +rather than, say, only the stopping record being eligible. + +Happy to send over the relevant bit of our implementation (C, in +libopendmarc) if that's easier than describing it in prose. Thanks for +any clarification. + +Dan + +======================================================================== +Reply: John Levine , dmarc@ietf.org, 2026-06-20 +======================================================================== + +We tried hard to make the tree walk produce the same result as the old +PSL check in nearly every case, because we did not want to introduce +gratuitous incompatibilities, and because we know that most people are +not going to change the DMARC records they've already published. So +you know that most DMARC records will not need to change and if you're +usually getting different results from the old PSL approach, you're +reading the spec wrong. + +> 1. Walk exhaustion without psd= (S4.10.1) +> +> If the walk runs out of labels without ever finding a record with +> psd=n or psd=y, but it passed one or more valid DMARC Policy Records +> (no psd= tag) along the way, does that count as "the set produced by +> the DNS Tree Walk contains no DMARC Policy Record" (S4.10.1), or do +> those earlier records still count for something? + +A DMARC policy record is defined in section 3.2.6. In section 4.7 you +will find the description of the psd= flag, with the word OPTIONAL in +bold face capitals, and text telling you that the default value is "u". + +> Right now we're treating exhaustion-without-psd= the same as +> no-record-found at all, + +That is completely wrong. See above. Every record without an explicit +psd value has an implicit psd=u, if you really really want to find psd +values everywhere. + +> 2. "might" in S4.10.2 +> +> ... is it actually optional whether an implementation runs separate +> walks per identifier, + +No. When looking for an organizational domain, the tree walk starts at +one of the places it looked for but did not find a strictly aligned +record. That point might be the author domain, or it might be the SPF +domain, or it might be the DKIM domain. But it's definitely one of +those three. + +> 3. "one label below" plus the 8-label shortcut (S4.10 step 4, S4.10.2 +> step 2) +> +> ... if the walk got there via the 8-label shortcut, it may never have +> actually queried that intermediate label. + +That's OK. You do what it says. I would be very surprised if there were +any situations where this particular thing happened other than +contrived examples. + +> 4. What happens to the non-psd= records collected along the way +> (S4.10.2) +> +> The step 3 selection ... only makes sense if the walk is accumulating +> every valid record it sees, not just whatever it has when it stops. + +You're in luck. It's accumulating every valid record it sees, whether +or not they contain the optional psd= flag. + +R's, +John diff --git a/DMARCBIS-WALK-NOTES.txt b/DMARCBIS-WALK-NOTES.txt index db4ea8c4..234f70c0 100644 --- a/DMARCBIS-WALK-NOTES.txt +++ b/DMARCBIS-WALK-NOTES.txt @@ -3,7 +3,9 @@ Open questions and implementation notes for RFC 9989 DNS Tree Walk This file tracks uncertainties identified while reading RFC 9989 and designing the walk-mode refactor in libopendmarc. Each item references -the RFC section where the ambiguity was found. +the RFC section where the ambiguity was found. Several items below were +resolved by direct correspondence with an RFC 9989 co-author; see +DMARCBIS-EDITOR-EMAIL.txt for the full question/answer thread. ------------------------------------------------------------------------ 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 walk are applicable as org-domain records when the walk exhausts, but this is not stated explicitly. -Status: deferred. Current implementation treats walk exhaustion without -psd= as no-record-found. +Status: resolved. Per John Levine (RFC 9989 co-author), dmarc@ietf.org, +2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): psd= is OPTIONAL (S 4.7) and +defaults to psd=u; a record with no explicit psd= tag is a valid DMARC +Policy Record, not an absent one. The status note previously here, +describing the implementation as treating walk exhaustion without psd= +as no-record-found, was inaccurate: query_dmarc_rfc9989_walk() already +returns the last accumulated record (best_domain) when the walk exhausts +without an explicit psd=y/psd=n, and only reports no-record when zero +valid records were found at any level (found_any == 0). ------------------------------------------------------------------------ 2. "might" in secondary alignment walks (S 4.10.2) @@ -48,24 +57,60 @@ tree walks for each authenticated identifier to determine its organizational domain for relaxed alignment comparison. A strict reading permits using only the org domain found during policy discovery. -Status: secondary alignment walks not implemented in this pass. Relaxed -alignment continues to compare org domains using the single walk -performed during policy discovery. +Status: resolved. Per John Levine (RFC 9989 co-author), dmarc@ietf.org, +2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): the tree walk for Identifier +Alignment Evaluation starts at whichever of the Author Domain, the +SPF-Authenticated Identifier, or the DKIM-Authenticated Identifier failed +strict alignment -- it is definitely one of those three, not an optional +refinement. Reusing the single policy-discovery walk's org domain for +all three identifiers was confirmed non-compliant. + +Fixed: opendmarc_policy_check_alignment() now reduces the SPF/DKIM +identifier (the 'tld' argument) to its own organizational domain via +reduce_to_org_domain_for_alignment(), which dispatches on the configured +walk_mode/walk_mode_fallback exactly like policy discovery does (PSL and +AUTO keep the pre-existing direct opendmarc_get_tld() lookup; RFC7489 and +RFC9989 run the same DNS walk used for policy discovery, started at the +identifier instead of the Author Domain, via a throwaway DMARC_POLICY_T +so the caller's real policy context is untouched). Covered by the +secondary-alignment-walk cases in test_dmarc_walk.c. ------------------------------------------------------------------------ -3. Multiple DMARC records at one level (SS 4.10 steps 2 and 6) +3. Multiple DMARC records at one level (SS 4.10 steps 2 and 6; RFC 7489 + S 6.6.3 step 5) ------------------------------------------------------------------------ -The walk algorithm says: "if multiple DMARC Policy Records are returned -for a single target, they are all discarded." - -The current dmarc_dns_get_record() implementation returns only the first -DMARC TXT record found at a name; it cannot detect the presence of -additional records. Full compliance with this requirement would need a -DNS-layer change to count matching records before returning. - -Status: known gap. Current implementation returns the first record; -multi-record discard is not enforced. +Both RFC 7489 and RFC 9989 require that when multiple valid DMARC records +are found at a single DNS name, they are all discarded and policy +discovery terminates without applying DMARC. + +RFC 7489 S 6.6.3 step 5: "If the remaining set contains multiple records +or no records, policy discovery terminates and DMARC processing is not +applied to this message." + +RFC 9989 SS 4.10 steps 2 and 6: "If multiple DMARC Policy Records are +returned for a single target, they are all discarded." + +Status: fixed. dmarc_dns_get_record() scans all TXT records in the +answer (and the fake-DNS test table) before returning, counts those +containing "v=DMARC", and returns the record only when exactly one is +found; zero or more than one yields NO_DATA / DMARC_DNS_ERROR_NO_RECORD. +Covered by a fake-DNS test case (_dmarc.multi.example, two v=DMARC1 +entries) in test_subdomain_fallback.c. + +Note on wording: RFC 7489 step 5 says discovery "terminates" on multiple +records, while RFC 9989 steps 2/6 say the records are "discarded" and +(per step 7) the walk continues to the next label. This is not a +behavioral conflict requiring different handling: RFC 7489's algorithm +has only two levels (From domain, then Organizational Domain), so its +"terminate" is just "no more levels to try" -- the same outcome the 9989 +walk reaches when it runs out of labels. query_dmarc_rfc9989_walk() +treats any NULL return from dmarc_query_at() (whether caused by zero +records or by the multiple-records discard) identically: strip the +leftmost label and continue, since that "no usable record at this level, +try the next one" loop already existed for the plain-absent case. The +fix at the DNS layer therefore satisfies both RFCs' wording without any +walk-level changes. ------------------------------------------------------------------------ 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 DNS hierarchy" must therefore be reconstructed from the original starting domain, not inferred from the walk's previous step. -Status: tracked. Implementation computes "one label below" by finding -the child of the psd=y domain that is an ancestor of the original -starting domain. +Status: confirmed correct. Per John Levine (RFC 9989 co-author), +dmarc@ietf.org, 2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): reconstructing +"one label below" from the original starting domain is the intended +approach; divergence from the walk's literal previous step in +8-label-skip cases is expected to be rare to the point of contrived. +Implementation computes "one label below" by finding the child of the +psd=y domain that is an ancestor of the original starting domain. ------------------------------------------------------------------------ 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 records were collected. This implies the walk accumulates all valid single records encountered, stopping only when psd= is hit. -Status: walk implementation accumulates records. The selection process -from 4.10.2 is applied post-walk. Policy discovery (4.10.1) uses the -psd= stopping record when found; falls back to fewest-labels selection -when the walk exhausts without psd=. +Status: confirmed correct. Per John Levine (RFC 9989 co-author), +dmarc@ietf.org, 2026-06-20 (see DMARCBIS-EDITOR-EMAIL.txt): the walk +accumulates every valid record it sees regardless of an explicit psd= +tag, confirming the S 4.10.2 fewest-labels selection should run over all +accumulated records. Walk implementation accumulates records; the +selection process from 4.10.2 is applied post-walk. Policy discovery +(4.10.1) uses the psd= stopping record when found; falls back to +fewest-labels selection when the walk exhausts without psd=. ------------------------------------------------------------------------ 6. AUTO mode fallback scope when a PSL boundary is found but unqueryable @@ -126,3 +179,32 @@ identified and queried unsuccessfully. Status: intentional. AUTO favors finding a record over strict adherence to the PSL boundary when one was identified but had nothing there. + +------------------------------------------------------------------------ +7. Configurable default/fallback walk strategy (not an RFC ambiguity -- + an operational requirement raised independently of the items above) +------------------------------------------------------------------------ + +RFC 9989 deprecates PSL-based discovery as the recommended mechanism, but +RFC 9990's aggregate-report schema still defines policy_published's +discovery_method as exactly { psl, treewalk } -- meaning reports must be +able to truthfully declare PSL as the discovery method, so PSL has to +remain a live, selectable strategy rather than something to delete once +the tree walk works. Operators transitioning between the two also need +to choose, independently, which strategy is the default and which (if +any) is the fallback when the default finds nothing, rather than being +stuck with AUTO's fixed PSL-then-RFC7489 combinator. + +Status: implemented. walk_mode_fallback (OPENDMARC_LIB_T) / +DMARCbisWalkModeFallback (opendmarc.conf) lets any one of PSL, RFC7489, +or RFC9989 serve as either the primary (DMARCbisWalkMode) or fallback +strategy. Consulted by opendmarc_policy_query_dmarc() for policy +discovery and by opendmarc_policy_check_alignment() (via +reduce_to_org_domain_for_alignment(), item 2 above) for the secondary +alignment walk, so both code paths agree on strategy. AUTO's own +PSL-then-RFC7489 combinator (item 6 above) is unaffected: walk_mode_fallback +is only consulted when DMARCbisWalkMode names a concrete strategy, not +when it is Auto. Defaults to no fallback (OPENDMARC_WALK_MODE_NONE, +which aliases AUTO's zero value so a zero-initialized OPENDMARC_LIB_T -- +the common case -- never silently enables a fallback nobody configured). +Covered by test_dmarc_walk.c. diff --git a/libopendmarc/dmarc.h.in b/libopendmarc/dmarc.h.in index 11eae5ab..803758ec 100644 --- a/libopendmarc/dmarc.h.in +++ b/libopendmarc/dmarc.h.in @@ -100,6 +100,7 @@ typedef struct { int nscount; struct sockaddr_in nsaddr_list[MAXNS]; int walk_mode; /* OPENDMARC_WALK_MODE_* */ + int walk_mode_fallback; /* OPENDMARC_WALK_MODE_*, tried if walk_mode finds nothing */ } OPENDMARC_LIB_T; #define OPENDMARC_TLD_TYPE_NONE (0) /* Will not use a tld file */ @@ -112,6 +113,11 @@ typedef struct { #define OPENDMARC_WALK_MODE_RFC7489 (2) /* Label-strip walk, stops before TLD */ #define OPENDMARC_WALK_MODE_RFC9989 (3) /* RFC 9989 S 4.10 DNS Tree Walk */ +/* For walk_mode_fallback only: no fallback strategy configured. Aliases + * AUTO's zero value so a zero-initialized OPENDMARC_LIB_T (the common case) + * defaults to "no fallback" rather than silently enabling one. */ +#define OPENDMARC_WALK_MODE_NONE OPENDMARC_WALK_MODE_AUTO + /* psd= tag values (RFC 9989) */ #define DMARC_RECORD_PSD_UNSPECIFIED (0) /* psd= absent */ #define DMARC_RECORD_PSD_N (1) /* psd=n: organizational domain */ diff --git a/libopendmarc/opendmarc_policy.c b/libopendmarc/opendmarc_policy.c index d93e8595..c7a6d456 100644 --- a/libopendmarc/opendmarc_policy.c +++ b/libopendmarc/opendmarc_policy.c @@ -25,6 +25,14 @@ # include #endif /* USE_DMARCSTRL_H */ +/* Forward declarations for the walk strategies defined further below, + * needed by opendmarc_policy_check_alignment()'s S 4.10.2 secondary + * alignment walk. */ +static OPENDMARC_STATUS_T query_dmarc_rfc7489_walk(DMARC_POLICY_T *pctx, u_char *domain, + u_char *buf, size_t bufsz, int *dns_reply_out); +static OPENDMARC_STATUS_T query_dmarc_rfc9989_walk(DMARC_POLICY_T *pctx, u_char *domain, + u_char *buf, size_t bufsz, int *dns_reply_out); + /* ** CHECK_DOMAIN -- check for syntactical validity of a domain name ** @@ -269,6 +277,70 @@ opendmarc_policy_connect_shutdown(DMARC_POLICY_T *pctx) return pctx; } +/* + * reduce_to_org_domain_for_alignment: find the organizational domain of + * 'domain' (an SPF- or DKIM-authenticated identifier) per S 4.10.2's + * secondary alignment walk, using whichever strategy is given by + * walk_mode. PSL and AUTO are handled as a direct PSL lookup, as before + * this function existed -- a name-boundary lookup with no DNS query and + * no requirement that a record exist at the result. RFC7489 and RFC9989 + * run the same DNS walk used for policy discovery (S 4.10), started at + * 'domain' instead of the Author Domain, since for those strategies the + * walk itself -- not a static list -- is what defines the organizational + * domain. A throwaway policy context is used so this has no effect on + * any real DMARC_POLICY_T the caller may be holding. + * + * Returns 0 on success (outbuf filled), nonzero on failure. + */ +static int +reduce_to_org_domain_for_alignment(u_char *domain, int walk_mode, + u_char *outbuf, size_t outbufsz) +{ + switch (walk_mode) + { + case OPENDMARC_WALK_MODE_RFC7489: + case OPENDMARC_WALK_MODE_RFC9989: + { + DMARC_POLICY_T scratch; + u_char rectext[BUFSIZ]; + int dns_reply = 0; + OPENDMARC_STATUS_T status; + + (void) memset(&scratch, '\0', sizeof scratch); + status = (walk_mode == OPENDMARC_WALK_MODE_RFC9989) + ? query_dmarc_rfc9989_walk(&scratch, domain, rectext, sizeof rectext, &dns_reply) + : query_dmarc_rfc7489_walk(&scratch, domain, rectext, sizeof rectext, &dns_reply); + + if (status == DMARC_PARSE_OKAY && scratch.organizational_domain != NULL) + (void) strlcpy((char *)outbuf, (char *)scratch.organizational_domain, outbufsz); + + if (scratch.organizational_domain != NULL) + free(scratch.organizational_domain); + + return (status == DMARC_PARSE_OKAY) ? 0 : -1; + } + + case OPENDMARC_WALK_MODE_PSL: + case OPENDMARC_WALK_MODE_AUTO: + default: + { + int ret = opendmarc_get_tld(domain, outbuf, outbufsz); + + /* + * No PSL loaded (or no boundary found in it) leaves outbuf + * identical to domain; treat that as failure here too, the same + * way query_dmarc_psl() does for policy discovery, so the caller + * knows to try walk_mode_fallback instead of silently "succeeding" + * with no actual reduction. + */ + if (ret == 0 && (outbuf[0] == '\0' || strcasecmp((char *)outbuf, (char *)domain) == 0)) + return -1; + + return ret; + } + } +} + int opendmarc_policy_check_alignment(u_char *subdomain, u_char *tld, int mode) { @@ -322,7 +394,21 @@ opendmarc_policy_check_alignment(u_char *subdomain, u_char *tld, int mode) if (ret == 0 && mode == DMARC_RECORD_A_RELAXED) return 0; - ret = opendmarc_get_tld(tld, tld_buf, sizeof tld_buf); + { + int walk_mode = (Opendmarc_Libp != NULL) + ? Opendmarc_Libp->walk_mode : OPENDMARC_WALK_MODE_AUTO; + + ret = reduce_to_org_domain_for_alignment(tld, walk_mode, tld_buf, sizeof tld_buf); + if (ret != 0) + { + int walk_mode_fallback = (Opendmarc_Libp != NULL) + ? Opendmarc_Libp->walk_mode_fallback : OPENDMARC_WALK_MODE_NONE; + + if (walk_mode_fallback != OPENDMARC_WALK_MODE_NONE) + ret = reduce_to_org_domain_for_alignment(tld, walk_mode_fallback, + tld_buf, sizeof tld_buf); + } + } if (ret != 0) return -1; (void) memset(rev_tld, '\0', sizeof rev_tld); @@ -1011,6 +1097,33 @@ query_dmarc_rfc9989_walk(DMARC_POLICY_T *pctx, u_char *domain, return DMARC_PARSE_OKAY; } +/* + * run_walk_strategy: dispatch to one concrete org-domain strategy + * (OPENDMARC_WALK_MODE_PSL, _RFC7489, or _RFC9989). Used for both the + * primary walk_mode and, if configured, walk_mode_fallback; AUTO is not + * a valid argument here since it is a combinator of these three, not a + * strategy itself. + */ +static OPENDMARC_STATUS_T +run_walk_strategy(int mode, DMARC_POLICY_T *pctx, u_char *domain, + u_char *buf, size_t bufsz, int *dns_reply_out) +{ + switch (mode) + { + case OPENDMARC_WALK_MODE_PSL: + return query_dmarc_psl(pctx, domain, buf, bufsz, dns_reply_out); + + case OPENDMARC_WALK_MODE_RFC9989: + return query_dmarc_rfc9989_walk(pctx, domain, buf, bufsz, dns_reply_out); + + case OPENDMARC_WALK_MODE_RFC7489: + return query_dmarc_rfc7489_walk(pctx, domain, buf, bufsz, dns_reply_out); + + default: + return DMARC_DNS_ERROR_NO_RECORD; + } +} + /************************************************************************** ** OPENDMARC_POLICY_QUERY_DMARC -- Look up the _dmarc record for the ** specified domain. If not found @@ -1071,30 +1184,32 @@ opendmarc_policy_query_dmarc(DMARC_POLICY_T *pctx, u_char *domain) ? Opendmarc_Libp->walk_mode : OPENDMARC_WALK_MODE_AUTO; - switch (walk_mode) + if (walk_mode == OPENDMARC_WALK_MODE_AUTO) { - case OPENDMARC_WALK_MODE_PSL: - status = query_dmarc_psl(pctx, domain, buf, sizeof buf, &dns_reply); - break; - - case OPENDMARC_WALK_MODE_RFC9989: - status = query_dmarc_rfc9989_walk(pctx, domain, buf, sizeof buf, &dns_reply); - break; - - case OPENDMARC_WALK_MODE_RFC7489: - status = query_dmarc_rfc7489_walk(pctx, domain, buf, sizeof buf, &dns_reply); - break; - - case OPENDMARC_WALK_MODE_AUTO: - default: /* * AUTO: use PSL if a TLD file is loaded, otherwise fall back to - * the RFC 7489 label-strip walk. Preserves pre-refactor behaviour. + * the RFC 7489 label-strip walk. Preserves pre-refactor behaviour; + * walk_mode_fallback is not consulted here since AUTO is already + * a fixed two-strategy combinator. */ status = query_dmarc_psl(pctx, domain, buf, sizeof buf, &dns_reply); if (status != DMARC_PARSE_OKAY) status = query_dmarc_rfc7489_walk(pctx, domain, buf, sizeof buf, &dns_reply); - break; + } + else + { + status = run_walk_strategy(walk_mode, pctx, domain, buf, sizeof buf, &dns_reply); + + if (status != DMARC_PARSE_OKAY) + { + int walk_mode_fallback = (Opendmarc_Libp != NULL) + ? Opendmarc_Libp->walk_mode_fallback + : OPENDMARC_WALK_MODE_NONE; + + if (walk_mode_fallback != OPENDMARC_WALK_MODE_NONE) + status = run_walk_strategy(walk_mode_fallback, pctx, domain, + buf, sizeof buf, &dns_reply); + } } if (status != DMARC_PARSE_OKAY) diff --git a/libopendmarc/tests/.gitignore b/libopendmarc/tests/.gitignore index afa8f441..26626609 100644 --- a/libopendmarc/tests/.gitignore +++ b/libopendmarc/tests/.gitignore @@ -14,5 +14,7 @@ test_parse_to_buf test_arcares test_spf_parse test_subdomain_fallback +test_spf +test_dmarc_walk *.log *.trs diff --git a/libopendmarc/tests/Makefile.am b/libopendmarc/tests/Makefile.am index bb57e9c2..59f8a7a5 100644 --- a/libopendmarc/tests/Makefile.am +++ b/libopendmarc/tests/Makefile.am @@ -13,8 +13,10 @@ check_PROGRAMS = test_tld \ test_arcares \ test_subdomain_fallback if LIVE_TESTS -#check_PROGRAMS += test_dns_lookup -#test_dns_lookup_SOURCES = test_dns_lookup.c +check_PROGRAMS += test_dns_lookup +test_dns_lookup_SOURCES = test_dns_lookup.c +check_PROGRAMS += test_dmarc_walk +test_dmarc_walk_SOURCES = test_dmarc_walk.c if TEST_SPF check_PROGRAMS += test_spf test_spf_SOURCES = test_spf.c @@ -53,4 +55,6 @@ TESTS = $(check_PROGRAMS) EXTRA_DIST = testfiles/effective_tld_names.dat \ testfiles/good.com!example.com!1337270400!1337356799.xml \ - testfiles/bad.com!example.com!1337140800!1337227200.xml + testfiles/bad.com!example.com!1337140800!1337227200.xml \ + testfiles/gushi.org.zone.inc \ + testfiles/test-dns.zone.inc diff --git a/libopendmarc/tests/test_dmarc_walk.c b/libopendmarc/tests/test_dmarc_walk.c new file mode 100644 index 00000000..a6219205 --- /dev/null +++ b/libopendmarc/tests/test_dmarc_walk.c @@ -0,0 +1,448 @@ +/* +** test_dmarc_walk.c -- live DNS test for DMARCbis walk-mode selection +** +** Exercises opendmarc_policy_query_dmarc() under the OPENDMARC_WALK_MODE_* +** strategies against the real dmarcwalk.gushi.org records. See +** testfiles/test-dns.zone.inc (the "dmarcwalk" section) for those records +** and the reasoning behind each scenario below -- this file follows that +** documentation case by case. That file is a portable, domain-agnostic +** template; the live dmarcwalk.gushi.org zone is simply its "dmarcwalk" +** section deployed under gushi.org. +** +** This is a live-network test: it requires --enable-live-tests and a +** resolver that can actually reach gushi.org's public DNS, which is why +** it is gated the same way test_spf.c is. +** +** The PSL-loaded divergence case documented in the zone file (PSL/AUTO +** falling through to gushi.org's real, independently managed apex record) +** is intentionally not asserted here: its correct answer depends on +** production DNS data outside this fixture's control. Only the PSL +** no-record case (no PSL loaded at all) is checked, since that outcome +** is deterministic. +*/ + +#include "../opendmarc_internal.h" +#include "../dmarc.h" + +#define ZONE "dmarcwalk.gushi.org" + +#define CHECK(cond, msg) \ + do { \ + count++; \ + if (cond) { \ + pass++; \ + } else { \ + printf("\t%s(%d): %s: FAIL\n", __FILE__, __LINE__, msg); \ + fails++; \ + } \ + } while (0) + +static OPENDMARC_STATUS_T +set_mode(int mode) +{ + OPENDMARC_LIB_T lib; + + memset(&lib, '\0', sizeof lib); + lib.tld_type = OPENDMARC_TLD_TYPE_NONE; + lib.walk_mode = mode; + return opendmarc_policy_library_init(&lib); +} + +static OPENDMARC_STATUS_T +set_mode_with_fallback(int mode, int fallback_mode) +{ + OPENDMARC_LIB_T lib; + + memset(&lib, '\0', sizeof lib); + lib.tld_type = OPENDMARC_TLD_TYPE_NONE; + lib.walk_mode = mode; + lib.walk_mode_fallback = fallback_mode; + return opendmarc_policy_library_init(&lib); +} + +int +main(int argc, char **argv) +{ + int pass = 0, fails = 0, count = 0; + DMARC_POLICY_T *pctx; + OPENDMARC_STATUS_T status; + u_char utilized[256]; + int fallback; + + /* + * === Test 0: direct hit === + * direct.dmarcwalk.gushi.org has its own record; no walk should occur + * in any mode, so the mode chosen here (AUTO) is arbitrary. + */ + (void) set_mode(OPENDMARC_WALK_MODE_AUTO); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"direct." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "direct hit: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "direct." ZONE) == 0, + "direct hit: utilized domain should be the queried name itself"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 0, "direct hit: no fallback should have occurred"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 1-2: psd=n stops the walk at the level it is found === + * sub.psdn.dmarcwalk.gushi.org has no record of its own. + * psdn.dmarcwalk.gushi.org has psd=n. rfc9989 stops there on its + * first query (S4.10 step 2); rfc7489's one-level walk lands on the + * same name with no psd= concept at all. Same answer, different + * reasoning. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.psdn." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc9989 psd=n: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "psdn." ZONE) == 0, + "rfc9989 psd=n: org domain should be psdn.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_RFC7489); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.psdn." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc7489 psd=n name: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "psdn." ZONE) == 0, + "rfc7489 psd=n name: org domain should agree with rfc9989"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 1, "rfc7489 psd=n name: fallback flag should be set"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 3-4: no psd= tag, reached by two different mechanisms === + * sub.nopsd.dmarcwalk.gushi.org has no record. nopsd.dmarcwalk.gushi.org + * has p=quarantine with no psd= tag. rfc7489's one-level walk lands + * there directly. rfc9989 doesn't stop on a record with no psd=, so it + * keeps walking, hits the apex's psd=y, and backs off one label -- which + * resolves back to the same name. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.nopsd." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc9989 no psd=: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "nopsd." ZONE) == 0, + "rfc9989 no psd=: org domain should be nopsd.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_RFC7489); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.nopsd." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc7489 no psd= name: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "nopsd." ZONE) == 0, + "rfc7489 no psd= name: org domain should agree with rfc9989"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 1, "rfc7489 no psd= name: fallback flag should be set"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 5-6: psd=y -- the genuine, intentional divergence === + * leaf.mid.psdy.dmarcwalk.gushi.org has no record, nor does + * mid.psdy.dmarcwalk.gushi.org. psdy.dmarcwalk.gushi.org has psd=y. + * rfc9989 backs off one label below the psd=y record (S4.10.2 step 2), + * landing on mid.psdy.dmarcwalk.gushi.org. rfc7489 has no psd= concept + * and just stops on the first record it finds: psdy.dmarcwalk.gushi.org + * itself. These must NOT match -- that is the point of this fixture. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"leaf.mid.psdy." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc9989 psd=y: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "mid.psdy." ZONE) == 0, + "rfc9989 psd=y: org domain should back off to mid.psdy.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_RFC7489); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"leaf.mid.psdy." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc7489 psd=y name: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "psdy." ZONE) == 0, + "rfc7489 psd=y name: org domain should be psdy.dmarcwalk.gushi.org, diverging from rfc9989"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 1, "rfc7489 psd=y name: fallback flag should be set"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 7-8: >8 labels -- rfc9989's S4.10 step 4 shortening === + * d6.d5.d4.d3.d2.d1.eightlabel.dmarcwalk.gushi.org is 10 labels deep. + * d3.d2.d1.eightlabel.dmarcwalk.gushi.org has psd=n and sits exactly 7 + * labels from the root. Both modes land on the same org domain; + * rfc9989 gets there in its first query, rfc7489 takes three. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, + (u_char *)"d6.d5.d4.d3.d2.d1.eightlabel." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc9989 >8 labels: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "d3.d2.d1.eightlabel." ZONE) == 0, + "rfc9989 >8 labels: org domain should be d3.d2.d1.eightlabel.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_RFC7489); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, + (u_char *)"d6.d5.d4.d3.d2.d1.eightlabel." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc7489 >8 labels: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "d3.d2.d1.eightlabel." ZONE) == 0, + "rfc7489 >8 labels: org domain should agree with rfc9989 despite more queries"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 9-10: multiple records at one name, then divergence === + * multirecord.dmarcwalk.gushi.org carries two v=DMARC1 TXT records, so + * the direct query there must be discarded (RFC 7489 S6.6.3 step 5 / + * RFC 9989 S4.10 steps 2 and 6). The walk then continues from there: + * rfc9989 reaches the apex's psd=y and backs off one label, landing + * back on multirecord.dmarcwalk.gushi.org -- the very name whose direct + * record was just discarded, which is correct: the discard applies to + * that one query, not to the name's eligibility as an org domain found + * via a different path. rfc7489 has no psd= concept and takes the + * apex's record as-is, so its org domain is dmarcwalk.gushi.org itself + * -- a genuine divergence from rfc9989. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"multirecord." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc9989 multirecord: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "multirecord." ZONE) == 0, + "rfc9989 multirecord: org domain should be multirecord.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_RFC7489); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"multirecord." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "rfc7489 multirecord: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, ZONE) == 0, + "rfc7489 multirecord: org domain should be dmarcwalk.gushi.org, diverging from rfc9989"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 1, "rfc7489 multirecord: fallback flag should be set"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 11-12: PSL mode and AUTO with no PSL loaded === + * Without a PSL file, opendmarc_get_tld() returns the queried domain + * unchanged, so PSL mode can never identify an org domain and must + * fail outright. AUTO tries PSL first, then falls back to the same + * rfc7489 walk used above. + */ + (void) set_mode(OPENDMARC_WALK_MODE_PSL); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.nopsd." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_DNS_ERROR_NO_RECORD, + "psl with no PSL loaded: should return DMARC_DNS_ERROR_NO_RECORD"); + + pctx = opendmarc_policy_connect_shutdown(pctx); + + (void) set_mode(OPENDMARC_WALK_MODE_AUTO); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.nopsd." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "auto with no PSL loaded: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "nopsd." ZONE) == 0, + "auto with no PSL loaded: org domain should match the rfc7489 walk"); + + (void) opendmarc_policy_fetch_org_domain_from_fallback(pctx, &fallback); + CHECK(fallback == 1, "auto with no PSL loaded: fallback flag should be set"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Test 13: walk_mode_fallback === + * Same query as test 11 (PSL, no PSL loaded, so PSL alone fails), but + * with RFC9989 configured as walk_mode_fallback. Unlike AUTO, whose + * PSL-then-RFC7489 combinator is fixed, this lets PSL fail over to + * whichever strategy is configured -- here, RFC9989 -- and should land + * on the same nopsd.dmarcwalk.gushi.org answer as test 3. + */ + (void) set_mode_with_fallback(OPENDMARC_WALK_MODE_PSL, OPENDMARC_WALK_MODE_RFC9989); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + if (pctx == NULL) { fprintf(stderr, "connect_init failed\n"); return 1; } + + (void) opendmarc_policy_store_from_domain(pctx, (u_char *)"sub.nopsd." ZONE); + status = opendmarc_policy_query_dmarc(pctx, NULL); + + CHECK(status == DMARC_PARSE_OKAY, + "psl falling back to rfc9989: query should return DMARC_PARSE_OKAY"); + if (status == DMARC_PARSE_OKAY) + { + (void) memset(utilized, '\0', sizeof utilized); + (void) opendmarc_policy_fetch_utilized_domain(pctx, utilized, sizeof utilized); + CHECK(strcasecmp((char *)utilized, "nopsd." ZONE) == 0, + "psl falling back to rfc9989: org domain should be nopsd.dmarcwalk.gushi.org"); + } + + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* + * === Tests 14-16: secondary alignment walk (S 4.10.2) === + * opendmarc_policy_check_alignment() takes no pctx, so these call it + * directly rather than through the connect/store/query path above. + * + * "othersub.nopsd." ZONE and "sub.nopsd." ZONE are siblings: neither is + * a literal suffix of the other, so the cheap substring checks that run + * before any organizational-domain reduction cannot match them. The + * only way they align is if "sub.nopsd." ZONE gets reduced to + * "nopsd." ZONE -- which is also "othersub." ZONE's organizational + * domain -- via the configured walk strategy. + */ + (void) set_mode(OPENDMARC_WALK_MODE_RFC9989); + CHECK(opendmarc_policy_check_alignment((u_char *)"othersub.nopsd." ZONE, + (u_char *)"sub.nopsd." ZONE, + DMARC_RECORD_A_RELAXED) == 0, + "rfc9989 secondary alignment walk: siblings under nopsd." ZONE " should align"); + + (void) set_mode(OPENDMARC_WALK_MODE_PSL); + CHECK(opendmarc_policy_check_alignment((u_char *)"othersub.nopsd." ZONE, + (u_char *)"sub.nopsd." ZONE, + DMARC_RECORD_A_RELAXED) != 0, + "psl with no PSL loaded: siblings should not align, no boundary available"); + + (void) set_mode_with_fallback(OPENDMARC_WALK_MODE_PSL, OPENDMARC_WALK_MODE_RFC9989); + CHECK(opendmarc_policy_check_alignment((u_char *)"othersub.nopsd." ZONE, + (u_char *)"sub.nopsd." ZONE, + DMARC_RECORD_A_RELAXED) == 0, + "psl falling back to rfc9989: secondary alignment walk should align via the fallback"); + + printf("DMARC walk-mode live test (%s): pass=%d, fail=%d\n", ZONE, pass, fails); + return fails; +} diff --git a/libopendmarc/tests/test_dns_lookup.c b/libopendmarc/tests/test_dns_lookup.c index 8addfdae..cd402236 100644 --- a/libopendmarc/tests/test_dns_lookup.c +++ b/libopendmarc/tests/test_dns_lookup.c @@ -1,3 +1,31 @@ +/* +** test_dns_lookup.c -- live DNS tests for the low-level DMARC/xdomain lookups +** +** This file was disabled (commented out of check_PROGRAMS) in 2015 because +** every case here depended on third-party domains (bcx.com, linkedin.com, +** facebook.com, web.de, csh.rit.edu, zalando-lounge.de) that nobody on this +** project controls, so the test broke whenever one of those domains' DNS +** changed. It has been rewritten against domains Dan Mahoney controls +** (gushi.org, and the dmarcwalk.gushi.org fixture zone) plus the +** RFC 2606 ".invalid" TLD, which is reserved to never resolve, for the +** "this absolutely does not exist" cases. +** +** Notes on specific fixtures used below: +** - gushi.org has a real "v=DMARC1" record at its apex, and a wildcard +** "*._report._dmarc.gushi.org" record that authorizes ANY domain to +** send it cross-domain DMARC reports -- that wildcard is what makes +** the xdomain-authorization-exists case below work without needing a +** second, independently controlled domain. +** - defaultsite.gushi.org is a real host (has an A record) with no +** _dmarc record of its own, standing in for "existing domain, no +** DMARC record" the way web.de/mail.bcx.com used to. +** - sub.nopsd.dmarcwalk.gushi.org has no record of its own, but its +** parent nopsd.dmarcwalk.gushi.org does; see the "dmarcwalk" section +** of testfiles/test-dns.zone.inc for the full fixture layout (the +** gushi.org-specific apex records used elsewhere in this file are in +** testfiles/gushi.org.zone.inc instead). +*/ + #include "../opendmarc_internal.h" #include "../dmarc.h" @@ -8,19 +36,18 @@ typedef struct { int cpnotnull; int replyzero; char * what; -} DL; +} DL; int dmarc_dns_test_record(void) { DL domain_list[] = { - {"_dmarc.bcx.com", TRUE, TRUE, "DMARC record found"}, - {"bcx.org._report._dmarc.bcx.com", TRUE, TRUE, "DMARC _report record found"}, - {"_dmarc.mail.bcx.com", FALSE, FALSE, "Existing domain, no DMARC"}, - {"*._report._dmarc.bcx.com", TRUE, TRUE, "DMARC record found"}, - {"_dmarc.none.bcx.com", FALSE, FALSE, "No such domain"}, - {"web.de", FALSE, FALSE, "Existing domain, no DMARC"}, - /* {"_dmarc.sf1.i.bcx.com", TRUE, TRUE, "Got DMARC record via CNAME"}, */ + {"_dmarc.gushi.org", TRUE, TRUE, "DMARC record found"}, + {"dmarcwalk.gushi.org._report._dmarc.gushi.org", TRUE, TRUE, "DMARC _report record found"}, + {"_dmarc.defaultsite.gushi.org", FALSE, FALSE, "Existing domain, no DMARC"}, + {"*._report._dmarc.gushi.org", TRUE, TRUE, "DMARC record found"}, + {"_dmarc.nosuchhost.gushi.org", FALSE, FALSE, "No such domain"}, + {"gushi.org", FALSE, FALSE, "Existing domain, no DMARC"}, {NULL, 0, 0, NULL}, }; DL * dp; @@ -80,19 +107,17 @@ typedef struct { char * domain; int use_tld_list; int status; -} DL2; +} DL2; int dmarc_dns_test_query(void) { DL2 domain_list[] = { - {"linkedin.com",FALSE, 0}, - {"mail.bcx.com",FALSE, DMARC_DNS_ERROR_NO_RECORD}, - {"none.bcx.com",FALSE, DMARC_DNS_ERROR_NO_RECORD}, - {"web.de", FALSE, DMARC_DNS_ERROR_NO_RECORD}, - {"service3.zalando-lounge.de", TRUE, DMARC_PARSE_OKAY}, - {"service3.zalando-lounge.de", FALSE, DMARC_PARSE_OKAY}, - {"service3.zalando-lounge.de", TRUE, DMARC_PARSE_OKAY}, + {"gushi.org", FALSE, 0}, + {"sub.nopsd.dmarcwalk.gushi.org", FALSE, DMARC_PARSE_OKAY}, + {"none.test.invalid", FALSE, DMARC_DNS_ERROR_NO_RECORD}, + {"sub.nopsd.dmarcwalk.gushi.org", TRUE, DMARC_PARSE_OKAY}, + {"none.test.invalid", TRUE, DMARC_DNS_ERROR_NO_RECORD}, {NULL, 0}, }; DL2 * dp; @@ -103,10 +128,10 @@ dmarc_dns_test_query(void) success = failures = 0; for (dp = domain_list; dp->domain != NULL; ++dp) { - pctx = opendmarc_policy_connect_init("0.0.0.0", FALSE); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); if (dp->use_tld_list) (void) opendmarc_tld_read_file(TESTFILE, "//", "*.", "!"); - status = opendmarc_policy_query_dmarc(pctx, dp->domain); + status = opendmarc_policy_query_dmarc(pctx, (u_char *)dp->domain); pctx = opendmarc_policy_connect_shutdown(pctx); if (status != dp->status) { @@ -131,14 +156,10 @@ int dmarc_dns_test_xdomain_query(void) { DL3 domain_list[] = { - {"facebookmail.com", "d@ruf.agari.com", DMARC_PARSE_OKAY}, - {"facebookmail.com", "postmater@facebook.com", DMARC_DNS_ERROR_NO_RECORD}, - {"facebook.com", "postmater@facebook.com", DMARC_PARSE_OKAY}, - {"csh.rit.edu", "postmaster@csh.rit.edu", DMARC_PARSE_OKAY}, - {"csh.rit.edu", "postmaster@mail.csh.rit.edu", DMARC_PARSE_OKAY}, - {"linkedin.com", "worr@csh.rit.edu", DMARC_DNS_ERROR_NO_RECORD}, - {"none.bcx.com", "worr@csh.rit.edu", DMARC_DNS_ERROR_NO_RECORD}, - {"none.fnnfansavasdfjashfasfsdf.csadf", "worr@csh.rit.edu", DMARC_DNS_ERROR_NO_RECORD}, + {"gushi.org", "postmaster@gushi.org", DMARC_PARSE_OKAY}, + {"dmarcwalk.gushi.org", "postmaster@gushi.org", DMARC_PARSE_OKAY}, + {"gushi.org", "postmaster@dmarcwalk.gushi.org", DMARC_DNS_ERROR_NO_RECORD}, + {"gushi.org", "worr@nosuchdomain.test.invalid", DMARC_DNS_ERROR_NO_RECORD}, {NULL, NULL, 0}, }; @@ -147,12 +168,23 @@ dmarc_dns_test_xdomain_query(void) DMARC_POLICY_T *pctx; OPENDMARC_STATUS_T status; + /* + * dmarc_dns_test_query() above may have loaded the real PSL for its + * use_tld_list==TRUE cases. opendmarc_get_tld() has no notion of the + * dmarcwalk.gushi.org delegation, so with a PSL loaded it would + * collapse both gushi.org and dmarcwalk.gushi.org to the same + * registrable domain "gushi.org" and short-circuit the cases below + * that depend on them being treated as different organizations. + * Force a clean, no-PSL starting state. + */ + opendmarc_tld_shutdown(); + successes = failures = 0; for (dp = domain_list; dp->domain != NULL; ++dp) { - pctx = opendmarc_policy_connect_init("0.0.0.0", FALSE); - pctx->from_domain = strdup(dp->domain); - status = opendmarc_policy_query_dmarc_xdomain(pctx, dp->uri); + pctx = opendmarc_policy_connect_init((u_char *)"0.0.0.0", FALSE); + pctx->from_domain = (u_char *)strdup(dp->domain); + status = opendmarc_policy_query_dmarc_xdomain(pctx, (u_char *)dp->uri); pctx = opendmarc_policy_connect_shutdown(pctx); if (status != dp->status) diff --git a/libopendmarc/tests/test_spf.c b/libopendmarc/tests/test_spf.c index a02f9af7..5481edc3 100644 --- a/libopendmarc/tests/test_spf.c +++ b/libopendmarc/tests/test_spf.c @@ -16,11 +16,11 @@ int opendmarc_spf2_run_test() { SPF2_T tests[] = { - /* {"gushi.org", "root@gushi.org", "149.20.68.145", DMARC_POLICY_SPF_OUTCOME_PASS}, */ - {"agari.com", "root@agari.com", "2001:a60:901e::22", DMARC_POLICY_SPF_OUTCOME_FAIL}, - {"agari.com", "root@agari.com", "1.2.3.4", DMARC_POLICY_SPF_OUTCOME_FAIL}, - {"agari.com", "root@agari.com", "185.28.196.1", DMARC_POLICY_SPF_OUTCOME_PASS}, - /* {"gushi.org", "<>", "204.14.152.227", DMARC_POLICY_SPF_OUTCOME_FAIL}, */ + {"gushi.org", "root@gushi.org", "149.20.68.145", DMARC_POLICY_SPF_OUTCOME_PASS}, /* matches "a" (gushi.org's own A record) */ + {"gushi.org", "root@gushi.org", "2620:137:6000:10::142", DMARC_POLICY_SPF_OUTCOME_PASS}, /* matches explicit ip6: mechanism */ + {"gushi.org", "root@gushi.org", "149.20.68.142", DMARC_POLICY_SPF_OUTCOME_PASS}, /* matches "mx" and "a:prime.gushi.org" */ + {"gushi.org", "root@gushi.org", "1.2.3.4", DMARC_POLICY_SPF_OUTCOME_FAIL}, /* no match, falls through to ~all */ + {"gushi.org", "<>", "203.0.113.1", DMARC_POLICY_SPF_OUTCOME_FAIL}, /* null envelope sender, falls back to HELO; no match */ {NULL, NULL, NULL, 0} }; int status; diff --git a/libopendmarc/tests/testfiles/gushi.org.zone.inc b/libopendmarc/tests/testfiles/gushi.org.zone.inc new file mode 100644 index 00000000..35b146a4 --- /dev/null +++ b/libopendmarc/tests/testfiles/gushi.org.zone.inc @@ -0,0 +1,87 @@ +; gushi.org.zone.inc -- what's actually live on gushi.org today +; =========================================================================== +; +; This documents gushi.org's REAL, operational records that +; libopendmarc/tests/test_spf.c and parts of test_dns_lookup.c currently +; test against, for as long as those tests are wired to gushi.org +; directly. It is NOT the portable fixture -- see +; testfiles/test-dns.zone.inc for that (a domain-agnostic template with no +; real values, meant to be stood up under any domain once the live tests +; gain a configurable test-domain switch; see issue #432 in +; trusteddomainproject/OpenDMARC). +; +; gushi.org's dmarcwalk.gushi.org subtree (the DMARCbis walk-mode fixture) +; isn't repeated here either: it's purely synthetic data with no +; production meaning, so the live zone is simply +; testfiles/test-dns.zone.inc's "dmarcwalk" section, deployed under +; gushi.org instead of a placeholder domain. Nothing gushi.org-specific +; to document there. +; +; rua=/ruf= reporting addresses are intentionally omitted below -- they +; aren't part of what the tests check, and a real mailbox address has no +; place in this file regardless. +; +; This is NOT a standalone zone -- gushi.org carries plenty of content +; unrelated to OpenDMARC (mail, web, etc.). This is a paste-in/$INCLUDE +; fragment, not a full zone file: the subset of records --enable-live-tests +; actually depends on. If gushi.org's zone is ever rebuilt from scratch +; (new registrar, new DNS provider, a fat-fingered zone transfer), this is +; the checklist of what needs to come back. +; +; Tested by: +; libopendmarc/tests/test_spf.c (opendmarc_spf2_run_test) +; libopendmarc/tests/test_dns_lookup.c (dmarc_dns_test_record, +; dmarc_dns_test_query, +; dmarc_dns_test_xdomain_query) +; +$ORIGIN gushi.org. + +; --- apex DMARC record --------------------------------------------------- +; Required by: dmarc_dns_test_record() ["_dmarc.gushi.org" found], +; dmarc_dns_test_query() ["gushi.org" own-record case, and +; the PSL-loaded case, since opendmarc_get_tld() collapses +; dmarcwalk.gushi.org down to gushi.org and re-queries here], +; dmarc_dns_test_xdomain_query() [same-org shortcut, and as +; the destination domain for the cross-domain case]. +; Only "v=DMARC1" needs to be present and parse. gushi.org's real record +; also carries rua=/ruf= reporting addresses not shown here; their exact +; content is not part of the test contract. +_dmarc IN TXT "v=DMARC1; p=none; adkim=r; aspf=r; rf=afrf; sp=reject;" + +; --- cross-domain report-authorization wildcard -------------------------- +; Required by: dmarc_dns_test_record() ["DMARC _report record found" via +; "dmarcwalk.gushi.org._report._dmarc.gushi.org", and the +; literal "*._report._dmarc.gushi.org" wildcard-name case], +; dmarc_dns_test_xdomain_query() [authorizes the +; dmarcwalk.gushi.org -> gushi.org cross-domain report case]. +; A literal "*" wildcard so ANY reporting domain is authorized to send +; gushi.org cross-domain DMARC reports. +*._report._dmarc IN TXT "v=DMARC1" + +; --- SPF record ----------------------------------------------------------- +; Required by: opendmarc_spf2_run_test(). All five PASS/FAIL cases there +; depend on the exact mechanisms below: own "a", explicit "ip6:", "mx" +; (-> prime), "a:prime.gushi.org", and the trailing "~all" softfail +; catch-all that the no-match cases rely on (DMARC_POLICY_SPF_OUTCOME_FAIL, +; since the test calls opendmarc_spf2_test() with softfail_okay_flag=FALSE). +@ IN TXT "v=spf1 a mx ip6:2620:137:6000:10::142 a:prime.gushi.org a:quark.gushi.org ~all" + +; --- hosts the SPF record and tests reference ----------------------------- +@ IN A 149.20.68.145 +@ IN MX 10 prime +prime IN A 149.20.68.142 +quark IN A 149.20.68.143 + +; --- existing host with no DMARC record of its own ------------------------ +; Required by: dmarc_dns_test_record() ["Existing domain, no DMARC" case]. +; defaultsite must keep resolving (A record below) but must NOT gain a +; _dmarc.defaultsite.gushi.org TXT record -- that absence is the point of +; the test case. (Also referenced by test_spf.c's "ptr:defaultsite.gushi.org" +; case in the non-libspf2 build path.) +defaultsite IN A 149.20.68.145 + +; --- structural requirement: no wildcard A/AAAA under gushi.org ---------- +; dmarc_dns_test_record()'s "_dmarc.nosuchhost.gushi.org" case and the +; PSL-divergence case in dmarc_dns_test_query() both depend on arbitrary, +; unregistered subdomains of gushi.org returning NXDOMAIN. A catch-all +; wildcard A/AAAA record at "*.gushi.org" would silently break both. diff --git a/libopendmarc/tests/testfiles/test-dns.zone.inc b/libopendmarc/tests/testfiles/test-dns.zone.inc new file mode 100644 index 00000000..f8b4974d --- /dev/null +++ b/libopendmarc/tests/testfiles/test-dns.zone.inc @@ -0,0 +1,191 @@ +; test-dns.zone.inc -- portable, domain-agnostic fixture for OpenDMARC's +; live test suite (--enable-live-tests) +; =========================================================================== +; +; This file has no hardcoded domain name anywhere in it. Drop it into your +; own zone with $INCLUDE, having already set your own $ORIGIN beforehand -- +; every name below either inherits that origin directly or builds on it +; with a relative, non-terminal $ORIGIN switch (see "$ORIGIN dmarcwalk" +; below). Point whatever test domain you chose at it via the matching +; #define (see issue #432 in trusteddomainproject/OpenDMARC -- that wiring +; doesn't exist yet, this file is the template waiting for it). +; +; Every value here is a placeholder: fake hostnames (mailserver01, +; mailserver02, webhost), RFC 1918 (10.0.0.0/8) and RFC 3849 documentation +; (2001:db8::/32) addresses that nobody delivers mail to or otherwise +; depends on. Nothing here needs to resolve to anything real; the tests +; only check the SPF/DMARC string-matching logic, not actual connectivity. +; This is deliberately the disaster-recovery copy: if whoever maintains +; the live fixture domain today is unavailable, anyone can stand this zone +; up fresh under a domain of their own and the test suite keeps working. +; +; No $ORIGIN directive is needed for the records immediately below -- +; they inherit whatever origin was active in your zone file at the +; $INCLUDE point. +; +; This file is written as an $INCLUDE fragment, not a standalone zone -- +; it has no SOA and no NS, so it's meant to be pulled into a zone you've +; already set up. If you'd rather use it as a complete, self-sufficient +; zone on its own, uncomment the SOA below (the "@" sigils mean it needs +; no real hostname either, just whatever domain you deploy this under) +; and add your own NS records -- that part can't be templated, since DNS +; requires actually-working, populated nameservers for the zone to +; resolve at all (most registrars require at least two). +; +; @ IN SOA @ hostmaster ( 1 3600 900 604800 3600 ) +; @ IN NS ns1.example.com. ; <- replace with your real nameservers +; @ IN NS ns2.example.com. +; +; --- apex DMARC record --------------------------------------------------- +; Mirrors what dmarc_dns_test_record()/dmarc_dns_test_query()/ +; dmarc_dns_test_xdomain_query() in test_dns_lookup.c need at the bare +; domain: a "v=DMARC1" record that's present and parses. No rua=/ruf= +; needed -- those addresses aren't part of what the tests check, and +; deliberately aren't included here for both portability and privacy +; reasons (don't put a real mailbox address in a public template). +_dmarc IN TXT "v=DMARC1; p=none;" + +; --- cross-domain report-authorization wildcard -------------------------- +; Authorizes ANY reporting domain to send this domain cross-domain DMARC +; reports, so dmarc_dns_test_xdomain_query()'s authorization-exists case +; doesn't need a second, independently controlled domain. +*._report._dmarc IN TXT "v=DMARC1" + +; --- SPF record ----------------------------------------------------------- +; Exercises the same mechanism types opendmarc_spf2_run_test() checks: +; own "a", explicit "ip6:", "mx" (-> mailserver01), "a:mailserver02", and a +; trailing "~all" softfail catch-all for the no-match cases. +@ IN TXT "v=spf1 a mx ip6:2001:db8::1 a:mailserver01 a:mailserver02 ~all" + +; --- hosts the SPF record references -------------------------------------- +@ IN A 10.0.0.1 +@ IN MX 10 mailserver01 +mailserver01 IN A 10.0.0.2 +mailserver02 IN A 10.0.0.3 + +; --- existing host with no DMARC record of its own ------------------------ +; webhost must keep resolving (A record below) but must NOT gain a +; _dmarc.webhost TXT record -- that absence is the "existing domain, no +; DMARC" test case. +webhost IN A 10.0.0.4 + +; --- structural requirement: no wildcard A/AAAA at this level ------------- +; The "definitely no such name" test cases, and the PSL-divergence case, +; both depend on arbitrary, unregistered subdomains here returning +; NXDOMAIN. A catch-all wildcard A/AAAA record would silently break both. + +; ===================================================================== +; dmarcwalk subtree -- DMARCbis walk-mode fixture +; ===================================================================== +; +; Exercises opendmarc_policy_query_dmarc() under all four +; OPENDMARC_WALK_MODE_* strategies (auto, psl, rfc7489, rfc9989). Each +; subtree below is a self-contained scenario; the apex carries an explicit +; psd=y record so that no query in this section ever needs to walk out +; above it -- every walk started anywhere under this subtree terminates +; inside it. +; +; $ORIGIN below is relative (no trailing dot), so it builds on whatever +; origin was already active above -- no domain name hardcoded here either. +; +; Test cases (paths below are relative to wherever "dmarcwalk" ends up +; resolving; substitute your own base domain when reading these): +; +; direct.dmarcwalk... +; Has its own _dmarc record. No walk should occur in any mode; +; org domain == the queried domain itself for auto/psl/rfc7489/rfc9989. +; +; sub.nopsd.dmarcwalk... (query this; it has no record itself) +; nopsd.dmarcwalk... has a record with no psd= tag (implicit psd=u). +; rfc7489: one-level label-strip walk lands here directly +; (org=nopsd.dmarcwalk..., fallback=yes). rfc9989: accumulates this +; record (no psd=, so it doesn't stop), continues upward, and stops +; at the apex's psd=y -- per S4.10.2 step 2 the org domain is "one +; label below" the psd=y domain, which resolves back to +; nopsd.dmarcwalk... -- same answer as rfc7489, reached by a +; different mechanism. psl: opendmarc_get_tld() has no notion of +; this subtree at all and will identify the parent zone's apex as +; the registrable domain, so psl (and auto, if a PSL is loaded) +; diverges to whatever that apex's record says -- this is the +; interesting case showing PSL-vs-walk divergence, not a bug in the +; fixture. +; +; sub.psdn.dmarcwalk... (query this; it has no record itself) +; psdn.dmarcwalk... has a record with psd=n. rfc9989 stops on the +; first query (S4.10 step 2: psd=n means this level IS the org +; domain) -- org=psdn.dmarcwalk.... rfc7489 lands on the same name +; via its own one-level walk, since it has no concept of psd= and +; just takes the first record it finds -- same answer, different +; reasoning, useful as a sanity check that the two mechanisms agree +; when there's only one plausible stopping point. +; +; leaf.mid.psdy.dmarcwalk... (query this; no record on this name or on +; mid.psdy.dmarcwalk...) +; psdy.dmarcwalk... has a record with psd=y. rfc9989: walk queries +; mid.psdy.dmarcwalk... (no record, accumulate nothing, continue), +; then psdy.dmarcwalk... (psd=y, stop) -- org domain is "one label +; below", i.e. mid.psdy.dmarcwalk.... rfc7489: walks the same two +; levels but, having no psd= concept, just returns the first record +; it actually finds -- psdy.dmarcwalk... itself, NOT +; mid.psdy.dmarcwalk.... This is a genuine, intentional divergence +; between the two modes; it is exactly the kind of case John Levine's +; RFC 9989 clarification (see DMARCBIS-EDITOR-EMAIL.txt) was about. +; +; d6.d5.d4.d3.d2.d1.eightlabel.dmarcwalk... (query this; 10 labels +; total, no record on this name) +; d3.d2.d1.eightlabel.dmarcwalk... has a record with psd=n, and is +; deliberately placed exactly 7 labels from the root of this +; 10-label name. Per RFC 9989 S4.10 step 4, a domain with more than +; 8 labels is shortened to 7 before the walk begins, so rfc9989's +; FIRST query should be exactly this name -- org domain found in a +; single query (nqueries==1 if you're instrumenting it), proving the +; 8-label skip starts where the spec says, not at the literal +; immediate parent. rfc7489 has no such optimization: it walks one +; label at a time from the leaf (d5..., d4..., then +; d3.d2.d1.eightlabel...) and reaches the same record after three +; queries instead of one -- same final org domain, different query +; cost. +; +; multirecord.dmarcwalk... (query this directly; it carries two +; separate v=DMARC1 TXT records) +; Exercises the multiple-DMARC-records-at-one-name discard rule (RFC +; 7489 S6.6.3 step 5 / RFC 9989 S4.10 steps 2 and 6, fixed in commit +; a25754c). The direct query at multirecord.dmarcwalk... must come +; back as no-record (not pick either TXT record). The walk then +; continues from there: rfc9989 goes one level up to the apex, finds +; psd=y, and resolves org=multirecord.dmarcwalk... (one label below +; the apex -- the very name whose direct record was just discarded, +; which is correct: the discard applies to that one query, not to +; the name's eligibility as an org domain found via a different +; path). rfc7489's one-level walk also reaches the apex next and, +; having no psd= concept, takes its record as-is -- org domain ends +; up as dmarcwalk... itself, diverging from rfc9989's answer. +; +$ORIGIN dmarcwalk + +; Apex: psd=y containment boundary. Caps every walk in this section so +; none of them can ever escape upward past it. +_dmarc IN TXT "v=DMARC1; p=reject; psd=y;" + +; --- direct: own record, no walk should ever be needed --- +_dmarc.direct IN TXT "v=DMARC1; p=reject;" + +; --- nopsd: record with no psd= tag; sub.nopsd has no record of its own --- +_dmarc.nopsd IN TXT "v=DMARC1; p=quarantine;" + +; --- psdn: record with explicit psd=n; sub.psdn has no record of its own --- +_dmarc.psdn IN TXT "v=DMARC1; p=reject; psd=n;" + +; --- psdy: record with explicit psd=y; mid.psdy and leaf.mid.psdy have no +; records of their own --- +_dmarc.psdy IN TXT "v=DMARC1; p=none; psd=y;" + +; --- eightlabel: record placed exactly 7 labels deep, to land precisely on +; the RFC 9989 S4.10 step 4 shortened starting point for a 10-label query +; (d6.d5.d4.d3.d2.d1.eightlabel.dmarcwalk...) --- +_dmarc.d3.d2.d1.eightlabel IN TXT "v=DMARC1; p=reject; psd=n;" + +; --- multirecord: two valid v=DMARC1 records at the same name; the direct +; query at this name must be discarded per the multiple-records rule --- +_dmarc.multirecord IN TXT "v=DMARC1; p=reject;" +_dmarc.multirecord IN TXT "v=DMARC1; p=quarantine;" diff --git a/opendmarc/opendmarc-check.c b/opendmarc/opendmarc-check.c index 28a53401..45041fda 100644 --- a/opendmarc/opendmarc-check.c +++ b/opendmarc/opendmarc-check.c @@ -51,10 +51,12 @@ static void usage(void) { fprintf(stderr, - "%s: usage: %s [-p pslfile] [-m mode | -a] domain [domain ...]\n" + "%s: usage: %s [-p pslfile] [-m mode | -a] [-f fallback] domain [domain ...]\n" "\t-p pslfile path to a Public Suffix List file\n" "\t-m mode walk mode: auto (default), psl, rfc7489, rfc9989\n" - "\t-a query all four walk modes per domain and compare\n", + "\t-a query all four walk modes per domain and compare\n" + "\t-f fallback walk mode to try if -m's choice finds nothing:\n" + "\t none (default), psl, rfc7489, rfc9989\n", progname, progname); } @@ -81,6 +83,31 @@ parse_walk_mode(const char *name, int *modep) return TRUE; } +/* +** PARSE_WALK_MODE_FALLBACK -- translate a fallback mode name to an +** OPENDMARC_WALK_MODE_* value. Unlike parse_walk_mode(), accepts "none" +** and rejects "auto", matching what DMARCbisWalkModeFallback accepts. +** +** Returns TRUE on success, FALSE if the name is not recognized. +*/ + +static int +parse_walk_mode_fallback(const char *name, int *modep) +{ + if (strcasecmp(name, "none") == 0) + *modep = OPENDMARC_WALK_MODE_NONE; + else if (strcasecmp(name, "psl") == 0) + *modep = OPENDMARC_WALK_MODE_PSL; + else if (strcasecmp(name, "rfc7489") == 0) + *modep = OPENDMARC_WALK_MODE_RFC7489; + else if (strcasecmp(name, "rfc9989") == 0) + *modep = OPENDMARC_WALK_MODE_RFC9989; + else + return FALSE; + + return TRUE; +} + /* ** WALK_MODE_TO_STR -- name of an OPENDMARC_WALK_MODE_* value */ @@ -175,6 +202,7 @@ main(int argc, char **argv) int compare_all = FALSE; int mode_given = FALSE; int walk_mode = OPENDMARC_WALK_MODE_AUTO; + int walk_mode_fallback = OPENDMARC_WALK_MODE_NONE; char *pslfile = NULL; OPENDMARC_STATUS_T status; char *p; @@ -193,7 +221,7 @@ main(int argc, char **argv) progname = (p = strrchr(argv[0], '/')) == NULL ? argv[0] : p + 1; - while ((ch = getopt(argc, argv, "am:p:h")) != -1) + while ((ch = getopt(argc, argv, "af:m:p:h")) != -1) { switch (ch) { @@ -201,6 +229,17 @@ main(int argc, char **argv) compare_all = TRUE; break; + case 'f': + if (!parse_walk_mode_fallback(optarg, &walk_mode_fallback)) + { + fprintf(stderr, "%s: unknown fallback walk mode '%s'\n", + progname, optarg); + usage(); + + return EX_USAGE; + } + break; + case 'm': if (!parse_walk_mode(optarg, &walk_mode)) { @@ -253,6 +292,7 @@ main(int argc, char **argv) sizeof lib.tld_source_file); } lib.walk_mode = walk_mode; + lib.walk_mode_fallback = walk_mode_fallback; status = opendmarc_policy_library_init(&lib); if (status != DMARC_PARSE_OKAY) diff --git a/opendmarc/opendmarc.c b/opendmarc/opendmarc.c index 848bceb2..4a12a7b8 100644 --- a/opendmarc/opendmarc.c +++ b/opendmarc/opendmarc.c @@ -194,6 +194,7 @@ struct dmarcf_config char * conf_rejectstring; char * conf_pslist; char * conf_walkmode; + char * conf_walkmodefallback; char * conf_ignorelist; char ** conf_trustedauthservids; char ** conf_ignoredomains; @@ -1338,6 +1339,10 @@ dmarcf_config_load(struct config *data, struct dmarcf_config *conf, &conf->conf_walkmode, sizeof conf->conf_walkmode); + (void) config_get(data, "DMARCbisWalkModeFallback", + &conf->conf_walkmodefallback, + sizeof conf->conf_walkmodefallback); + if (!conf->conf_dolog) { (void) config_get(data, "Syslog", &conf->conf_dolog, @@ -5358,6 +5363,18 @@ main(int argc, char **argv) libopendmarc.walk_mode = OPENDMARC_WALK_MODE_AUTO; } + if (curconf->conf_walkmodefallback != NULL) + { + if (strcasecmp(curconf->conf_walkmodefallback, "PSL") == 0) + libopendmarc.walk_mode_fallback = OPENDMARC_WALK_MODE_PSL; + else if (strcasecmp(curconf->conf_walkmodefallback, "RFC7489") == 0) + libopendmarc.walk_mode_fallback = OPENDMARC_WALK_MODE_RFC7489; + else if (strcasecmp(curconf->conf_walkmodefallback, "RFC9989") == 0) + libopendmarc.walk_mode_fallback = OPENDMARC_WALK_MODE_RFC9989; + else + libopendmarc.walk_mode_fallback = OPENDMARC_WALK_MODE_NONE; + } + if (opendmarc_policy_library_init(&libopendmarc) != 0) { if (curconf->conf_dolog) diff --git a/opendmarc/opendmarc.conf.5.in b/opendmarc/opendmarc.conf.5.in index c2d2d613..6d799f72 100644 --- a/opendmarc/opendmarc.conf.5.in +++ b/opendmarc/opendmarc.conf.5.in @@ -147,7 +147,9 @@ the hashing algorithm. .TP .I DMARCbisWalkMode (string) Selects the strategy used to find the Organizational Domain when the author -domain has no DMARC record of its own. Accepted values are: +domain has no DMARC record of its own, and, per RFC 9989 section 4.10.2, the +Organizational Domain of an SPF- or DKIM-authenticated identifier during +relaxed alignment evaluation. Accepted values are: .RS .TP .B Auto @@ -158,7 +160,9 @@ This is the default and preserves pre-DMARCbis behaviour. .TP .B PSL Use only the Public Suffix List. No fallback walk is performed if the PSL -cannot identify a boundary. Requires +cannot identify a boundary, unless one is configured with +.IR DMARCbisWalkModeFallback . +Requires .I PublicSuffixList to be configured. .TP @@ -175,6 +179,24 @@ or is found. Applies an 8-query cap. Does not require a PSL. .RE +.TP +.I DMARCbisWalkModeFallback (string) +Selects a second strategy to try, using the same accepted values as +.I DMARCbisWalkMode +except +.BR Auto , +if the strategy chosen by +.I DMARCbisWalkMode +fails to identify an Organizational Domain. This lets any one of PSL, +RFC7489, or RFC9989 serve as either the primary or the fallback strategy, +which is useful while transitioning between PSL-based and DNS-Tree-Walk-based +discovery. The default is unset, meaning no fallback is attempted and the +behaviour of +.I DMARCbisWalkMode +is unchanged from before this setting existed. +.IR Auto 's +own PSL-then-RFC7489 fallback behaviour is unaffected by this setting. + .TP .I DNSTimeout (integer) Sets the DNS timeout in seconds. A value of 0 causes an infinite wait. diff --git a/opendmarc/opendmarc.conf.sample b/opendmarc/opendmarc.conf.sample index f3c8799b..003f977b 100644 --- a/opendmarc/opendmarc.conf.sample +++ b/opendmarc/opendmarc.conf.sample @@ -144,14 +144,17 @@ ## default (none, behaves as "Auto") ## ## Selects the strategy used to find the Organizational Domain when the -## author domain has no DMARC record of its own. Accepted values: +## author domain has no DMARC record of its own, and, per RFC 9989 section +## 4.10.2, the Organizational Domain of an SPF- or DKIM-authenticated +## identifier during relaxed alignment evaluation. Accepted values: ## ## Auto -- Use PSL if PublicSuffixList is configured; otherwise ## fall back to the RFC 7489 label-strip walk. This is ## the pre-DMARCbis default and preserves existing behaviour. ## ## PSL -- Use the Public Suffix List only. No fallback walk is -## performed if the PSL cannot identify a boundary. Requires +## performed if the PSL cannot identify a boundary, unless +## one is configured with DMARCbisWalkModeFallback. Requires ## PublicSuffixList to be configured. ## ## RFC7489 -- Label-strip walk per RFC 7489. Strips labels from the @@ -165,6 +168,20 @@ # # DMARCbisWalkMode Auto +## DMARCbisWalkModeFallback (string) +## default (none, no fallback) +## +## Selects a second strategy to try, using the same accepted values as +## DMARCbisWalkMode except Auto, if the strategy chosen by DMARCbisWalkMode +## fails to identify an Organizational Domain. This lets any one of PSL, +## RFC7489, or RFC9989 serve as either the primary or the fallback +## strategy, which is useful while transitioning between PSL-based and +## DNS-Tree-Walk-based discovery. Leaving this unset preserves the +## behaviour DMARCbisWalkMode had before this setting existed. Auto's own +## PSL-then-RFC7489 fallback behaviour is unaffected by this setting. +# +# DMARCbisWalkModeFallback RFC9989 + ## DNSTimeout (integer) ## default 5 ##