# <@LICENSE> # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to you under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at: # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # =head1 NAME Mail::SpamAssassin::Plugin::DomainKeys - perform DomainKeys verification tests =head1 SYNOPSIS loadplugin Mail::SpamAssassin::Plugin::DomainKeys [/path/to/DomainKeys.pm] Signature: header DK_SIGNED eval:check_domainkeys_signed() header DK_VERIFIED eval:check_domainkeys_verified() Policy: Note that DK policy record is only fetched if DK_VERIFIED is false to save signing domain from unnecessary DNS queries, as recommended (SHOULD) by draft-delany-domainkeys-base. Rules DK_POLICY_* should preferably not be relied upon when DK_VERIFIED is true, although they will return false in current implementation when a policy record is not fetched, except for DK_POLICY_TESTING, which is true if t=y appears in a public key record OR in a policy record (when available). header DK_POLICY_TESTING eval:check_domainkeys_testing() header DK_POLICY_SIGNSOME eval:check_domainkeys_signsome() header DK_POLICY_SIGNALL eval:check_domainkeys_signall() Whitelisting based on verified signature: header USER_IN_DK_WHITELIST eval:check_for_dk_whitelist_from() header USER_IN_DEF_DK_WL eval:check_for_def_dk_whitelist_from() =head1 DESCRIPTION This is the DomainKeys plugin and it needs lots more documentation. =cut package Mail::SpamAssassin::Plugin::DomainKeys; use Mail::SpamAssassin::Plugin; use Mail::SpamAssassin::Logger; use Mail::SpamAssassin::Timeout; use strict; use warnings; use bytes; # Have to do this so that RPM doesn't find these as required perl modules BEGIN { require Mail::DomainKeys::Message; require Mail::DomainKeys::Policy; } use vars qw(@ISA); @ISA = qw(Mail::SpamAssassin::Plugin); # constructor: register the eval rule sub new { my $class = shift; my $mailsaobject = shift; $class = ref($class) || $class; my $self = $class->SUPER::new($mailsaobject); bless ($self, $class); $self->register_eval_rule ("check_domainkeys_signed"); $self->register_eval_rule ("check_domainkeys_verified"); $self->register_eval_rule ("check_domainkeys_signsome"); $self->register_eval_rule ("check_domainkeys_testing"); $self->register_eval_rule ("check_domainkeys_signall"); $self->register_eval_rule ("check_for_dk_whitelist_from"); $self->register_eval_rule ("check_for_def_dk_whitelist_from"); $self->set_config($mailsaobject->{conf}); return $self; } ########################################################################### sub set_config { my($self, $conf) = @_; my @cmds = (); =head1 USER SETTINGS =over 4 =item domainkeys_timeout n (default: 5) How many seconds to wait for a DomainKeys query to complete, before scanning continues without the DomainKeys result. =cut push (@cmds, { setting => 'domainkeys_timeout', default => 5, type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC }); =item whitelist_from_dk add@ress.com [signing domain name] Use this to supplement the whitelist_from addresses with a check to make sure the message has been signed by a DomainKeys signature that can be verified against the From: domain's DomainKeys public key. In order to support signing domain names that differ from the address domain name, only one whitelist entry is allowed per line, exactly like C. Multiple C lines are allowed. File-glob style meta characters are allowed for the From: address, just like with C. The optional signing domain name parameter must match from the right-most side, also like in C. If no signing domain name parameter is specified the domain of the address parameter specified will be used instead. The From: address is obtained from a signed part of the message (ie. the "From:" header), not from envelope data that is possible to forge. Since this whitelist requires a DomainKeys check to be made, network tests must be enabled. Examples: whitelist_from_dk joe@example.com whitelist_from_dk *@corp.example.com whitelist_from_dk bob@it.example.net example.net whitelist_from_dk *@eng.example.net example.net =item def_whitelist_from_dk add@ress.com [signing domain name] Same as C, but used for the default whitelist entries in the SpamAssassin distribution. The whitelist score is lower, because these are often targets for spammer spoofing. =cut push (@cmds, { setting => 'whitelist_from_dk', code => sub { my ($self, $key, $value, $line) = @_; unless (defined $value && $value !~ /^$/) { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } unless ($value =~ /^(\S+)(?:\s+(\S+))?$/) { return $Mail::SpamAssassin::Conf::INVALID_VALUE; } my $address = $1; my $signer = (defined $2 ? $2 : $1); unless (defined $2) { $signer =~ s/^.*@(.*)$/$1/; } $self->{parser}->add_to_addrlist_rcvd ('whitelist_from_dk', $address, $signer); } }); push (@cmds, { setting => 'def_whitelist_from_dk',, code => sub { my ($self, $key, $value, $line) = @_; unless (defined $value && $value !~ /^$/) { return $Mail::SpamAssassin::Conf::MISSING_REQUIRED_VALUE; } unless ($value =~ /^(\S+)(?:\s+(\S+))?$/) { return $Mail::SpamAssassin::Conf::INVALID_VALUE; } my $address = $1; my $signer = (defined $2 ? $2 : $1); unless (defined $2) { $signer =~ s/^.*@(.*)$/$1/; } $self->{parser}->add_to_addrlist_rcvd ('def_whitelist_from_dk', $address, $signer); } }); $conf->{parser}->register_commands(\@cmds); } sub check_domainkeys_signed { my ($self, $scan) = @_; $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked}; return $scan->{domainkeys_signed}; } sub check_domainkeys_verified { my ($self, $scan) = @_; $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked}; return $scan->{domainkeys_verified}; } sub check_domainkeys_signsome { my ($self, $scan) = @_; $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked}; return $scan->{domainkeys_signsome}; } sub check_domainkeys_testing { my ($self, $scan) = @_; $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked}; return $scan->{domainkeys_testing}; } sub check_domainkeys_signall { my ($self, $scan) = @_; $self->_check_domainkeys($scan) unless $scan->{domainkeys_checked}; return $scan->{domainkeys_signall}; } sub check_for_dk_whitelist_from { my ($self, $scan) = @_; $self->_check_dk_whitelist($scan, 0) unless $scan->{dk_whitelist_from_checked}; $scan->{dk_whitelist_from}; } sub check_for_def_dk_whitelist_from { my ($self, $scan) = @_; $self->_check_dk_whitelist($scan, 1) unless $scan->{def_dk_whitelist_from_checked}; $scan->{def_dk_whitelist_from}; } # --------------------------------------------------------------------------- sub _check_domainkeys { my ($self, $scan) = @_; $scan->{domainkeys_checked} = 0; $scan->{domainkeys_signed} = 0; $scan->{domainkeys_verified} = 0; $scan->{domainkeys_signsome} = 0; $scan->{domainkeys_testing} = 0; $scan->{domainkeys_signall} = 0; my $header = $scan->{msg}->get_pristine_header(); my $body = $scan->{msg}->get_body(); my $dksighdr = $scan->{msg}->get_header("DomainKey-Signature"); dbg("dk: signature: $dksighdr") if defined $dksighdr; $self->sanitize_header_for_dk(\$header) if defined $dksighdr && $dksighdr !~ /(?:^|;)[ \t]*h=/; # case sensitive my $message = Mail::DomainKeys::Message->load(HeadString => $header, BodyReference => $body); if (!$message) { dbg("dk: cannot load message using Mail::DomainKeys::Message"); return; } $scan->{domainkeys_checked} = 1; # does a sender domain header exist? my $domain = $message->senderdomain(); if (!$domain) { dbg("dk: no sender domain"); return; } # get the sender address for whitelist checks if (defined $message->sender()) { $scan->{dk_address} = @{$message->sender()}[1]; dbg("dk: sender: $scan->{dk_address}"); } elsif (defined $message->from()) { $scan->{dk_address} ||= @{$message->from()}[1]; dbg("dk: from: $scan->{dk_address}"); } else { dbg("dk: could not determine sender: or from: identity"); } # get the signing domain name for whitelist checks $scan->{dk_signing_domain} = $self->_dkmsg_signing_domain($scan, $message); dbg("dk: signing domain name: ". ($scan->{dk_signing_domain} ? $scan->{dk_signing_domain} : "not found")); my $timeout = $scan->{conf}->{domainkeys_timeout}; my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout }); my $err = $timer->run_and_catch(sub { $self->_dk_lookup_trapped($scan, $message, $domain); }); if ($timer->timed_out()) { dbg("dk: lookup timed out after $timeout seconds"); return 0; } if ($err) { chomp $err; warn("dk: lookup failed: $err\n"); return 0; } my $comment = $self->_dkmsg_hdr($message); $comment ||= ''; $comment =~ s/\s+/ /gs; # no newlines please $scan->{dk_comment} = "DomainKeys status: $comment"; } # perform DK lookups. This method is trapped within a timeout alarm() scope sub _dk_lookup_trapped { my ($self, $scan, $message, $domain) = @_; # verified if ($message->signed()) { $scan->{domainkeys_signed} = 1; if ($message->verify()) { $scan->{domainkeys_verified} = 1; } } # testing flag in signature if ($message->testing()) { $scan->{domainkeys_testing} = 1; dbg("dk: testing flag found in signature"); } my $policy; if (!$scan->{domainkeys_verified}) { # Recipient systems SHOULD not retrieve a policy TXT record # for email that successfully verifies. $policy = Mail::DomainKeys::Policy->fetch(Protocol => 'dns', Domain => $domain); my($fetched_policy) = $policy ? $policy->as_string : 'NONE'; $fetched_policy = '' if !defined $fetched_policy; dbg ("dk: fetched policy for domain $domain: $fetched_policy"); } return unless $policy; # not signed and domain doesn't sign all if ($policy->signsome()) { $scan->{domainkeys_signsome} = 1; } # testing flag in policy if ($policy->testing()) { $scan->{domainkeys_testing} = 1; } # does policy require all mail to be signed if ($policy->signall()) { $scan->{domainkeys_signall} = 1; } my $comment = $self->_dkmsg_hdr($message); dbg("dk: comment is '$comment'"); } # get the DK status "header" from the Mail::DomainKeys::Message object sub _dkmsg_hdr { my ($self, $message) = @_; # try to use the signature() API if it exists (post-0.80) if ($message->can("signature")) { my($sts,$msg); if (!$message->signed) { $sts = "no signature"; } else { $sts = $message->signature->status; $msg = $message->signature->errorstr; } dbg("dk: $sts" . (defined $msg ? " ($msg)" : '')); return $sts; } else { return $message->header->value; } } # get the DK signing domain name from the Mail::DomainKeys::Message object sub _dkmsg_signing_domain { my ($self, $scan, $message) = @_; # try to use the signature() API if it exists (post-0.80) if ($message->can("signature")) { if (!$message->signed) { return undef; } return $message->signature->domain; } else { # otherwise parse it ourself if ($scan->{msg}->get_header("DomainKey-Signature") =~ /(?: ^|; ) [ \t]* d= [ \t]* ([^;]*?) [ \t]* (?: ;|$ )/x) { return $1; } return undef; } } sub sanitize_header_for_dk { my ($self, $ref) = @_; dbg("dk: sanitizing header, no \"h\" tag in signature"); # remove folding, in a HTML-escape data-preserving style, so we can # strip headers easily $$ref =~ s/!/!ex;/gs; $$ref =~ s/\n([ \t])/!nl;$1/gs; my @hdrs = split(/^/m, $$ref); while (scalar @hdrs > 0) { my $last = pop @hdrs; next if ($last =~ /^\r?$/); # List all the known appended headers that may break a DK signature. Things # to note: # # 1. only *appended* headers should be listed; prepended additions are fine. # 2. some virus-scanner headers may be better left out, since there are ISPs # who scan for viruses before the message leaves their SMTP relay; this is # not quite decided. # # TODO: there's probably loads more, and this should be user-configurable if ($last =~ /^ (?: # SpamAssassin additions, remove these so that mass-check works X-Spam-\S+ # other spam filters |X-MailScanner(?:-SpamCheck)? |X-Pyzor |X-DCC-\S{2,25}-Metrics |X-Bogosity # post-delivery MUA additions |X-Evolution |X-MH-Thread-Markup # IMAP or POP additions |X-Keywords |(?:X-)?Status |X-Flags |Replied |Forwarded |Lines |Content-Length |X-UIDL? |X-IMAPbase # MTA delivery control headers |X-MDaemon-Deliver-To # other MUAs: VM and Gnus |X-VM-(?:Bookmark|(?:POP|IMAP)-Retrieved|Labels|Last-Modified |Summary-Format|VHeader|v\d-Data|Message-Order) |X-Gnus-Mail-Source |Xref ):/ix) { $last =~ /^([^:]+):/; dbg("dk: ignoring header '$1'"); next; } push (@hdrs, $last); last; } $$ref = join("", @hdrs); # and return the remaining headers to pristine condition # $$ref =~ s/^\n//gs; $$ref =~ s/\n$//gs; $$ref =~ s/!nl;/\n/gs; $$ref =~ s/!ex;/!/gs; } sub _check_dk_whitelist { my ($self, $scan, $default) = @_; return unless $scan->is_dns_available(); # trigger a DK check so we can get address/signer info # if verification failed only continue if we want the debug info unless ($self->check_domainkeys_verified($scan)) { unless (would_log("dbg", "dk")) { return; } } unless ($scan->{dk_address}) { dbg("dk: ". ($default ? "def_" : "") ."whitelist_from_dk: could not find sender or from address"); return; } unless ($scan->{dk_signing_domain}) { dbg("dk: ". ($default ? "def_" : "") ."whitelist_from_dk: could not find signing domain name"); return; } if ($default) { $scan->{def_dk_whitelist_from_checked} = 1; $scan->{def_dk_whitelist_from} = 0; # copied and butchered from the code for whitelist_from_rcvd in Evaltests.pm ONE: foreach my $white_addr (keys %{$scan->{conf}->{def_whitelist_from_dk}}) { my $regexp = qr/$scan->{conf}->{def_whitelist_from_dk}->{$white_addr}{re}/i; foreach my $domain (@{$scan->{conf}->{def_whitelist_from_dk}->{$white_addr}{domain}}) { if ($scan->{dk_address} =~ $regexp) { if ($scan->{dk_signing_domain} =~ /(?:^|\.)\Q${domain}\E$/i) { dbg("dk: address: $scan->{dk_address} matches def_whitelist_from_dk ". "$scan->{conf}->{def_whitelist_from_dk}->{$white_addr}{re} ${domain}"); $scan->{def_dk_whitelist_from} = 1; last ONE; } } } } } else { $scan->{dk_whitelist_from_checked} = 1; $scan->{dk_whitelist_from} = 0; # copied and butchered from the code for whitelist_from_rcvd in Evaltests.pm ONE: foreach my $white_addr (keys %{$scan->{conf}->{whitelist_from_dk}}) { my $regexp = qr/$scan->{conf}->{whitelist_from_dk}->{$white_addr}{re}/i; foreach my $domain (@{$scan->{conf}->{whitelist_from_dk}->{$white_addr}{domain}}) { if ($scan->{dk_address} =~ $regexp) { if ($scan->{dk_signing_domain} =~ /(?:^|\.)\Q${domain}\E$/i) { dbg("dk: address: $scan->{dk_address} matches whitelist_from_dk ". "$scan->{conf}->{whitelist_from_dk}->{$white_addr}{re} ${domain}"); $scan->{dk_whitelist_from} = 1; last ONE; } } } } } # if the message doesn't pass DK validation, it can't pass a DK whitelist if ($default) { if ($scan->{def_dk_whitelist_from}) { if ($self->check_domainkeys_verified($scan)) { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is in user's DEF_WHITELIST_FROM_DK and ". "passed DK verification"); } else { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is in user's DEF_WHITELIST_FROM_DK but ". "failed DK verification"); $scan->{def_dk_whitelist_from} = 0; } } else { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is not in user's DEF_WHITELIST_FROM_DK"); } } else { if ($scan->{dk_whitelist_from}) { if ($self->check_domainkeys_verified($scan)) { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is in user's WHITELIST_FROM_DK and ". "passed DK verification"); } else { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is in user's WHITELIST_FROM_DK but ". "failed DK verification"); $scan->{dk_whitelist_from} = 0; } } else { dbg("dk: address: $scan->{dk_address} signing domain name: ". "$scan->{dk_signing_domain} is not in user's WHITELIST_FROM_DK"); } } } 1;