dmarc: added subdomain policy handling
This commit is contained in:
parent
9e204aaf7e
commit
f854736142
156
plugins/dmarc
156
plugins/dmarc
@ -31,7 +31,7 @@ See Section 10 of the draft: Domain Owner Actions
|
|||||||
|
|
||||||
=head3 Publish a DMARC policy
|
=head3 Publish a DMARC policy
|
||||||
|
|
||||||
_dmarc IN TXT "v=DMARC1; p=reject; pct=100; rua=mailto:dmarc-feedback@example.com;"
|
_dmarc IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-feedback@example.com;"
|
||||||
|
|
||||||
v=DMARC1; (version)
|
v=DMARC1; (version)
|
||||||
p=none; (disposition policy : reject, quarantine, none (monitor))
|
p=none; (disposition policy : reject, quarantine, none (monitor))
|
||||||
@ -50,9 +50,7 @@ _dmarc IN TXT "v=DMARC1; p=reject; pct=100; rua=mailto:dmarc-feedback@example.c
|
|||||||
|
|
||||||
2. install a public suffix list in config/public_suffix_list. See http://publicsuffix.org/list/
|
2. install a public suffix list in config/public_suffix_list. See http://publicsuffix.org/list/
|
||||||
|
|
||||||
3. activate this plugin (add to config/plugins)
|
3. activate this plugin. (add to config/plugins, listing it after SPF & DKIM. Check that SPF and DKIM are configured to not reject mail.
|
||||||
|
|
||||||
Be sure to run the DMARC plugin after the SPF & DKIM plugins. Configure the SPF and DKIM messages to not reject mail.
|
|
||||||
|
|
||||||
=head2 Parse dmarc feedback reports into a database
|
=head2 Parse dmarc feedback reports into a database
|
||||||
|
|
||||||
@ -68,23 +66,9 @@ https://github.com/qpsmtpd-dev/qpsmtpd-dev/wiki/DMARC-FAQ
|
|||||||
|
|
||||||
2. provide dmarc feedback to domains that request it
|
2. provide dmarc feedback to domains that request it
|
||||||
|
|
||||||
3. If a message has multiple 'From' recipients, reject it
|
=head1 AUTHORS
|
||||||
|
|
||||||
=head1 IMPLEMENTATION
|
2013 - Matt Simerson <msimerson@cpan.org>
|
||||||
|
|
||||||
1. Primary identifier is RFC5322.From field (From: header)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
=cut
|
=cut
|
||||||
|
|
||||||
@ -113,18 +97,17 @@ sub data_post_handler {
|
|||||||
return DECLINED if $self->is_immune();
|
return DECLINED if $self->is_immune();
|
||||||
|
|
||||||
# 11.1. Extract Author Domain
|
# 11.1. Extract Author Domain
|
||||||
|
my $from_dom = $self->get_from_dom($transaction) or return DECLINED;
|
||||||
my $from_host = $self->get_from_host($transaction) or return DECLINED;
|
my $org_dom = $self->get_organizational_domain($from_dom);
|
||||||
my $org_host = $self->get_organizational_domain($from_host);
|
|
||||||
|
|
||||||
# 6. Receivers should reject email if the domain appears to not exist
|
# 6. Receivers should reject email if the domain appears to not exist
|
||||||
if (!$self->exists_in_dns($from_host) && !$self->exists_in_dns($org_host)) {
|
my $exists = $self->exists_in_dns($from_dom, $org_dom) or do {
|
||||||
$self->log(LOGINFO, "fail, $from_host not in DNS");
|
$self->log(LOGINFO, "fail, $from_dom not in DNS");
|
||||||
return $self->get_reject("RFC5322.From host appears non-existent");
|
return $self->get_reject("RFC5322.From host appears non-existent");
|
||||||
}
|
};
|
||||||
|
|
||||||
# 11.2. Determine Handling Policy
|
# 11.2. Determine Handling Policy
|
||||||
my $policy = $self->discover_policy($from_host)
|
my $policy = $self->discover_policy($from_dom, $org_dom)
|
||||||
or return DECLINED;
|
or return DECLINED;
|
||||||
|
|
||||||
# 3. Perform DKIM signature verification checks. A single email may
|
# 3. Perform DKIM signature verification checks. A single email may
|
||||||
@ -139,11 +122,14 @@ sub data_post_handler {
|
|||||||
|
|
||||||
# 5. Conduct identifier alignment checks.
|
# 5. Conduct identifier alignment checks.
|
||||||
return DECLINED
|
return DECLINED
|
||||||
if $self->is_aligned($from_host, $org_host, $policy, $spf_dom );
|
if $self->is_aligned($from_dom, $org_dom, $policy, $spf_dom );
|
||||||
|
|
||||||
# 6. Apply policy. Emails that fail the DMARC mechanism check are
|
# 6. Apply policy. Emails that fail the DMARC mechanism check are
|
||||||
# disposed of in accordance with the discovered DMARC policy of the
|
# disposed of in accordance with the discovered DMARC policy of the
|
||||||
# Domain Owner. See Section 6.2 for details.
|
# Domain Owner. See Section 6.2 for details.
|
||||||
|
if ( $self->{_args}{is_subdomain} && defined $policy->{sp} ) {
|
||||||
|
return DECLINED if lc $policy->{sp} eq 'none';
|
||||||
|
};
|
||||||
return DECLINED if lc $policy->{p} eq 'none';
|
return DECLINED if lc $policy->{p} eq 'none';
|
||||||
|
|
||||||
my $pct = $policy->{pct} || 100;
|
my $pct = $policy->{pct} || 100;
|
||||||
@ -156,7 +142,7 @@ sub data_post_handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub is_aligned {
|
sub is_aligned {
|
||||||
my ($self, $from_host, $org_host, $policy, $spf_dom) = @_;
|
my ($self, $from_dom, $org_dom, $policy, $spf_dom) = @_;
|
||||||
|
|
||||||
# 5. Conduct identifier alignment checks. With authentication checks
|
# 5. Conduct identifier alignment checks. With authentication checks
|
||||||
# and policy discovery performed, the Mail Receiver checks if
|
# and policy discovery performed, the Mail Receiver checks if
|
||||||
@ -169,14 +155,14 @@ sub is_aligned {
|
|||||||
|
|
||||||
my $dkim_sigs = $self->connection->notes('dkim_pass_domains') || [];
|
my $dkim_sigs = $self->connection->notes('dkim_pass_domains') || [];
|
||||||
foreach (@$dkim_sigs) {
|
foreach (@$dkim_sigs) {
|
||||||
if ($_ eq $from_host) { # strict alignment
|
if ($_ eq $from_dom) { # strict alignment, requires exact match
|
||||||
$self->log(LOGINFO, "pass, DKIM aligned");
|
$self->log(LOGINFO, "pass, DKIM aligned");
|
||||||
$self->adjust_karma(1);
|
$self->adjust_karma(1);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
next if $policy->{adkim} && lc $policy->{adkim} eq 's'; # strict pol.
|
next if $policy->{adkim} && lc $policy->{adkim} eq 's'; # strict pol.
|
||||||
# default policy is relaxed
|
# relaxed policy (default): Org. Dom must match a DKIM sig
|
||||||
if ( $_ eq $org_host ) {
|
if ( $_ eq $org_dom ) {
|
||||||
$self->log(LOGINFO, "pass, DKIM aligned, relaxed");
|
$self->log(LOGINFO, "pass, DKIM aligned, relaxed");
|
||||||
$self->adjust_karma(1);
|
$self->adjust_karma(1);
|
||||||
return 1;
|
return 1;
|
||||||
@ -184,13 +170,13 @@ sub is_aligned {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return 0 if ! $spf_dom;
|
return 0 if ! $spf_dom;
|
||||||
if ($spf_dom eq $from_host) {
|
if ($spf_dom eq $from_dom) {
|
||||||
$self->adjust_karma(1);
|
$self->adjust_karma(1);
|
||||||
$self->log(LOGINFO, "pass, SPF aligned");
|
$self->log(LOGINFO, "pass, SPF aligned");
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
return 0 if ($policy->{aspf} && lc $policy->{aspf} eq 's' ); # strict pol
|
return 0 if ($policy->{aspf} && lc $policy->{aspf} eq 's' ); # strict pol
|
||||||
if ($spf_dom eq $org_host) {
|
if ($spf_dom eq $org_dom) {
|
||||||
$self->adjust_karma(1);
|
$self->adjust_karma(1);
|
||||||
$self->log(LOGINFO, "pass, SPF aligned, relaxed");
|
$self->log(LOGINFO, "pass, SPF aligned, relaxed");
|
||||||
return 1;
|
return 1;
|
||||||
@ -200,35 +186,16 @@ sub is_aligned {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sub discover_policy {
|
sub discover_policy {
|
||||||
my ($self, $from_host) = @_;
|
my ($self, $from_dom, $org_dom) = @_;
|
||||||
|
|
||||||
# 1. Mail Receivers MUST query the DNS for a DMARC TXT record...
|
# 1. Mail Receivers MUST query the DNS for a DMARC TXT record...
|
||||||
my @matches = $self->fetch_dmarc_record($from_host); # 2. within
|
my @matches = $self->fetch_dmarc_record($from_dom, $org_dom) or return;
|
||||||
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
|
# 4. Records that do not include a "v=" tag that identifies the
|
||||||
# current version of DMARC are discarded.
|
# current version of DMARC are discarded.
|
||||||
@matches = grep /v=DMARC1/i, @matches;
|
@matches = grep /v=DMARC1/i, @matches;
|
||||||
if (0 == scalar @matches) {
|
if (0 == scalar @matches) {
|
||||||
$self->log(LOGINFO, "skip, no valid record for $from_host");
|
$self->log(LOGINFO, "skip, no valid record for $from_dom");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -280,7 +247,7 @@ sub has_valid_reporting_uri {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub get_organizational_domain {
|
sub get_organizational_domain {
|
||||||
my ($self, $from_host) = @_;
|
my ($self, $from_dom) = @_;
|
||||||
|
|
||||||
# 1. Acquire a "public suffix" list, i.e., a list of DNS domain
|
# 1. Acquire a "public suffix" list, i.e., a list of DNS domain
|
||||||
# names reserved for registrations. http://publicsuffix.org/list/
|
# names reserved for registrations. http://publicsuffix.org/list/
|
||||||
@ -290,7 +257,7 @@ sub get_organizational_domain {
|
|||||||
# labels. Number these labels from right-to-left; e.g. for
|
# labels. Number these labels from right-to-left; e.g. for
|
||||||
# "example.com", "com" would be label 1 and "example" would be
|
# "example.com", "com" would be label 1 and "example" would be
|
||||||
# label 2.;
|
# label 2.;
|
||||||
my @labels = reverse split /\./, $from_host;
|
my @labels = reverse split /\./, $from_dom;
|
||||||
|
|
||||||
# 3. Search the public suffix list for the name that matches the
|
# 3. Search the public suffix list for the name that matches the
|
||||||
# largest number of labels found in the subject DNS domain. Let
|
# largest number of labels found in the subject DNS domain. Let
|
||||||
@ -314,7 +281,7 @@ sub get_organizational_domain {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return $from_host if $greatest == scalar @labels; # same
|
return $from_dom if $greatest == scalar @labels; # same
|
||||||
|
|
||||||
# 4. Construct a new DNS domain name using the name that matched
|
# 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
|
# from the public suffix list and prefixing to it the "x+1"th
|
||||||
@ -324,26 +291,29 @@ sub get_organizational_domain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sub exists_in_dns {
|
sub exists_in_dns {
|
||||||
my ($self, $domain) = @_;
|
my ($self, $domain, $org_dom) = @_;
|
||||||
# 6. Receivers should endeavour to reject or quarantine email if the
|
# 6. Receivers should endeavour to reject or quarantine email if the
|
||||||
# RFC5322.From purports to be from a domain that appears to be
|
# RFC5322.From purports to be from a domain that appears to be
|
||||||
# either non-existent or incapable of receiving mail.
|
# either non-existent or incapable of receiving mail.
|
||||||
|
|
||||||
# I went back to the ADSP (from where DMARC this originated, which in turn
|
# That's all the draft says. I went back to the DKIM ADSP (which led me to
|
||||||
# led me to the ietf-dkim email list where a handful of 'experts' failed to
|
# the ietf-dkim email list where some 'experts' failed to agree on The Right
|
||||||
# agree on The Right Way to test domain validity. No direction was given.
|
# Way to test domain validity. Let alone deliverability. They point out:
|
||||||
# They point out:
|
# MX records aren't mandatory, and A|AAAA as fallback aren't reliable.
|
||||||
# MX records aren't mandatory.
|
#
|
||||||
# A or AAAA records as fallback aren't reliable.
|
# Some experimentation proved both cases in real world usage. Instead, I test
|
||||||
|
# existence by searching for a MX, NS, A, or AAAA record. Since this search
|
||||||
# I chose to query the From: domain name and match NS,MX,A,or AAAA records.
|
# is repeated for the Organizational Name, if the NS query fails, there's no
|
||||||
# Since this search gets repeated for the Organizational Name, if it
|
# delegation from the TLD. That's proven very reliable.
|
||||||
# fails for the O.N., there's no delegation from the TLD.
|
|
||||||
my $res = $self->init_resolver(8);
|
my $res = $self->init_resolver(8);
|
||||||
return 1 if $self->host_has_rr('NS', $res, $domain);
|
my @todo = $domain;
|
||||||
return 1 if $self->host_has_rr('MX', $res, $domain);
|
push @todo, $org_dom if $domain ne $org_dom;
|
||||||
return 1 if $self->host_has_rr('A', $res, $domain);
|
foreach ( @todo ) {
|
||||||
return 1 if $self->host_has_rr('AAAA', $res, $domain);
|
return 1 if $self->host_has_rr('MX', $res, $_);
|
||||||
|
return 1 if $self->host_has_rr('NS', $res, $_);
|
||||||
|
return 1 if $self->host_has_rr('A', $res, $_);
|
||||||
|
return 1 if $self->host_has_rr('AAAA', $res, $_);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
sub host_has_rr {
|
sub host_has_rr {
|
||||||
@ -370,12 +340,12 @@ sub host_has_rr {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sub fetch_dmarc_record {
|
sub fetch_dmarc_record {
|
||||||
my ($self, $zone) = @_;
|
my ($self, $zone, $org_dom) = @_;
|
||||||
|
|
||||||
# 1. Mail Receivers MUST query the DNS for a DMARC TXT record at the
|
# 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
|
# DNS domain matching the one found in the RFC5322.From domain in
|
||||||
# the message. A possibly empty set of records is returned.
|
# the message. A possibly empty set of records is returned.
|
||||||
|
$self->{_args}{is_subdomain} = defined $org_dom ? 0 : 1;
|
||||||
my $res = $self->init_resolver();
|
my $res = $self->init_resolver();
|
||||||
my $query = $res->send('_dmarc.' . $zone, 'TXT');
|
my $query = $res->send('_dmarc.' . $zone, 'TXT');
|
||||||
my @matches;
|
my @matches;
|
||||||
@ -384,27 +354,43 @@ sub fetch_dmarc_record {
|
|||||||
|
|
||||||
# 2. Records that do not start with a "v=" tag that identifies the
|
# 2. Records that do not start with a "v=" tag that identifies the
|
||||||
# current version of DMARC are discarded.
|
# current version of DMARC are discarded.
|
||||||
next if 'v=' ne substr($rr->txtdata, 0, 2);
|
next if 'v=' ne lc substr($rr->txtdata, 0, 2);
|
||||||
next if 'v=spf' eq substr($rr->txtdata, 0, 5); # commonly found
|
next if 'v=spf' eq lc substr($rr->txtdata, 0, 5); # SPF commonly found
|
||||||
$self->log(LOGINFO, $rr->txtdata);
|
$self->log(LOGINFO, $rr->txtdata);
|
||||||
push @matches, join('', $rr->txtdata);
|
push @matches, join('', $rr->txtdata);
|
||||||
}
|
}
|
||||||
|
return @matches if scalar @matches; # found one! (at least)
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
if ( defined $org_dom ) { # <- recursion break
|
||||||
|
if ( $org_dom eq $zone ) {
|
||||||
|
$self->log(LOGINFO, "skip, no policy for $zone (same org)");
|
||||||
|
return @matches;
|
||||||
|
};
|
||||||
|
return $self->fetch_dmarc_record($org_dom); # <- recursion
|
||||||
|
};
|
||||||
|
|
||||||
|
$self->log(LOGINFO, "skip, no policy for $zone");
|
||||||
return @matches;
|
return @matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
sub get_from_host {
|
sub get_from_dom {
|
||||||
my ($self, $transaction) = @_;
|
my ($self, $transaction) = @_;
|
||||||
|
|
||||||
my $from = $transaction->header->get('From') or do {
|
my $from = $transaction->header->get('From') or do {
|
||||||
$self->log(LOGINFO, "error, unable to retrieve From header!");
|
$self->log(LOGINFO, "error, unable to retrieve From header!");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
my ($from_host) = (split /@/, $from)[-1]; # grab everything after the @
|
my ($from_dom) = (split /@/, $from)[-1]; # grab everything after the @
|
||||||
($from_host) = split /\s+/, $from_host; # remove any trailing cruft
|
($from_dom) = split /\s+/, $from_dom; # remove any trailing cruft
|
||||||
chomp $from_host;
|
chomp $from_dom; # remove \n
|
||||||
chop $from_host if '>' eq substr($from_host, -1, 1);
|
chop $from_dom if '>' eq substr($from_dom, -1, 1); # remove closing >
|
||||||
$self->log(LOGDEBUG, "info, from_host is $from_host");
|
$self->log(LOGDEBUG, "info, from_dom is $from_dom");
|
||||||
return $from_host;
|
return $from_dom;
|
||||||
}
|
}
|
||||||
|
|
||||||
sub parse_policy {
|
sub parse_policy {
|
||||||
|
Loading…
Reference in New Issue
Block a user