2013-04-20 22:30:06 +02:00
#!perl -w
=head1 NAME
Domain-based Message Authentication, Reporting and Conformance
=head1 SYNOPSIS
2013-12-18 05:53:00 +01:00
DMARC is a reliable means to authenticate email.
2013-04-24 06:29:33 +02:00
=head1 DESCRIPTION
2013-04-20 22:30:06 +02:00
From the DMARC Draft: "DMARC operates as a policy layer atop DKIM and SPF. These technologies are the building blocks of DMARC as each is widely deployed, supported by mature tools, and is readily available to both senders and receivers. They are complementary, as each is resilient to many of the failure modes of the other."
DMARC provides a way to exchange authentication information and policies among mail servers.
2013-12-18 05:53:00 +01:00
DMARC benefits domain owners by preventing others from impersonating them. A domain owner can reliably tell other mail servers that "it it doesn't originate from this list of servers (SPF) and it is not signed (DKIM), then [ignore|quarantine|reject] it." DMARC also provides domain owners with a means to receive feedback and determine that their policies are working as desired.
2013-04-20 22:30:06 +02:00
2013-12-18 05:53:00 +01:00
DMARC benefits mail server operators by providing them with a more reliable (than SPF or DKIM alone) means to block forged emails. Is that message really from PayPal, Chase, Gmail, or Facebook? Since those organizations publish DMARC policies, operators have a definitive means to know.
2013-04-20 22:30:06 +02:00
=head1 HOWTO
2013-04-24 06:29:33 +02:00
=head2 Protect a domain with DMARC
2013-04-20 22:30:06 +02:00
See Section 10 of the draft: Domain Owner Actions
2013-04-25 08:25:21 +02:00
1. Deploy DKIM & SPF
2. Ensure identifier alignment.
3. Publish a "monitor" record, ask for data reports
4. Roll policies from monitor to reject
2013-04-20 22:30:06 +02:00
2013-04-24 06:29:33 +02:00
=head3 Publish a DMARC policy
2013-04-25 08:25:21 +02:00
_dmarc IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-feedback@example.com;"
2013-04-20 22:30:06 +02:00
v=DMARC1; (version)
p=none; (disposition policy : reject, quarantine, none (monitor))
sp=reject; (subdomain policy: default, same as p)
adkim=s; (dkim alignment: s=strict, r=relaxed)
aspf=r; (spf alignment: s=strict, r=relaxed)
2013-04-24 06:29:33 +02:00
rua=mailto: dmarc-feedback@example.com; (aggregate reports)
ruf=mailto: dmarc-feedback@example.com; (forensic reports)
2013-04-20 22:30:06 +02:00
rf=afrf; (report format: afrf, iodef)
ri=8400; (report interval)
pct=50; (percent of messages to filter)
2013-04-24 06:29:33 +02:00
=head2 Validate messages with DMARC
2013-12-18 05:53:00 +01:00
1. install Mail::DMARC
2013-04-24 06:29:33 +02:00
2013-12-18 05:53:00 +01:00
2. install this plugin
2013-04-20 22:30:06 +02:00
2013-04-25 08:25:21 +02:00
3. activate this plugin. (add to config/plugins, listing it after SPF & DKIM. Check that SPF and DKIM are configured to not reject mail.
2013-04-24 06:29:33 +02:00
2015-03-13 14:02:05 +01:00
=head1 CONFIGURATION
=head2 reporting [ 0 | 1 ]
Default: 1
0: do not save and publish aggregate reports
1: publish reports (requires proper Mail::DMARC configuration)
2013-04-24 06:29:33 +02:00
=head1 MORE INFORMATION
2013-04-20 22:30:06 +02:00
http://www.dmarc.org/draft-dmarc-base-00-02.txt
2013-12-18 05:53:00 +01:00
https://github.com/smtpd/qpsmtpd/wiki/DMARC-FAQ
2013-04-24 06:29:33 +02:00
2013-04-20 22:30:06 +02:00
=head1 TODO
2013-04-30 04:06:47 +02:00
reject messages with multiple From: headers
2013-04-20 22:30:06 +02:00
2013-04-25 08:25:21 +02:00
=head1 AUTHORS
2013-04-20 22:30:06 +02:00
2013-04-25 08:25:21 +02:00
2013 - Matt Simerson <msimerson@cpan.org>
2013-04-20 22:30:06 +02:00
=cut
use strict;
use warnings;
2015-01-21 19:09:37 +01:00
use English qw/-no_match_vars/;
2013-04-20 22:30:06 +02:00
use Qpsmtpd::Constants;
2013-12-18 05:53:00 +01:00
sub register {
my ($self, $qp, @args) = @_;
$self->log(LOGERROR, "Bad arguments") if @args % 2;
$self->{_args} = {@args};
2013-04-21 06:50:39 +02:00
$self->{_args}{reject} = 1 if !defined $self->{_args}{reject};
2013-04-20 22:30:06 +02:00
$self->{_args}{reject_type} ||= 'perm';
2013-04-21 06:50:39 +02:00
$self->{_args}{p_vals} = {map { $_ => 1 } qw/ none reject quarantine /};
2015-03-13 14:02:05 +01:00
$self->{_args}{reporting} = 1 if !defined $self->{_args}{reporting};
2013-04-20 22:30:06 +02:00
2015-01-05 02:10:26 +01:00
eval 'require Mail::DMARC::PurePerl';
2013-12-18 05:53:00 +01:00
if ( $@ ) {
$self->log(LOGERROR, "failed to load Mail::DMARC::PurePerl" );
}
else {
$self->{_dmarc} = Mail::DMARC::PurePerl->new();
2015-01-21 19:09:37 +01:00
$self->register_hook('data_post_headers', 'check_dmarc');
2016-05-02 18:31:10 +02:00
$self->register_hook('data_post', 'reject_dmarc');
2013-12-18 05:53:00 +01:00
};
2013-04-21 06:50:39 +02:00
}
2013-04-20 22:30:06 +02:00
2015-01-21 19:09:37 +01:00
sub check_dmarc {
2013-04-20 22:30:06 +02:00
my ($self, $transaction) = @_;
2013-12-18 05:53:00 +01:00
if ( $self->qp->connection->relay_client() ) {
$self->log(LOGINFO, "skip, relay client" );
2015-01-21 19:09:37 +01:00
return DECLINED; # don't report to ourself
2013-04-25 08:25:21 +02:00
};
2013-04-21 06:50:39 +02:00
2013-12-18 05:53:00 +01:00
my $dmarc = $self->{_dmarc};
$dmarc->init();
my $from = $transaction->header->get('From');
2014-01-09 01:22:16 +01:00
if ( ! $from ) {
$self->log(LOGINFO, "skip, null sender" );
return $self->get_reject("empty from address, null sender?");
};
2013-12-18 05:53:00 +01:00
eval { $dmarc->header_from_raw( $from ); };
if ( $@ ) {
$self->log(LOGERROR, "unparseable from header: $from" );
return $self->get_reject("unparseable from header");
2013-04-25 08:25:21 +02:00
};
2013-12-18 05:53:00 +01:00
my @recipients = $transaction->recipients;
eval { $dmarc->envelope_to( lc $recipients[0]->host ); }; # optional
eval { $dmarc->envelope_from( $transaction->sender->host ); }; # may be <>
2015-01-21 19:09:37 +01:00
eval { $dmarc->spf( $transaction->notes('dmarc_spf') ); };
2013-12-18 05:53:00 +01:00
my $dkim = $self->connection->notes('dkim_verifier');
2015-01-21 19:09:37 +01:00
if ( $dkim ) { eval { $dmarc->dkim( $dkim ); }; };
2013-12-18 05:53:00 +01:00
$dmarc->source_ip( $self->qp->connection->remote_ip );
eval { $dmarc->validate(); };
2015-01-21 19:09:37 +01:00
if ( $EVAL_ERROR ) {
2013-12-18 05:53:00 +01:00
$self->log(LOGERROR, $@ );
return DECLINED if $self->is_immune;
2015-01-21 19:09:37 +01:00
return $self->get_reject( $@ );
2013-04-24 22:16:00 +02:00
};
2013-12-18 05:53:00 +01:00
#$self->log(LOGINFO, "result: " . Dumper( $dmarc ) );
2013-04-21 08:49:39 +02:00
2013-12-18 05:53:00 +01:00
my $pol;
eval { $pol = $dmarc->result->published; };
2015-03-13 14:02:05 +01:00
if ( $self->{_args}{reporting} && $pol ) {
2016-04-28 19:09:07 +02:00
if ( $pol->rua && $dmarc->has_valid_reporting_uri($pol->rua) ) {
2013-12-18 05:53:00 +01:00
eval { $dmarc->save_aggregate(); };
$self->log(LOGERROR, $@ ) if $@;
2013-04-21 06:50:39 +02:00
}
2013-12-18 05:53:00 +01:00
else {
$self->log(LOGERROR, "has policy, no report URI" );
2013-04-21 08:49:39 +02:00
};
2013-04-25 08:25:21 +02:00
};
2013-04-24 09:09:02 +02:00
2016-05-03 19:35:43 +02:00
$transaction->notes('dmarc_result', $dmarc->result);
2013-12-18 05:53:00 +01:00
my $disposition = $dmarc->result->disposition;
2015-05-26 21:36:40 +02:00
my $result = $dmarc->result->result;
my $auth_str = "dmarc=$result";
2015-05-26 18:13:08 +02:00
$auth_str .= " (p=" . $pol->p . ")" if $pol;
2013-04-24 09:09:02 +02:00
2013-12-18 05:53:00 +01:00
if ( $dmarc->result->result eq 'pass' ) {
$self->log(LOGINFO, "pass");
$self->store_auth_results( $auth_str . " d=" . $dmarc->header_from);
return DECLINED;
2013-04-20 22:30:06 +02:00
};
2013-12-18 05:53:00 +01:00
my $reason_type = my $comment = '';
if ( $dmarc->result->reason && $dmarc->result->reason->[0] ) {
$reason_type = $dmarc->result->reason->[0]->type;
if ( $dmarc->result->reason->[0]->comment ) {
$comment = $dmarc->result->reason->[0]->comment;
2013-04-25 08:25:21 +02:00
};
};
2013-12-18 05:53:00 +01:00
if ( $disposition eq 'none' && $comment && $comment eq 'no policy') {
$self->log(LOGINFO, "skip, no policy");
return DECLINED;
2013-04-20 22:30:06 +02:00
};
2013-04-21 08:49:39 +02:00
2013-12-18 05:53:00 +01:00
my $log_mess = $dmarc->result->result;
$log_mess .= ", tolerated" if $disposition eq 'none';
$log_mess .= ", $reason_type" if $reason_type;
$log_mess .= ", $comment" if $comment;
$self->log(LOGINFO, $log_mess);
2013-04-21 08:49:39 +02:00
2013-12-18 05:53:00 +01:00
$self->store_auth_results( $auth_str . " d=" . $dmarc->header_from);
2013-04-20 22:30:06 +02:00
2013-12-18 05:53:00 +01:00
return DECLINED if $disposition eq 'none';
return DECLINED if ! $disposition; # for safety
return DECLINED if $self->is_immune;
2013-04-20 22:30:06 +02:00
2013-12-18 05:53:00 +01:00
$self->adjust_karma(-3);
2016-05-02 18:31:10 +02:00
# Add a mark now so the data_post hook can do the real reject
$self->connection->notes('reject_dmarc', '1');
}
sub reject_dmarc {
my ($self, $transaction) = @_;
return $self->get_reject("failed DMARC policy")
if ($self->connection->notes('reject_dmarc'));
return DECLINED;
2013-04-21 06:50:39 +02:00
}