From 2ced6fd80e19a2fc12303f2cbc840a7a33ca3d74 Mon Sep 17 00:00:00 2001 From: Dan Mahoney Date: Mon, 22 Jun 2026 02:11:12 -0400 Subject: [PATCH] feat: add RFC 9989 t= tag support and a config knob to ignore pct= Adds parsing and a fetch accessor for the "t" (test mode) tag RFC 9989 introduces as the replacement for most of "pct"'s former role (Appendix A.6). t=y steps the enforced policy down one level (reject -> quarantine, quarantine -> none) per S 3.2.7, rather than suppressing enforcement outright the way pct=0 does. The step-down is computed into a new local, enforce_policy, instead of overwriting policy: policy is read again later to decide whether to generate a failure report, and RFC 9989 is explicit that t= "does not affect the generation of DMARC reports." Overwriting it in place would have silently changed failure-report eligibility for stepped-down messages. pct= sampling (random() % 100 < pct, gating RejectFailures and HoldQuarantinedMessages) is left completely unchanged by default: real published DMARC records actively use pct=0 and pct=100 today (e.g. Shopify, Oracle, Akamai, Salesforce, several .edu domains, per a local Umbrella top-1M survey), and dropping support for that would start enforcing on traffic those domains do not yet want enforced. A new boolean, DMARCbisIgnorePct (default false), ignores pct= entirely, equivalent to treating every record as pct=100, for sites that want full RFC 9989 compliance now. Out of scope here: wiring t=/np= into the historyfile, DB schema, and opendmarc-reports' XML output for RFC 9990 aggregate reporting -- that is a separate, larger task. --- libopendmarc/dmarc.h.in | 4 +++ libopendmarc/opendmarc_internal.h | 1 + libopendmarc/opendmarc_policy.c | 25 +++++++++++++++++ libopendmarc/tests/test_dmarc_fetch.c | 39 ++++++++++++++++++++++++++- libopendmarc/tests/test_dmarc_parse.c | 3 +++ opendmarc/opendmarc.c | 34 ++++++++++++++++++++--- opendmarc/opendmarc.conf.5.in | 30 +++++++++++++++++++++ opendmarc/opendmarc.conf.sample | 17 ++++++++++++ 8 files changed, 149 insertions(+), 4 deletions(-) diff --git a/libopendmarc/dmarc.h.in b/libopendmarc/dmarc.h.in index 803758ec..ee686cda 100644 --- a/libopendmarc/dmarc.h.in +++ b/libopendmarc/dmarc.h.in @@ -55,6 +55,9 @@ extern "C" { #define DMARC_RECORD_FO_1 (0x2) /* fo, a bitmap */ #define DMARC_RECORD_FO_D (0x4) /* fo, a bitmap */ #define DMARC_RECORD_FO_S (0x8) /* fo, a bitmap */ +#define DMARC_RECORD_T_UNSPECIFIED ('\0') /* t, RFC 9989 test mode; absent behaves as "n" */ +#define DMARC_RECORD_T_N ('n') /* t */ +#define DMARC_RECORD_T_Y ('y') /* t */ #define DMARC_PARSE_OKAY (0) /* Okay to continue */ #define DMARC_PARSE_ERROR_EMPTY (1) /* Nothing to parse */ @@ -161,6 +164,7 @@ OPENDMARC_STATUS_T opendmarc_policy_fetch_adkim(DMARC_POLICY_T *pctx, int *adkim OPENDMARC_STATUS_T opendmarc_policy_fetch_aspf(DMARC_POLICY_T *pctx, int *aspf); OPENDMARC_STATUS_T opendmarc_policy_fetch_p(DMARC_POLICY_T *pctx, int *p); OPENDMARC_STATUS_T opendmarc_policy_fetch_sp(DMARC_POLICY_T *pctx, int *sp); +OPENDMARC_STATUS_T opendmarc_policy_fetch_t(DMARC_POLICY_T *pctx, int *t); OPENDMARC_STATUS_T opendmarc_policy_fetch_fo(DMARC_POLICY_T *pctx, int *fo); u_char ** opendmarc_policy_fetch_rua(DMARC_POLICY_T *pctx, u_char *list_buf, size_t size_of_buf, int constant); u_char ** opendmarc_policy_fetch_ruf(DMARC_POLICY_T *pctx, u_char *list_buf, size_t size_of_buf, int constant); diff --git a/libopendmarc/opendmarc_internal.h b/libopendmarc/opendmarc_internal.h index 2f12c663..3e8f771b 100644 --- a/libopendmarc/opendmarc_internal.h +++ b/libopendmarc/opendmarc_internal.h @@ -171,6 +171,7 @@ typedef struct dmarc_policy_t { int sp; int np; /* RFC 9989: non-existent subdomain policy */ int psd; /* RFC 9989: DMARC_RECORD_PSD_* */ + int t; /* RFC 9989: DMARC_RECORD_T_*, test mode */ int pct; int rf; uint32_t ri; diff --git a/libopendmarc/opendmarc_policy.c b/libopendmarc/opendmarc_policy.c index c7a6d456..b743c760 100644 --- a/libopendmarc/opendmarc_policy.c +++ b/libopendmarc/opendmarc_policy.c @@ -1440,6 +1440,16 @@ opendmarc_policy_parse_dmarc(DMARC_POLICY_T *pctx, u_char *domain, u_char *recor else return DMARC_PARSE_ERROR_BAD_VALUE; } + else if (strcasecmp((char *)cp, "t") == 0) + { + /* RFC 9989: test mode, replaces most of pct='s former role */ + if (strncasecmp((char *)vp, "y", 1) == 0) + pctx->t = DMARC_RECORD_T_Y; + else if (strncasecmp((char *)vp, "n", 1) == 0) + pctx->t = DMARC_RECORD_T_N; + else + return DMARC_PARSE_ERROR_BAD_VALUE; + } else if (strcasecmp((char *)cp, "adkim") == 0) { /* @@ -1807,6 +1817,21 @@ opendmarc_policy_fetch_sp(DMARC_POLICY_T *pctx, int *sp) return DMARC_PARSE_OKAY; } +OPENDMARC_STATUS_T +opendmarc_policy_fetch_t(DMARC_POLICY_T *pctx, int *t) +{ + if (pctx == NULL) + { + return DMARC_PARSE_ERROR_NULL_CTX; + } + if (t == NULL) + { + return DMARC_PARSE_ERROR_EMPTY; + } + *t = pctx->t; + return DMARC_PARSE_OKAY; +} + u_char ** opendmarc_policy_fetch_rua(DMARC_POLICY_T *pctx, u_char *list_buf, size_t size_of_buf, int constant) { diff --git a/libopendmarc/tests/test_dmarc_fetch.c b/libopendmarc/tests/test_dmarc_fetch.c index 1e6c3c2c..fe3060f7 100644 --- a/libopendmarc/tests/test_dmarc_fetch.c +++ b/libopendmarc/tests/test_dmarc_fetch.c @@ -6,13 +6,14 @@ int main(int argc, char **argv) { - static char *record= "v=DMARC1; p=none; sp=none; adkim=s; aspf=s; pct=50; ri=300; rf=afrf; rua=mailto:dmarc-a@abuse.net; ruf=mailto:dmarc-f@abuse.net"; + static char *record= "v=DMARC1; p=none; sp=none; adkim=s; aspf=s; pct=50; t=y; ri=300; rf=afrf; rua=mailto:dmarc-a@abuse.net; ruf=mailto:dmarc-f@abuse.net"; DMARC_POLICY_T *pctx; OPENDMARC_STATUS_T status; int pass, fails, count; int pct; int adkim; int aspf; + int t; pass = fails = count = 0; pctx = opendmarc_policy_connect_init((u_char *)"1.2.3.4", 0); @@ -60,6 +61,17 @@ main(int argc, char **argv) printf("\t%s(%d): opendmarc_policy_fetch_adkim: expected %d got %d: FAIL\n", __FILE__, __LINE__, DMARC_RECORD_A_STRICT, aspf); fails += 1; } + status = opendmarc_policy_fetch_t(pctx, &t); + if (status != DMARC_PARSE_OKAY) + { + printf("\t%s(%d): opendmarc_policy_fetch_t: %s: FAIL\n", __FILE__, __LINE__, opendmarc_policy_status_to_str(status)); + fails += 1; + } + if (t != DMARC_RECORD_T_Y) + { + printf("\t%s(%d): opendmarc_policy_fetch_t: expected %d got %d: FAIL\n", __FILE__, __LINE__, DMARC_RECORD_T_Y, t); + fails += 1; + } /* ** Regression tests for issue #256: opendmarc_policy_fetch_ruf() and @@ -115,5 +127,30 @@ main(int argc, char **argv) } } + pctx = opendmarc_policy_connect_shutdown(pctx); + + /* RFC 9989: t= absent defaults to "n" behavior (DMARC_RECORD_T_UNSPECIFIED) */ + count++; + pctx = opendmarc_policy_connect_init((u_char *)"1.2.3.4", 0); + if (pctx == NULL) + { + (void) fprintf(stderr, "opendmarc_policy_connect_init: %s\n", strerror(errno)); + return 1; + } + status = opendmarc_policy_parse_dmarc(pctx, (u_char *)"abuse.net", + (u_char *)"v=DMARC1; p=reject;"); + if (status != DMARC_PARSE_OKAY) + { + printf("\t%s(%d): opendmarc_policy_parse_dmarc: %s: FAIL\n", __FILE__, __LINE__, opendmarc_policy_status_to_str(status)); + fails += 1; + } + status = opendmarc_policy_fetch_t(pctx, &t); + if (status != DMARC_PARSE_OKAY || t != DMARC_RECORD_T_UNSPECIFIED) + { + printf("\t%s(%d): opendmarc_policy_fetch_t: expected unspecified, got %d: FAIL\n", __FILE__, __LINE__, t); + fails += 1; + } + pctx = opendmarc_policy_connect_shutdown(pctx); + return fails; } diff --git a/libopendmarc/tests/test_dmarc_parse.c b/libopendmarc/tests/test_dmarc_parse.c index 94117c9d..425b498e 100644 --- a/libopendmarc/tests/test_dmarc_parse.c +++ b/libopendmarc/tests/test_dmarc_parse.c @@ -35,6 +35,9 @@ main(int argc, char **argv) /* 17 */ {"v=DMARC1; p=none; ruf=mailto://abuse.com", DMARC_PARSE_OKAY}, /* 18 */ {"v=DMARC1; p=none; ruf=mailto://abuse.com; foo=bar; buzz=happy;", DMARC_PARSE_OKAY}, /* 19 */ {"v=DMARC1; p=none; rf=000000000000000000000000000000000", DMARC_PARSE_ERROR_BAD_VALUE}, + /* 20 */ {"v=DMARC1; p=reject; t=y;", DMARC_PARSE_OKAY}, + /* 21 */ {"v=DMARC1; p=reject; t=n;", DMARC_PARSE_OKAY}, + /* 22 */ {"v=DMARC1; p=reject; t=bob;", DMARC_PARSE_ERROR_BAD_VALUE}, {NULL, 0}, }; int pass, fails, count; diff --git a/opendmarc/opendmarc.c b/opendmarc/opendmarc.c index 4a12a7b8..ebe0e021 100644 --- a/opendmarc/opendmarc.c +++ b/opendmarc/opendmarc.c @@ -182,6 +182,7 @@ struct dmarcf_config _Bool conf_ignoreauthclients; _Bool conf_holdquarantinedmessages; _Bool conf_reject_multi_from; + _Bool conf_ignorepct; unsigned int conf_refcnt; unsigned int conf_dnstimeout; struct config * conf_data; @@ -1283,6 +1284,10 @@ dmarcf_config_load(struct config *data, struct dmarcf_config *conf, &conf->conf_rejectfail, sizeof conf->conf_rejectfail); + (void) config_get(data, "DMARCbisIgnorePct", + &conf->conf_ignorepct, + sizeof conf->conf_ignorepct); + (void) config_get(data, "RejectString", &conf->conf_rejectstring, sizeof conf->conf_rejectstring); @@ -2270,6 +2275,7 @@ mlfi_eom(SMFICTX *ctx) int c; int pc; int policy; + int enforce_policy; int skiphistory = 0; int status; int adkim; @@ -2277,6 +2283,7 @@ mlfi_eom(SMFICTX *ctx) int pct; int p; int sp; + int t; int align_dkim; int align_spf; int limit_arc = 0; @@ -3316,6 +3323,8 @@ mlfi_eom(SMFICTX *ctx) opendmarc_policy_fetch_sp(cc->cctx_dmarc, &sp); dmarcf_dstring_printf(dfc->mctx_histbuf, "sp %d\n", sp); + opendmarc_policy_fetch_t(cc->cctx_dmarc, &t); + { int fo = DMARC_RECORD_FO_UNSPECIFIED; opendmarc_policy_fetch_fo(cc->cctx_dmarc, &fo); @@ -3349,12 +3358,31 @@ mlfi_eom(SMFICTX *ctx) /* ** Enact policy based on DMARC results. + ** + ** RFC 9989 S 3.2.7/Appendix A.6: t=y is a request that the policy + ** actually applied be one level below the published Domain Owner + ** Assessment Policy ("reject" -> "quarantine", "quarantine" -> "none"), + ** not a request to suppress enforcement outright. It has no effect + ** when the policy is already "none". This is kept in a separate + ** variable rather than overwriting "policy": t= "does not affect the + ** generation of DMARC reports" per RFC 9989, and "policy" is read + ** again below (failure report eligibility) where the unadjusted, + ** published policy must still apply. */ + enforce_policy = policy; + if (t == DMARC_RECORD_T_Y) + { + if (enforce_policy == DMARC_POLICY_REJECT) + enforce_policy = DMARC_POLICY_QUARANTINE; + else if (enforce_policy == DMARC_POLICY_QUARANTINE) + enforce_policy = DMARC_POLICY_NONE; + } + result = DMARC_RESULT_ACCEPT; ret = SMFIS_ACCEPT; - switch (policy) + switch (enforce_policy) { case DMARC_POLICY_ABSENT: /* No DMARC record found */ case DMARC_FROM_DOMAIN_ABSENT: /* No From: domain */ @@ -3374,7 +3402,7 @@ mlfi_eom(SMFICTX *ctx) ret = SMFIS_CONTINUE; if (conf->conf_rejectfail && - random() % 100 < pct) + (conf->conf_ignorepct || random() % 100 < pct)) { if (strstr(conf->conf_rejectstring, "%s") != NULL) snprintf((char *)replybuf, sizeof replybuf, @@ -3412,7 +3440,7 @@ mlfi_eom(SMFICTX *ctx) ret = SMFIS_CONTINUE; if (conf->conf_holdquarantinedmessages && - random() % 100 < pct) + (conf->conf_ignorepct || random() % 100 < pct)) { /* quarantine will be deferred until after the ARC policy eval */ diff --git a/opendmarc/opendmarc.conf.5.in b/opendmarc/opendmarc.conf.5.in index 6d799f72..28fd5c7d 100644 --- a/opendmarc/opendmarc.conf.5.in +++ b/opendmarc/opendmarc.conf.5.in @@ -197,6 +197,36 @@ is unchanged from before this setting existed. .IR Auto 's own PSL-then-RFC7489 fallback behaviour is unaffected by this setting. +.TP +.I DMARCbisIgnorePct (Boolean) +RFC 9989 removes the +.I pct +tag. By default (false), this filter keeps honoring it exactly as before: +a published +.I pct +value gates whether +.I RejectFailures +or +.I HoldQuarantinedMessages +actually act on a given message, via a per-message random draw against +.IR pct . +This default exists because +.I pct=0 +and +.I pct=100 +remain common in real published DMARC records (notably as a Domain +Owner's deliberate way to publish a stricter +.I p +value, such as during pre-production testing, without yet enforcing it). +Setting this to true ignores +.I pct +entirely, equivalent to treating every record as +.IR pct=100 , +for full RFC 9989 compliance. This does not affect the +.I t +tag, which is honored unconditionally per RFC 9989 regardless of 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 003f977b..3a9df799 100644 --- a/opendmarc/opendmarc.conf.sample +++ b/opendmarc/opendmarc.conf.sample @@ -182,6 +182,23 @@ # # DMARCbisWalkModeFallback RFC9989 +## DMARCbisIgnorePct { true | false } +## default false +## +## RFC 9989 removes the pct tag. By default, this filter keeps honoring +## it exactly as before: a published pct value gates whether +## RejectFailures or HoldQuarantinedMessages actually act on a given +## message, via a per-message random draw against pct. This default +## exists because pct=0 and pct=100 remain common in real published +## DMARC records (notably as a Domain Owner's deliberate way to publish a +## stricter p value, such as during pre-production testing, without yet +## enforcing it). Setting this to true ignores pct entirely, equivalent +## to treating every record as pct=100, for full RFC 9989 compliance. +## This does not affect the t tag, which is honored unconditionally per +## RFC 9989 regardless of this setting. +# +# DMARCbisIgnorePct false + ## DNSTimeout (integer) ## default 5 ##