Setting up the DMARC filter in Spamassassin

December 30, 2023 by Roberto Puzzanghera 13 comments

DMARC (Domain-based Message Authentication, Reporting and Conformance) is an email authentication protocol. It is designed to give email domain owners the ability to protect their domain from unauthorized use, commonly known as email spoofing. The purpose and primary outcome of implementing DMARC is to protect a domain from being used in business email compromise attacks, phishing emails, email scams and other cyber threat activities.

Changelog

  • Dec 30, 2023
    - now DMARC_REJECT is not hit if SPF_HELO_PASS is true

Configuration

You can use Spamassassin to apply a DMARC filter by means of the AskDNS plugin. Just add the following to your local.cf:

ifplugin Mail::SpamAssassin::Plugin::AskDNS
askdns __DMARC_POLICY_NONE   _dmarc._AUTHORDOMAIN_ TXT /^v=DMARC1;.*\bp=none;/
askdns __DMARC_POLICY_QUAR   _dmarc._AUTHORDOMAIN_ TXT /^v=DMARC1;.*\bp=quarantine;/
askdns __DMARC_POLICY_REJECT _dmarc._AUTHORDOMAIN_ TXT /^v=DMARC1;.*\bp=reject;/

meta DMARC_REJECT !(DKIM_VALID_AU || SPF_PASS || SPF_HELO_PASS) && __DMARC_POLICY_REJECT
score DMARC_REJECT 5
meta DMARC_QUAR   !(DKIM_VALID_AU || SPF_PASS || SPF_HELO_PASS) && __DMARC_POLICY_QUAR
score DMARC_QUAR 2.5
meta DMARC_NONE   !(DKIM_VALID_AU || SPF_PASS || SPF_HELO_PASS) && __DMARC_POLICY_NONE
score DMARC_NONE 0.1
endif # Mail::SpamAssassin::Plugin::AskDNS

This means that a DMARC reject (p=reject in the DNS record) will turn into a +5 spam score, DMARC quarantine (p=quarantine) into a +2.5 spam score and a p=none into a +0.1 spam score.

This is how you may want to set your own DMARC record into your bind zone:

_dmarc.yourdomain.tld. IN TXT "v=DMARC1;p=reject;sp=none;pct=100;rua=mailto:postmaster@yourdomain.tld"

Of course this requires that you already have both SPF and DKIM working as explained before.

If you decide to set a similar DNS record in your DMZ view, it is important that you have set your allowed localnets in spamassassin, for example:

internal_networks 10.0.0/24

otherwise you will probably ban your system or web application mail messages in case you don't sign them.

Comments

invalid regex, subdomain policy

Unfortunately, all this is not quite correct.

The regex above does not match:

v =     DMARC1  ;   p    = reject

which is a fine record according to RFC7489.

Also, you will miss separate subdomain policies:

_dmarc apple com. 3516 IN TXT "v=DMARC1; p=quarantine; sp=reject; ..."

Too bad, I liked the simplicity.

Reply |

It is not working with SpamAssassin 3.4.0

It is not working with SpamAssassin 3.4.0 (package in Centos 7 for example), because _AUTHORDOMAIN_ is empty. You have to edit source file /usr/share/perl5/vendor_perl/Mail/SpamAssassin/PerMsgStatus.pm ...

Reply |

It is not working with SpamAssassin 3.4.0

Thanks for the advice.

_AUTHORDOMAIN_ not found here on v. 3.4.4 file /usr/local/share/perl5/vendor_perl/Mail/SpamAssassin/PerMsgStatus.pm

Reply |

SPF Alignment

DMARC requires not just that SPF passes (i.e. the SMTP FROM is authenticated) but also that SMTP FROM aligns with the From header. After all, DMARC is about authentication of the From header. You should replace SPF_PASS with SPF_PASS  && !HEADER_FROM_DIFFERENT_DOMAINS.

Be aware that DMARC often breaks with forwarded email. I would not set the scores as high as yours.

Reply |

SPF Alignment

Thanks for the contribution.

Actually it's DKIM to break with forwarded email, but in my rule DMARC will pass if DKIM *or* SPF pass, so it will be sufficient that SPF passed to have DMARC passed as well.

Reply |

SPF Alignment

Actually, after checking the DMARC and SPF rfc's (rfc7489 section-4.1, rfc7208.html section-2.4): it is probably better to check for a null SMTP From in Spamassassin and then use SPF_HELO_PASS instead of SPF_PASS. DMARC defers to the SPF rfc in case of a null MAIL FROM (AKA SMTP From). The SPF rfc requires verifiers to check the HELO in case of a null sender. To check for a null SMTP From I think your MTA needs to inject a header (e.g. Return-Path), so Spamassassin can see it. Then you can use something like:

meta DMARC_PASS (DKIM_VALID_AU || SPF_PASS && !HEADER_FROM_DIFFERENT_DOMAINS || SPF_HELO_PASS && __BOUNCE_RPATH_NULL)

I think the DMARC RFC wants you to check alignment between the HELO and the From header in case of a null SMTP From. I wouldn't do that, since such alignment is uncommon (at least for autoreplies, maybe less so for bounces) and it will always fail with forwarded emails.

The above rule still causes DMARC fails for bounces from servers that haven't configured SPF for their HELO domain. And it will often break for forwarded emails. So I wouldn't assign it a high score. But it is somewhat closer to the DMARC specification, if that is what you're after.

Reply |

SPF Alignment

That's true in case of your version of the rule, since SPF_PASS checks the SMTP From which in case of forwarded mail is chosen by the forwarding server instead of the original sender. DMARC checks additionally that the SMTP From and the From header are aligned, which always fails for forwarded mail.

Another issue with using SPF_PASS is that it never hits when the SMTP From is null (<>), which is mandatory for autoreplies and bounces. So suppose you receive a forwarded mail (with altered headers such that DKIM breaks) with null SMTP From. Then even if the sender has set up DMARC, SPF and DKIM correctly your DMARC_X rule will hit. So maybe use !SPF_FAIL instead.

But in that case, without !HEADER_FROM_DIFFERENT_DOMAINS the rule will hardly get any hits, since nowadays almost no mail fails SPF, including spam. But with !HEADER_FROM_DIFFERENT_DOMAINS forwarding could fail both DKIM and SPF/alignment.

I don't think there is a good solution, at least not until everyone has implemented DKIM and forwarders stopped messing with headers. Hence, I would never use high scores with DKIM/DMARC rules.

Reply |

SPF Alignment

Thank you. I'll have something to read in the following days :-)

Reply |

Incorrect rejections

I implemented these rules a couple of weeks ago. I've been noticing some legitimate mail in my spam box, which led me to investigate.
According to the specifications of the relevant standards, it's allowed to implement SPF and DMARC, but not DKIM.

However, your example rule gives these types of emails a score of 10:

meta DMARC_REJECT !(DKIM_VALID_AU && SPF_PASS) && __DMARC_POLICY_REJECT
score DMARC_REJECT 10

To allow mails with only SPF and DMARC to be delivered, I think the expression should be like this instead:

meta DMARC_REJECT !((!DKIM_SIGNED || DKIM_VALID_AU) && SPF_PASS) && __DMARC_POLICY_REJECT

Reply |

Incorrect rejections

I had a look to RFC7489#section-6.6.2 and it actually suggests that the DMARC test should pass if <<"one or more" of the Authenticated Identifiers align with the From domain>> so I'm going to accept your observation.

Perhaps it's even more correct to leave things as originally suggested by Iulian

meta DMARC_REJECT !(DKIM_VALID_AU || SPF_PASS) && __DMARC_POLICY_REJECT

so that the emails will be accepted if at least one between DKIM and SPF pass.

Reply |

Incorrect rejections

I think your suggestion is correct. Let me check the rfc in detail before correcting the rule.

Your rule should also prevent that the email will be rejected when for any reason the dkim record was not retrieved. There is a discussion on Iulian's blog (the guy who suggested that rule) on the purpose...

Reply |

Invalid syntax

meta DMARC_REJECT !(DKIM_VALID_AU || SPF_PASS) && __DMARC_POLICY_REJECT

this check is invalid, this -OR- logic in the () reads:

if not (DKIM_VALID_AU -OR- SPF_PASS) AND theres a policy for the domain then reject which means an email with assuming a policy exists (1):

!DKIM_VALID_AU and !SPF_PASS == if !(0 || 0) && 1 == 1 && 1 == 1 == ACTION (GOOD)
DKIM_VALID_AU and !SPF_PASS == if !(1 || 0) && 1 == 0 && 1 == 0 == NO ACTION (BAD)
!DKIM_VALID_AU and SPF_PASS == if !(0 || 1) && 1 == 0 && 1 == 0 == NO ACTION (BAD)
DKIM_VALID_AU and SPF_PASS == if !(1 || 1) && 1 == 0 && 1 == 0 == NO ACTION (GOOD)

if no policy exists (0) we are always NO ACTION (GOOD)

Basically its not failing out and runing the domain's policy for a failure of individual parts.

Test with a quick perl script (play with the 3 variables up top):

#!/usr/bin/perl
$DKIM_VALID_AU = 1;
$SPF_PASS = 1;
$POLICY = 1;

$PRES = int( !($DKIM_VALID_AU || $SPF_PASS) );
$RES = int ( !($DKIM_VALID_AU || $SPF_PASS) && $POLICY );

print "DKIM_VALID_AU=$DKIM_VALID_AU SPF_PASS=$SPF_PASS PAREN RESULT=$PRES RESULT=$RES AKA: ";

if ($RES) {
print "ACTION\n";
} else {
print "NO ACTION\n";
}
POLICY=1 DKIM_VALID_AU=0 SPF_PASS=0 PAREN RESULT=1 RESULT=1 AKA: ACTION
POLICY=1 DKIM_VALID_AU=0 SPF_PASS=1 PAREN RESULT=0 RESULT=0 AKA: NO ACTION
POLICY=1 DKIM_VALID_AU=1 SPF_PASS=0 PAREN RESULT=0 RESULT=0 AKA: NO ACTION
POLICY=1 DKIM_VALID_AU=1 SPF_PASS=1 PAREN RESULT=0 RESULT=0 AKA: NO ACTION

The following will actually work (tested for all cases):

meta DMARC_REJECT !(DKIM_VALID_AU && SPF_PASS) && __DMARC_POLICY_REJECT

if not (DKIM_VALID_AU -AND- SPF_PASS) AND theres a policy for the domain then REJECT

Validate by changing that perl script logic to match:

#!/usr/bin/perl
$DKIM_VALID_AU = 1;
$SPF_PASS = 1;
$POLICY = 1;

$PRES = int( !($DKIM_VALID_AU && $SPF_PASS) );
$RES = int ( !($DKIM_VALID_AU && $SPF_PASS) && $POLICY );

print "DKIM_VALID_AU=$DKIM_VALID_AU SPF_PASS=$SPF_PASS PAREN RESULT=$PRES RESULT=$RES AKA: ";

if ($RES) {
print "ACTION\n";
} else {
print "NO ACTION\n";
}
POLICY=1 DKIM_VALID_AU=1 SPF_PASS=1 PAREN RESULT=0 RESULT=0 AKA: NO ACTION
POLICY=1 DKIM_VALID_AU=0 SPF_PASS=1 PAREN RESULT=1 RESULT=1 AKA: ACTION
POLICY=1 DKIM_VALID_AU=1 SPF_PASS=0 PAREN RESULT=1 RESULT=1 AKA: ACTION
POLICY=1 DKIM_VALID_AU=0 SPF_PASS=0 PAREN RESULT=1 RESULT=1 AKA: ACTION

POLICY=0 DKIM_VALID_AU=1 SPF_PASS=1 PAREN RESULT=0 RESULT=0 AKA: NO ACTION
POLICY=0 DKIM_VALID_AU=0 SPF_PASS=0 PAREN RESULT=1 RESULT=0 AKA: NO ACTION

If any of the conditions are 0 (fail) then the policy is enforced. If everything checks out its ignored. No policy means no action.

Thanks so much for the information about how to set the DMARC check up via AskDNS. Hopefully this correction helps make this method even better.

Reply |

Invalid syntax

Thank you, fixed

Reply |

Add a comment