blob: dec7af58f07c50a3f4cc899082b454e8b38e4588 [file] [log] [blame]
# <@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.
# </@LICENSE>
=head1 NAME
Mail::SpamAssassin::Plugin::DKIM - perform DKIM verification tests
=head1 SYNOPSIS
loadplugin Mail::SpamAssassin::Plugin::DKIM [/path/to/DKIM.pm]
full DOMAINKEY_DOMAIN eval:check_dkim_verified()
=head1 DESCRIPTION
This SpamAssassin plugin implements DKIM lookups, as described by the current
draft specs:
http://mipassoc.org/dkim/specs/draft-allman-dkim-base-01.txt
http://mipassoc.org/mass/specs/draft-allman-dkim-base-00-10dc.html
It requires the C<Mail::DKIM> CPAN module to operate. Many thanks to Jason Long
for that module.
=head1 SEE ALSO
C<Mail::DKIM>, C<Mail::SpamAssassin::Plugin>
http://jason.long.name/dkimproxy/
=cut
package Mail::SpamAssassin::Plugin::DKIM;
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.
# Crypt::OpenSSL::Bignum included here, since Mail::DKIM loads it in some
# situations at runtime and spews messy errors if it's not there.
BEGIN { require Mail::DKIM; require Mail::DKIM::Verifier; require Crypt::OpenSSL::Bignum; }
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_dkim_signed");
$self->register_eval_rule ("check_dkim_verified");
$self->register_eval_rule ("check_dkim_signsome");
$self->register_eval_rule ("check_dkim_testing");
$self->register_eval_rule ("check_dkim_signall");
$self->register_eval_rule ("check_for_dkim_whitelist_from");
$self->register_eval_rule ("check_for_def_dkim_whitelist_from");
$self->set_config($mailsaobject->{conf});
return $self;
}
###########################################################################
sub set_config {
my($self, $conf) = @_;
my @cmds = ();
=head1 USER SETTINGS
=over 4
=item dkim_timeout n (default: 5)
How many seconds to wait for a DKIM query to complete, before
scanning continues without the DKIM result.
=cut
push (@cmds, {
setting => 'dkim_timeout',
default => 5,
type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC
});
=item whitelist_from_dkim add@ress.com [identity]
Use this to supplement the whitelist_from addresses with a check to make sure
the message has been signed by a Domain Keys Identified Mail (DKIM) signature
that can be verified against the From: domain's DKIM public key.
In order to support optional identities, only one whitelist entry is allowed
per line, exactly like C<whitelist_from_rcvd>. Multiple C<whitelist_from_dkim>
lines are allowed. File-glob style meta characters are allowed for the From:
address, just like with C<whitelist_from_rcvd>. The optional identity
parameter must match from the right-most side, also like in
C<whitelist_from_rcvd>.
If no identity 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 an DKIM check to be made, network tests must be
enabled.
Examples:
whitelist_from_dkim joe@example.com
whitelist_from_dkim *@corp.example.com
whitelist_from_dkim jane@example.net example.org
whitelist_from_dkim dick@example.net richard@example.net
=item def_whitelist_from_dkim add@ress.com [identity]
Same as C<whitelist_from_dkim>, 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_dkim',
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 $identity = (defined $2 ? $2 : $1);
unless (defined $2) {
$identity =~ s/^.*(@.*)$/$1/;
}
$self->{parser}->add_to_addrlist_rcvd ('whitelist_from_dkim',
$address, $identity);
}
});
push (@cmds, {
setting => 'def_whitelist_from_dkim',,
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 $identity = (defined $2 ? $2 : $1);
unless (defined $2) {
$identity =~ s/^.*(@.*)$/$1/;
}
$self->{parser}->add_to_addrlist_rcvd ('def_whitelist_from_dkim',
$address, $identity);
}
});
$conf->{parser}->register_commands(\@cmds);
}
# ---------------------------------------------------------------------------
sub check_dkim_signed {
my ($self, $scan) = @_;
$self->_check_dkim($scan) unless $scan->{dkim_checked};
return $scan->{dkim_signed};
}
sub check_dkim_verified {
my ($self, $scan) = @_;
$self->_check_dkim($scan) unless $scan->{dkim_checked};
return $scan->{dkim_verified};
}
sub check_dkim_signsome {
my ($self, $scan) = @_;
$self->_check_dkim($scan) unless $scan->{dkim_checked};
return $scan->{dkim_signsome};
}
sub check_dkim_testing {
my ($self, $scan) = @_;
$self->_check_dkim($scan) unless $scan->{dkim_checked};
return $scan->{dkim_testing};
}
sub check_dkim_signall {
my ($self, $scan) = @_;
$self->_check_dkim($scan) unless $scan->{dkim_checked};
return $scan->{dkim_signall};
}
sub check_for_dkim_whitelist_from {
my ($self, $scanner) = @_;
$self->_check_dkim_whitelist($scanner, 0) unless $scanner->{dkim_whitelist_from_checked};
$scanner->{dkim_whitelist_from};
}
sub check_for_def_dkim_whitelist_from {
my ($self, $scanner) = @_;
$self->_check_dkim_whitelist($scanner, 1) unless $scanner->{def_dkim_whitelist_from_checked};
$scanner->{def_dkim_whitelist_from};
}
# ---------------------------------------------------------------------------
sub _check_dkim {
my ($self, $scan) = @_;
$scan->{dkim_checked} = 1;
$scan->{dkim_signed} = 0;
$scan->{dkim_verified} = 0;
$scan->{dkim_signsome} = 0;
$scan->{dkim_testing} = 0;
$scan->{dkim_signall} = 0;
my $header = $scan->{msg}->get_pristine_header();
my $body = $scan->{msg}->get_body();
my $message = Mail::DKIM::Verifier->new_object();
if (!$message) {
dbg("dkim: cannot create Mail::DKIM::Verifier");
return;
}
# headers, line-by-line with \r\n endings, as per Mail::DKIM API
foreach my $line (split(/\r?\n/s, $header)) { # split lines, deleting endings and final empty line
$line =~ s/$/\r\n/s; # add back a standard \r\n ending
$message->PRINT($line);
}
$message->PRINT("\r\n");
# body, line-by-line with \r\n endings.
eval {
foreach my $line (@{$body}) {
$line =~ s/\r?\n$/\r\n/s; # ensure \r\n ending
$message->PRINT($line);
}
};
if ($@) { # intercept die() exceptions and render safe
dbg ("dkim: verification failed, intercepted error: $@");
return 0; # cannot verify message
}
my $timeout = $scan->{conf}->{dkim_timeout};
my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout });
my $err = $timer->run_and_catch(sub {
dbg("dkim: performing lookup");
$message->CLOSE(); # the action happens here
$scan->{dkim_address} = ($message->message_originator ? $message->message_originator->address() : '');
$scan->{dkim_identity} = ($message->signature ? $message->signature->identity() : '');
dbg("dkim: originator address: ".($scan->{dkim_address} ? $scan->{dkim_address} : 'none'));
dbg("dkim: signature identity: ".($scan->{dkim_identity} ? $scan->{dkim_identity} : 'none'));
my $result = $message->result();
my $detail = $message->result_detail();
dbg("dkim: result: $detail");
my $policy;
if ($message->message_originator && $message->message_originator->host) {
# both of these must be populated for DKIM to look up the policy
$policy = $message->fetch_author_policy();
}
if ($policy) {
# TODO - required? (for $policy_result, see perldoc Mail::DKIM::Policy)
my $policy_result = $policy->apply($message);
dbg("dkim: policy result $policy_result: ".$policy->as_string());
# extract the flags we expose, from the policy
my $pol_o = $policy->policy();
if ($pol_o eq '~') {
$scan->{dkim_signsome} = 1;
}
elsif ($pol_o eq '-') {
$scan->{dkim_signall} = 1;
}
if ($policy->testing()) {
$scan->{dkim_testing} = 1;
}
}
else {
dbg("dkim: policy: none");
}
# and now extract the actual lookup results
if ($result eq 'pass') {
$scan->{dkim_signed} = 1;
$scan->{dkim_verified} = 1;
}
elsif ($result eq 'fail') {
$scan->{dkim_signed} = 1;
}
elsif ($result eq 'none') {
# no-op, this is the default state
}
elsif ($result eq 'invalid') {
# 'Returned if no valid DKIM-Signature headers were found, but there is
# at least one invalid DKIM-Signature header. For a reason why a DKIM-
# Signature header found in the message was invalid, see
# $dkim->{signature_reject_reason}.'
warn("dkim: invalid DKIM-Signature: $detail");
}
});
if ($timer->timed_out()) {
dbg("dkim: lookup timed out after $timeout seconds");
return 0;
}
if ($err) {
chomp $err;
warn("dkim: lookup failed: $err\n");
return 0;
}
}
sub _check_dkim_whitelist {
my ($self, $scanner, $default) = @_;
return unless $scanner->is_dns_available();
# trigger an DKIM check so we can get address/identity info
# if verification failed only continue if we want the debug info
unless ($self->check_dkim_verified($scanner)) {
unless (would_log("dbg", "dkim")) {
return;
}
}
unless ($scanner->{dkim_address}) {
dbg("dkim: ". ($default ? "def_" : "") ."whitelist_from_dkim: could not find originator address");
return;
}
unless ($scanner->{dkim_identity}) {
dbg("dkim: ". ($default ? "def_" : "") ."whitelist_from_dkim: could not find identity");
return;
}
if ($default) {
$scanner->{def_dkim_whitelist_from_checked} = 1;
$scanner->{def_dkim_whitelist_from} = 0;
# copied and butchered from the code for whitelist_from_rcvd in Evaltests.pm
ONE: foreach my $white_addr (keys %{$scanner->{conf}->{def_whitelist_from_dkim}}) {
my $regexp = qr/$scanner->{conf}->{def_whitelist_from_dkim}->{$white_addr}{re}/i;
foreach my $domain (@{$scanner->{conf}->{def_whitelist_from_dkim}->{$white_addr}{domain}}) {
if ($scanner->{dkim_address} =~ $regexp) {
if ($scanner->{dkim_identity} =~ /(?:^|\.|(?:@(?!@)|(?=@)))\Q${domain}\E$/i) {
dbg("dkim: address: $scanner->{dkim_address} matches def_whitelist_from_dkim ".
"$scanner->{conf}->{def_whitelist_from_dkim}->{$white_addr}{re} ${domain}");
$scanner->{def_dkim_whitelist_from} = 1;
last ONE;
}
}
}
}
} else {
$scanner->{dkim_whitelist_from_checked} = 1;
$scanner->{dkim_whitelist_from} = 0;
# copied and butchered from the code for whitelist_from_rcvd in Evaltests.pm
ONE: foreach my $white_addr (keys %{$scanner->{conf}->{whitelist_from_dkim}}) {
my $regexp = qr/$scanner->{conf}->{whitelist_from_dkim}->{$white_addr}{re}/i;
foreach my $domain (@{$scanner->{conf}->{whitelist_from_dkim}->{$white_addr}{domain}}) {
if ($scanner->{dkim_address} =~ $regexp) {
if ($scanner->{dkim_identity} =~ /(?:^|\.|(?:@(?!@)|(?=@)))\Q${domain}\E$/i) {
dbg("dkim: address: $scanner->{dkim_address} matches whitelist_from_dkim ".
"$scanner->{conf}->{whitelist_from_dkim}->{$white_addr}{re} ${domain}");
$scanner->{dkim_whitelist_from} = 1;
last ONE;
}
}
}
}
}
# if the message doesn't pass DKIM validation, it can't pass an DKIM whitelist
if ($default) {
if ($scanner->{def_dkim_whitelist_from}) {
if ($self->check_dkim_verified($scanner)) {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is in user's DEF_WHITELIST_FROM_DKIM and ".
"passed DKIM verification");
} else {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is in user's DEF_WHITELIST_FROM_DKIM but ".
"failed DKIM verification");
$scanner->{def_dkim_whitelist_from} = 0;
}
} else {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is not in user's DEF_WHITELIST_FROM_DKIM");
}
} else {
if ($scanner->{dkim_whitelist_from}) {
if ($self->check_dkim_verified($scanner)) {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is in user's WHITELIST_FROM_DKIM and ".
"passed DKIM verification");
} else {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is in user's WHITELIST_FROM_DKIM but ".
"failed DKIM verification");
$scanner->{dkim_whitelist_from} = 0;
}
} else {
dbg("dkim: address: $scanner->{dkim_address} identity: ".
"$scanner->{dkim_identity} is not in user's WHITELIST_FROM_DKIM");
}
}
}
1;