402 lines
14 KiB
Plaintext
402 lines
14 KiB
Plaintext
|
#!perl -w
|
||
|
|
||
|
=head1 NAME
|
||
|
|
||
|
Domain-based Message Authentication, Reporting and Conformance
|
||
|
|
||
|
=head1 SYNOPSIS
|
||
|
|
||
|
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.
|
||
|
|
||
|
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 reject it!" DMARC also provides domain owners with a means to receive feedback and determine that their policies are working as desired.
|
||
|
|
||
|
DMARC benefits mail server operators by providing them with an extremely reliable (as opposed to DKIM or SPF, which both have reliability issues when used independently) means to block forged emails. Is that message really from PayPal, Chase, Gmail, or Facebook? Since those organizations, and many more, publish DMARC policies, operators have a definitive means to know.
|
||
|
|
||
|
=head1 HOW IT WORKS
|
||
|
|
||
|
=head1 HOWTO
|
||
|
|
||
|
See Section 10 of the draft: Domain Owner Actions
|
||
|
|
||
|
1. Deploy DKIM & SPF
|
||
|
2. Ensure identifier alignment.
|
||
|
3. Publish a "monitor" record, ask for data reports
|
||
|
4. Roll policies from monitor to reject
|
||
|
|
||
|
=head2 Publish a DMARC policy
|
||
|
|
||
|
v=DMARC1; (version)
|
||
|
p=none; (disposition policy : reject, quarantine, none (monitor))
|
||
|
sp=reject; (subdomain policy: default, same as p)
|
||
|
rua
|
||
|
adkim=s; (dkim alignment: s=strict, r=relaxed)
|
||
|
aspf=r; (spf alignment: s=strict, r=relaxed)
|
||
|
rua=mailto: dmarc-feedback\@$zone; (aggregate reports)
|
||
|
ruf=mailto: dmarc-feedback\@$zone.com; (forensic reports)
|
||
|
rf=afrf; (report format: afrf, iodef)
|
||
|
ri=8400; (report interval)
|
||
|
pct=50; (percent of messages to filter)
|
||
|
|
||
|
|
||
|
=head2
|
||
|
|
||
|
=head1 DRAFT
|
||
|
|
||
|
http://www.dmarc.org/draft-dmarc-base-00-02.txt
|
||
|
|
||
|
=head1 TODO
|
||
|
|
||
|
1. run dmarc before SPF, if DMARC policy is discovered, ignore SPF
|
||
|
|
||
|
2. provide dmarc feedback to domains that request it
|
||
|
|
||
|
3. If a message has multiple 'From' recipients, reject it
|
||
|
|
||
|
4. Rejections with a 550 (perm) or 450 (temp)
|
||
|
|
||
|
=head1 IMPLEMENTATION
|
||
|
|
||
|
1. Primary identifier is RFC5322.From field
|
||
|
|
||
|
2. Senders can specify strict or relaxed mode
|
||
|
|
||
|
3. policies available: reject, quarantine, no action
|
||
|
|
||
|
4. DMARC overrides other public auth mechanisms
|
||
|
|
||
|
5. senders can specify a percentage of messages to which policy applies
|
||
|
|
||
|
6. Receivers should endeavour to reject or quarantine email if the
|
||
|
RFC5322.From purports to be from a domain that appears to be
|
||
|
either non-existent or incapable of receiving mail.
|
||
|
|
||
|
=head2 Reports should include
|
||
|
|
||
|
The report SHOULD include the following data:
|
||
|
|
||
|
o Enough information for the report consumer to re-calculate DMARC
|
||
|
disposition based on the published policy, message dispositon, and
|
||
|
SPF, DKIM, and identifier alignment results. {R12}
|
||
|
|
||
|
o Data for each sender subdomain separately from mail from the
|
||
|
sender's organizational domain, even if no subdomain policy is
|
||
|
applied. {R13}
|
||
|
|
||
|
o Sending and receiving domains {R17}
|
||
|
|
||
|
o The policy requested by the Domain Owner and the policy actually
|
||
|
applied (if different) {R18}
|
||
|
|
||
|
o The number of successful authentications {R19}
|
||
|
|
||
|
o The counts of messages based on all messages received even if
|
||
|
their delivery is ultimately blocked by other filtering agents
|
||
|
{R20}
|
||
|
|
||
|
=cut
|
||
|
|
||
|
use strict;
|
||
|
use warnings;
|
||
|
|
||
|
use Qpsmtpd::Constants;
|
||
|
|
||
|
#use Socket qw(:DEFAULT :crlf);
|
||
|
|
||
|
sub init {
|
||
|
my ($self, $qp) = (shift, shift);
|
||
|
$self->{_args} = { @_ };
|
||
|
$self->{_args}{reject} = 1 if ! defined $self->{_args}{reject};
|
||
|
$self->{_args}{reject_type} ||= 'perm';
|
||
|
$self->{_args}{p_vals} = { map { $_ => 1 } qw/ none reject quarantine / };
|
||
|
}
|
||
|
|
||
|
sub register {
|
||
|
my $self = shift;
|
||
|
|
||
|
$self->register_hook('data_post', 'data_post_handler');
|
||
|
};
|
||
|
|
||
|
sub data_post_handler {
|
||
|
my ($self, $transaction) = @_;
|
||
|
|
||
|
return DECLINED if $self->is_immune();
|
||
|
|
||
|
# 11.1. Extract Author Domain
|
||
|
|
||
|
# TODO: check exists_in_dns result, and possibly reject here if domain non-exist
|
||
|
my $from_host = $self->get_from_host( $transaction ) or return DECLINED;
|
||
|
if ( ! $self->exists_in_dns( $from_host ) ) {
|
||
|
my $org_host = $self->get_organizational_domain( $from_host );
|
||
|
if ( ! $self->exists_in_dns( $org_host ) ) {
|
||
|
$self->log( LOGINFO, "fail, domain/org not in DNS" );
|
||
|
#return $self->get_reject();
|
||
|
return DECLINED;
|
||
|
};
|
||
|
};
|
||
|
|
||
|
# 11.2. Determine Handling Policy
|
||
|
my $policy = $self->discover_policy( $from_host )
|
||
|
or return DECLINED;
|
||
|
|
||
|
# 3. Perform DKIM signature verification checks. A single email may
|
||
|
# contain multiple DKIM signatures. The results of this step are
|
||
|
# passed to the remainder of the algorithm and MUST include the
|
||
|
# value of the "d=" tag from all DKIM signatures that successfully
|
||
|
# validated.
|
||
|
my $dkim_sigs = $self->connection->notes('dkim_pass_domains') || [];
|
||
|
|
||
|
# 4. Perform SPF validation checks. The results of this step are
|
||
|
# passed to the remainder of the algorithm and MUST include the
|
||
|
# domain name from the RFC5321.MailFrom if SPF evaluation returned
|
||
|
# a "pass" result.
|
||
|
my $spf_dom = $transaction->notes('spf_pass_host');
|
||
|
|
||
|
# 5. Conduct identifier alignment checks. With authentication checks
|
||
|
# and policy discovery performed, the Mail Receiver checks if
|
||
|
# Authenticated Identifiers fall into alignment as decribed in
|
||
|
# Section 4. If one or more of the Authenticated Identifiers align
|
||
|
# with the RFC5322.From domain, the message is considered to pass
|
||
|
# the DMARC mechanism check. All other conditions (authentication
|
||
|
# failures, identifier mismatches) are considered to be DMARC
|
||
|
# mechanism check failures.
|
||
|
foreach ( @$dkim_sigs ) {
|
||
|
if ( $_ eq $from_host ) { # strict alignment
|
||
|
$self->log(LOGINFO, "pass, DKIM alignment");
|
||
|
$self->adjust_karma( 2 ); # big karma boost
|
||
|
return DECLINED;
|
||
|
};
|
||
|
};
|
||
|
|
||
|
if ( $spf_dom && $spf_dom eq $from_host ) {
|
||
|
$self->adjust_karma( 2 ); # big karma boost
|
||
|
$self->log(LOGINFO, "pass, SPF alignment");
|
||
|
return DECLINED;
|
||
|
};
|
||
|
|
||
|
# 6. Apply policy. Emails that fail the DMARC mechanism check are
|
||
|
# disposed of in accordance with the discovered DMARC policy of the
|
||
|
# Domain Owner. See Section 6.2 for details.
|
||
|
|
||
|
$self->log(LOGINFO, "skip, NEED RELAXED alignment");
|
||
|
return DECLINED;
|
||
|
};
|
||
|
|
||
|
sub discover_policy {
|
||
|
my ($self, $from_host) = @_;
|
||
|
|
||
|
# 1. Mail Receivers MUST query the DNS for a DMARC TXT record at the
|
||
|
# DNS domain matching the one found in the RFC5322.From domain in
|
||
|
# the message. A possibly empty set of records is returned.
|
||
|
my @matches = $self->fetch_dmarc_record($from_host); # 2. within
|
||
|
if ( 0 == scalar @matches ) {
|
||
|
# 3. If the set is now empty, the Mail Receiver MUST query the DNS for
|
||
|
# a DMARC TXT record at the DNS domain matching the Organizational
|
||
|
# Domain in place of the RFC5322.From domain in the message (if
|
||
|
# different). This record can contain policy to be asserted for
|
||
|
# subdomains of the Organizational Domain.
|
||
|
|
||
|
my $org_dom = $self->get_organizational_domain( $from_host ) or return;
|
||
|
if ( $org_dom eq $from_host ) {
|
||
|
$self->log( LOGINFO, "skip, no policy for $from_host (same org)" );
|
||
|
return;
|
||
|
};
|
||
|
@matches = $self->fetch_dmarc_record($org_dom);
|
||
|
|
||
|
if ( 0 == scalar @matches ) {
|
||
|
$self->log( LOGINFO, "skip, no policy for $from_host" );
|
||
|
return;
|
||
|
};
|
||
|
};
|
||
|
|
||
|
# 4. Records that do not include a "v=" tag that identifies the
|
||
|
# current version of DMARC are discarded.
|
||
|
@matches = grep /v=DMARC1/i, @matches;
|
||
|
if ( 0 == scalar @matches ) {
|
||
|
$self->log( LOGINFO, "skip, no valid record for $from_host" );
|
||
|
return;
|
||
|
};
|
||
|
|
||
|
# 5. If the remaining set contains multiple records, processing
|
||
|
# terminates and the Mail Receiver takes no action.
|
||
|
if ( @matches > 1 ) {
|
||
|
$self->log( LOGINFO, "skip, too many records" );
|
||
|
return;
|
||
|
};
|
||
|
|
||
|
# 6. If a retrieved policy record does not contain a valid "p" tag, or
|
||
|
# contains an "sp" tag that is not valid, then:
|
||
|
my %policy = $self->parse_policy( $matches[0] );
|
||
|
if ( ! $self->has_valid_p(\%policy) || $self->has_invalid_sp(\%policy) ) {
|
||
|
|
||
|
# A. if an "rua" tag is present and contains at least one
|
||
|
# syntactically valid reporting URI, the Mail Receiver SHOULD
|
||
|
# act as if a record containing a valid "v" tag and "p=none"
|
||
|
# was retrieved, and continue processing;
|
||
|
# B. otherwise, the Mail Receiver SHOULD take no action.
|
||
|
my $rua = $policy{rua};
|
||
|
if ( ! $rua || ! $self->has_valid_reporting_uri($rua) ) {
|
||
|
$self->log( LOGINFO, "skip, no valid reporting rua" );
|
||
|
return;
|
||
|
};
|
||
|
$policy{v} = 'DMARC1';
|
||
|
$policy{p} = 'none';
|
||
|
};
|
||
|
|
||
|
return \%policy;
|
||
|
};
|
||
|
|
||
|
sub has_valid_p {
|
||
|
my ($self, $policy) = @_;
|
||
|
return 1 if $self->{_args}{p_vals}{$policy};
|
||
|
return 0;
|
||
|
};
|
||
|
|
||
|
sub has_invalid_sp {
|
||
|
my ($self, $policy) = @_;
|
||
|
return 0 if ! $self->{_args}{p_vals}{$policy};
|
||
|
return 1;
|
||
|
};
|
||
|
|
||
|
sub has_valid_reporting_uri {
|
||
|
my ($self, $rua) = @_;
|
||
|
return 1 if 'mailto:' eq lc substr($rua, 0, 7);
|
||
|
return 0;
|
||
|
};
|
||
|
|
||
|
sub get_organizational_domain {
|
||
|
my ($self, $from_host) = @_;
|
||
|
|
||
|
# 1. Acquire a "public suffix" list, i.e., a list of DNS domain
|
||
|
# names reserved for registrations. http://publicsuffix.org/list/
|
||
|
# $self->qp->config('public_suffix_list')
|
||
|
|
||
|
# 2. Break the subject DNS domain name into a set of "n" ordered
|
||
|
# labels. Number these labels from right-to-left; e.g. for
|
||
|
# "example.com", "com" would be label 1 and "example" would be
|
||
|
# label 2.;
|
||
|
my @labels = reverse split /\./, $from_host;
|
||
|
|
||
|
# 3. Search the public suffix list for the name that matches the
|
||
|
# largest number of labels found in the subject DNS domain. Let
|
||
|
# that number be "x".
|
||
|
my $greatest = 0;
|
||
|
for ( my $i = 0; $i <= scalar @labels; $i++ ) {
|
||
|
next if ! $labels[$i];
|
||
|
my $tld = join '.', reverse( (@labels)[0..$i] );
|
||
|
# $self->log( LOGINFO, "i: $i, $tld" );
|
||
|
#warn "i: $i - tld: $tld\n";
|
||
|
if ( grep /$tld/, $self->qp->config('public_suffix_list') ) {
|
||
|
$greatest = $i + 1;
|
||
|
};
|
||
|
};
|
||
|
|
||
|
return $from_host if $greatest == scalar @labels; # same
|
||
|
|
||
|
# 4. Construct a new DNS domain name using the name that matched
|
||
|
# from the public suffix list and prefixing to it the "x+1"th
|
||
|
# label from the subject domain. This new name is the
|
||
|
# Organizational Domain.
|
||
|
return join '.', reverse( (@labels)[0..$greatest]);
|
||
|
};
|
||
|
|
||
|
sub exists_in_dns {
|
||
|
my ($self, $domain) = @_;
|
||
|
my $res = $self->init_resolver();
|
||
|
my $query = $res->send( $domain, 'NS' ) or do {
|
||
|
if ( $res->errorstring eq 'NXDOMAIN' ) {
|
||
|
$self->log( LOGDEBUG, "fail, non-existent domain: $domain" );
|
||
|
return;
|
||
|
};
|
||
|
$self->log( LOGINFO, "error, looking up NS for $domain: " . $res->errorstring );
|
||
|
return;
|
||
|
};
|
||
|
my @matches;
|
||
|
for my $rr ($query->answer) {
|
||
|
next if $rr->type ne 'NS';
|
||
|
push @matches, $rr->nsdname;
|
||
|
};
|
||
|
if ( 0 == scalar @matches ) {
|
||
|
$self->log( LOGDEBUG, "fail, zero NS for $domain" );
|
||
|
};
|
||
|
return @matches;
|
||
|
};
|
||
|
|
||
|
sub fetch_dmarc_record {
|
||
|
my ($self, $zone) = @_;
|
||
|
my $res = $self->init_resolver();
|
||
|
my $query = $res->send( '_dmarc.' . $zone, 'TXT' );
|
||
|
my @matches;
|
||
|
for my $rr ($query->answer) {
|
||
|
next if $rr->type ne 'TXT';
|
||
|
# 2. Records that do not start with a "v=" tag that identifies the
|
||
|
# current version of DMARC are discarded.
|
||
|
next if 'v=' ne substr( $rr->txtdata, 0, 2);
|
||
|
$self->log( LOGINFO, $rr->txtdata );
|
||
|
push @matches, join('', $rr->txtdata);
|
||
|
};
|
||
|
return @matches;
|
||
|
};
|
||
|
|
||
|
sub get_from_host {
|
||
|
my ($self, $transaction) = @_;
|
||
|
|
||
|
my $from = $transaction->header->get('From') or do {
|
||
|
$self->log( LOGINFO, "error, unable to retrieve From header!" );
|
||
|
return;
|
||
|
};
|
||
|
my ($from_host) = (split /@/, $from)[-1]; # grab everything after the @
|
||
|
($from_host) = split /\s+/, $from_host; # remove any trailing cruft
|
||
|
chomp $from_host;
|
||
|
chop $from_host if '>' eq substr($from_host,-1,1);
|
||
|
$self->log( LOGDEBUG, "info, from_host is $from_host" );
|
||
|
return $from_host;
|
||
|
};
|
||
|
|
||
|
sub parse_policy {
|
||
|
my ($self, $str) = @_;
|
||
|
$str =~ s/\s//g; # remove all whitespace
|
||
|
my %dmarc = map { split /=/, $_ } split /;/, $str;
|
||
|
#warn Data::Dumper::Dumper(\%dmarc);
|
||
|
return %dmarc;
|
||
|
};
|
||
|
|
||
|
sub verify_external_reporting {
|
||
|
|
||
|
=head2 Verify External Destinations
|
||
|
|
||
|
1. Extract the host portion of the authority component of the URI.
|
||
|
Call this the "destination host".
|
||
|
|
||
|
2. Prepend the string "_report._dmarc".
|
||
|
|
||
|
3. Prepend the domain name from which the policy was retrieved.
|
||
|
|
||
|
4. Query the DNS for a TXT record at the constructed name. If the
|
||
|
result of this request is a temporary DNS error of some kind
|
||
|
(e.g., a timeout), the Mail Receiver MAY elect to temporarily
|
||
|
fail the delivery so the verification test can be repeated later.
|
||
|
|
||
|
5. If the result includes no TXT resource records or multiple TXT
|
||
|
resource records, a positive determination of the external
|
||
|
reporting relationship cannot be made; stop.
|
||
|
|
||
|
6. Parse the result, if any, as a series of "tag=value" pairs, i.e.,
|
||
|
the same overall format as the policy record. In particular, the
|
||
|
"v=DMARC1" tag is mandatory and MUST appear first in the list.
|
||
|
If at least that tag is present and the record overall is
|
||
|
syntactically valid per Section 6.3, then the external reporting
|
||
|
arrangement was authorized by the destination ADMD.
|
||
|
|
||
|
7. If a "rua" or "ruf" tag is thus discovered, replace the
|
||
|
corresponding value extracted from the domain's DMARC policy
|
||
|
record with the one found in this record. This permits the
|
||
|
report receiver to override the report destination. However, to
|
||
|
prevent loops or indirect abuse, the overriding URI MUST use the
|
||
|
same destination host from the first step.
|
||
|
|
||
|
=cut
|
||
|
|
||
|
};
|