From b8229fbdbf373d87cc18d1d99c901bd633575294 Mon Sep 17 00:00:00 2001 From: Matt Simerson Date: Thu, 25 Apr 2013 02:25:21 -0400 Subject: [PATCH] dmarc: added subdomain policy handling --- plugins/dmarc | 164 +++++++++++++++++++++++--------------------------- 1 file changed, 75 insertions(+), 89 deletions(-) diff --git a/plugins/dmarc b/plugins/dmarc index 1c1eaa0..a44c6d6 100644 --- a/plugins/dmarc +++ b/plugins/dmarc @@ -24,14 +24,14 @@ DMARC benefits mail server operators by providing them with an extremely reliabl 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 + 1. Deploy DKIM & SPF + 2. Ensure identifier alignment. + 3. Publish a "monitor" record, ask for data reports + 4. Roll policies from monitor to reject =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) 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/ -3. activate this plugin (add to config/plugins) - -Be sure to run the DMARC plugin after the SPF & DKIM plugins. Configure the SPF and DKIM messages to not reject mail. +3. activate this plugin. (add to config/plugins, listing it after SPF & DKIM. Check that SPF and DKIM are configured to not reject mail. =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 - 3. If a message has multiple 'From' recipients, reject it +=head1 AUTHORS -=head1 IMPLEMENTATION - -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. + 2013 - Matt Simerson =cut @@ -113,18 +97,17 @@ sub data_post_handler { return DECLINED if $self->is_immune(); # 11.1. Extract Author Domain - - my $from_host = $self->get_from_host($transaction) or return DECLINED; - my $org_host = $self->get_organizational_domain($from_host); + my $from_dom = $self->get_from_dom($transaction) or return DECLINED; + my $org_dom = $self->get_organizational_domain($from_dom); # 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)) { - $self->log(LOGINFO, "fail, $from_host not in DNS"); + my $exists = $self->exists_in_dns($from_dom, $org_dom) or do { + $self->log(LOGINFO, "fail, $from_dom not in DNS"); return $self->get_reject("RFC5322.From host appears non-existent"); - } + }; # 11.2. Determine Handling Policy - my $policy = $self->discover_policy($from_host) + my $policy = $self->discover_policy($from_dom, $org_dom) or return DECLINED; # 3. Perform DKIM signature verification checks. A single email may @@ -139,11 +122,14 @@ sub data_post_handler { # 5. Conduct identifier alignment checks. 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 # disposed of in accordance with the discovered DMARC policy of the # 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'; my $pct = $policy->{pct} || 100; @@ -156,7 +142,7 @@ sub data_post_handler { } 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 # 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') || []; 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->adjust_karma(1); return 1; } next if $policy->{adkim} && lc $policy->{adkim} eq 's'; # strict pol. - # default policy is relaxed - if ( $_ eq $org_host ) { + # relaxed policy (default): Org. Dom must match a DKIM sig + if ( $_ eq $org_dom ) { $self->log(LOGINFO, "pass, DKIM aligned, relaxed"); $self->adjust_karma(1); return 1; @@ -184,13 +170,13 @@ sub is_aligned { } return 0 if ! $spf_dom; - if ($spf_dom eq $from_host) { + if ($spf_dom eq $from_dom) { $self->adjust_karma(1); $self->log(LOGINFO, "pass, SPF aligned"); return 1; } 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->log(LOGINFO, "pass, SPF aligned, relaxed"); return 1; @@ -200,35 +186,16 @@ sub is_aligned { }; 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... - 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; - } - } + my @matches = $self->fetch_dmarc_record($from_dom, $org_dom) or 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"); + $self->log(LOGINFO, "skip, no valid record for $from_dom"); return; } @@ -280,7 +247,7 @@ sub has_valid_reporting_uri { } 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 # 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 # "example.com", "com" would be label 1 and "example" would be # 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 # 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 # 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 { - my ($self, $domain) = @_; + my ($self, $domain, $org_dom) = @_; # 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. -# I went back to the ADSP (from where DMARC this originated, which in turn -# led me to the ietf-dkim email list where a handful of 'experts' failed to -# agree on The Right Way to test domain validity. No direction was given. -# They point out: -# MX records aren't mandatory. -# A or AAAA records as fallback aren't reliable. - -# I chose to query the From: domain name and match NS,MX,A,or AAAA records. -# Since this search gets repeated for the Organizational Name, if it -# fails for the O.N., there's no delegation from the TLD. +# That's all the draft says. I went back to the DKIM ADSP (which led me to +# the ietf-dkim email list where some 'experts' failed to agree on The Right +# Way to test domain validity. Let alone deliverability. They point out: +# MX records aren't mandatory, and A|AAAA 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 +# is repeated for the Organizational Name, if the NS query fails, there's no +# delegation from the TLD. That's proven very reliable. my $res = $self->init_resolver(8); - return 1 if $self->host_has_rr('NS', $res, $domain); - return 1 if $self->host_has_rr('MX', $res, $domain); - return 1 if $self->host_has_rr('A', $res, $domain); - return 1 if $self->host_has_rr('AAAA', $res, $domain); + my @todo = $domain; + push @todo, $org_dom if $domain ne $org_dom; + foreach ( @todo ) { + 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 { @@ -370,12 +340,12 @@ sub host_has_rr { }; 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 # DNS domain matching the one found in the RFC5322.From domain in # 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 $query = $res->send('_dmarc.' . $zone, 'TXT'); my @matches; @@ -384,27 +354,43 @@ sub fetch_dmarc_record { # 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); - next if 'v=spf' eq substr($rr->txtdata, 0, 5); # commonly found + next if 'v=' ne lc substr($rr->txtdata, 0, 2); + next if 'v=spf' eq lc substr($rr->txtdata, 0, 5); # SPF commonly found $self->log(LOGINFO, $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; } -sub get_from_host { +sub get_from_dom { 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; + my ($from_dom) = (split /@/, $from)[-1]; # grab everything after the @ + ($from_dom) = split /\s+/, $from_dom; # remove any trailing cruft + chomp $from_dom; # remove \n + chop $from_dom if '>' eq substr($from_dom, -1, 1); # remove closing > + $self->log(LOGDEBUG, "info, from_dom is $from_dom"); + return $from_dom; } sub parse_policy {