| # <@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::SPF - perform SPF verification tests |
| |
| =head1 SYNOPSIS |
| |
| loadplugin Mail::SpamAssassin::Plugin::SPF |
| |
| =head1 DESCRIPTION |
| |
| This plugin checks a message against Sender Policy Framework (SPF) |
| records published by the domain owners in DNS to fight email address |
| forgery and make it easier to identify spams. |
| |
| =cut |
| |
| package Mail::SpamAssassin::Plugin::SPF; |
| |
| use Mail::SpamAssassin::Plugin; |
| use Mail::SpamAssassin::Logger; |
| use Mail::SpamAssassin::Timeout; |
| use strict; |
| use warnings; |
| use bytes; |
| |
| use vars qw(@ISA); |
| @ISA = qw(Mail::SpamAssassin::Plugin); |
| |
| # constructor: register the eval rule |
| sub new { |
| my $class = shift; |
| my $mailsaobject = shift; |
| |
| # some boilerplate... |
| $class = ref($class) || $class; |
| my $self = $class->SUPER::new($mailsaobject); |
| bless ($self, $class); |
| |
| my $conf = $mailsaobject->{conf}; |
| |
| $self->register_eval_rule ("check_for_spf_pass"); |
| $self->register_eval_rule ("check_for_spf_neutral"); |
| $self->register_eval_rule ("check_for_spf_fail"); |
| $self->register_eval_rule ("check_for_spf_softfail"); |
| $self->register_eval_rule ("check_for_spf_helo_pass"); |
| $self->register_eval_rule ("check_for_spf_helo_neutral"); |
| $self->register_eval_rule ("check_for_spf_helo_fail"); |
| $self->register_eval_rule ("check_for_spf_helo_softfail"); |
| $self->register_eval_rule ("check_for_spf_whitelist_from"); |
| $self->register_eval_rule ("check_for_def_spf_whitelist_from"); |
| |
| $self->set_config($mailsaobject->{conf}); |
| |
| return $self; |
| } |
| |
| ########################################################################### |
| |
| sub set_config { |
| my($self, $conf) = @_; |
| my @cmds = (); |
| |
| =head1 USER SETTINGS |
| |
| =over 4 |
| |
| =item spf_timeout n (default: 5) |
| |
| How many seconds to wait for an SPF query to complete, before scanning |
| continues without the SPF result. |
| |
| =cut |
| |
| push (@cmds, { |
| setting => 'spf_timeout', |
| default => 5, |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC |
| }); |
| |
| =item whitelist_from_spf add@ress.com |
| |
| Use this to supplement the whitelist_from addresses with a check against the |
| domain's SPF record. Aside from the name 'whitelist_from_spf', the syntax is |
| exactly the same as the syntax for 'whitelist_from'. |
| |
| Just like whitelist_from, multiple addresses per line, separated by spaces, |
| are OK. Multiple C<whitelist_from_spf> lines are also OK. |
| |
| The headers checked for whitelist_from_spf addresses are the same headers |
| used for SPF checks (Envelope-From, Return-Path, X-Envelope-From, etc). |
| |
| Since this whitelist requires an SPF check to be made network tests must be |
| enabled. It is also required that your trust path be correctly configured. |
| See the section on C<trusted_networks> for more info on trust paths. |
| |
| e.g. |
| |
| whitelist_from_spf joe@example.com fred@example.com |
| whitelist_from_spf *@example.com |
| |
| =item def_whitelist_from_spf add@ress.com |
| |
| Same as C<whitelist_from_spf>, 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_spf', |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST |
| }); |
| |
| push (@cmds, { |
| setting => 'def_whitelist_from_spf', |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_ADDRLIST |
| }); |
| |
| $conf->{parser}->register_commands(\@cmds); |
| } |
| |
| # SPF support |
| sub check_for_spf_pass { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 0) unless $scanner->{spf_checked}; |
| $scanner->{spf_pass}; |
| } |
| |
| sub check_for_spf_neutral { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 0) unless $scanner->{spf_checked}; |
| if ($scanner->{spf_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_failure_comment}); |
| } |
| $scanner->{spf_neutral}; |
| } |
| |
| sub check_for_spf_fail { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 0) unless $scanner->{spf_checked}; |
| if ($scanner->{spf_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_failure_comment}); |
| } |
| $scanner->{spf_fail}; |
| } |
| |
| sub check_for_spf_softfail { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 0) unless $scanner->{spf_checked}; |
| if ($scanner->{spf_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_failure_comment}); |
| } |
| $scanner->{spf_softfail}; |
| } |
| |
| sub check_for_spf_helo_pass { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 1) unless $scanner->{spf_helo_checked}; |
| $scanner->{spf_helo_pass}; |
| } |
| |
| sub check_for_spf_helo_neutral { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 1) unless $scanner->{spf_helo_checked}; |
| if ($scanner->{spf_helo_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_helo_failure_comment}); |
| } |
| $scanner->{spf_helo_neutral}; |
| } |
| |
| sub check_for_spf_helo_fail { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 1) unless $scanner->{spf_helo_checked}; |
| if ($scanner->{spf_helo_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_helo_failure_comment}); |
| } |
| $scanner->{spf_helo_fail}; |
| } |
| |
| sub check_for_spf_helo_softfail { |
| my ($self, $scanner) = @_; |
| $self->_check_spf ($scanner, 1) unless $scanner->{spf_helo_checked}; |
| if ($scanner->{spf_helo_failure_comment}) { |
| $scanner->test_log ($scanner->{spf_helo_failure_comment}); |
| } |
| $scanner->{spf_helo_softfail}; |
| } |
| |
| sub check_for_spf_whitelist_from { |
| my ($self, $scanner) = @_; |
| $self->_check_spf_whitelist($scanner) unless $scanner->{spf_whitelist_from_checked}; |
| $scanner->{spf_whitelist_from}; |
| } |
| |
| sub check_for_def_spf_whitelist_from { |
| my ($self, $scanner) = @_; |
| $self->_check_def_spf_whitelist($scanner) unless $scanner->{def_spf_whitelist_from_checked}; |
| $scanner->{def_spf_whitelist_from}; |
| } |
| |
| sub _check_spf { |
| my ($self, $scanner, $ishelo) = @_; |
| |
| return unless $scanner->is_dns_available(); |
| |
| # skip SPF checks if the A/MX records are nonexistent for the From |
| # domain, anyway, to avoid crappy messages from slowing us down |
| # (bug 3016) |
| return if $scanner->check_for_from_dns(); |
| |
| if ($ishelo) { |
| # SPF HELO-checking variant |
| $scanner->{spf_helo_checked} = 1; |
| $scanner->{spf_helo_pass} = 0; |
| $scanner->{spf_helo_neutral} = 0; |
| $scanner->{spf_helo_fail} = 0; |
| $scanner->{spf_helo_softfail} = 0; |
| $scanner->{spf_helo_failure_comment} = undef; |
| } else { |
| # SPF on envelope sender (where possible) |
| $scanner->{spf_checked} = 1; |
| $scanner->{spf_pass} = 0; |
| $scanner->{spf_neutral} = 0; |
| $scanner->{spf_fail} = 0; |
| $scanner->{spf_softfail} = 0; |
| $scanner->{spf_failure_comment} = undef; |
| } |
| |
| my $lasthop = $self->_get_relay($scanner); |
| if (!defined $lasthop) { |
| dbg("spf: no suitable relay for spf use found, skipping SPF". ($ishelo ? '-helo' : '') ." check"); |
| return; |
| } |
| |
| my $ip = $lasthop->{ip}; |
| my $helo = $lasthop->{helo}; |
| $scanner->{sender} = '' unless $scanner->{sender_got}; |
| |
| if ($ishelo) { |
| dbg("spf: checking HELO (helo=$helo, ip=$ip)"); |
| |
| } else { |
| $self->_get_sender($scanner) unless $scanner->{sender_got}; |
| |
| if (!$scanner->{sender}) { |
| # we already dbg'd that we couldn't get an Envelope-From and can't do SPF |
| return; |
| } |
| dbg("spf: checking EnvelopeFrom (helo=$helo, ip=$ip, envfrom=$scanner->{sender})"); |
| } |
| |
| # this test could probably stand to be more strict, but try to test |
| # any invalid HELO hostname formats with a header rule |
| if ($ishelo && ($helo =~ /^\d+\.\d+\.\d+\.\d+$/ || $helo =~ /^[^.]+$/)) { |
| dbg("spf: cannot check HELO of '$helo', skipping"); |
| return; |
| } |
| if (!$helo) { |
| dbg("spf: cannot get HELO, cannot use SPF"); |
| return; |
| } |
| |
| if ($scanner->server_failed_to_respond_for_domain($helo)) { |
| dbg("spf: we had a previous timeout on '$helo', skipping"); |
| return; |
| } |
| |
| my $query; |
| eval { |
| require Mail::SPF::Query; |
| if (!defined $Mail::SPF::Query::VERSION || $Mail::SPF::Query::VERSION < 1.996) { |
| die "spf: Mail::SPF::Query 1.996 or later required, this is $Mail::SPF::Query::VERSION\n"; |
| } |
| $query = Mail::SPF::Query->new (ip => $ip, |
| sender => $scanner->{sender}, |
| helo => $helo, |
| debug => 0, |
| trusted => 0); |
| }; |
| |
| if ($@) { |
| dbg("spf: cannot load or create Mail::SPF::Query module: $@"); |
| return; |
| } |
| |
| my ($result, $comment); |
| my $timeout = $scanner->{conf}->{spf_timeout}; |
| |
| my $timer = Mail::SpamAssassin::Timeout->new({ secs => $timeout }); |
| my $err = $timer->run_and_catch(sub { |
| |
| ($result, $comment) = $query->result(); |
| |
| }); |
| |
| if ($err) { |
| chomp $err; |
| warn("spf: lookup failed: $err\n"); |
| return 0; |
| } |
| |
| $result ||= 'timeout'; # bug 5077 |
| $comment ||= ''; |
| $comment =~ s/\s+/ /gs; # no newlines please |
| |
| if ($ishelo) { |
| if ($result eq 'pass') { $scanner->{spf_helo_pass} = 1; } |
| elsif ($result eq 'neutral') { $scanner->{spf_helo_neutral} = 1; } |
| elsif ($result eq 'fail') { $scanner->{spf_helo_fail} = 1; } |
| elsif ($result eq 'softfail') { $scanner->{spf_helo_softfail} = 1; } |
| |
| if ($result eq 'neutral' || $result eq 'fail' || $result eq 'softfail') { |
| $scanner->{spf_helo_failure_comment} = "SPF failed: $comment"; |
| } |
| } else { |
| if ($result eq 'pass') { $scanner->{spf_pass} = 1; } |
| elsif ($result eq 'neutral') { $scanner->{spf_neutral} = 1; } |
| elsif ($result eq 'fail') { $scanner->{spf_fail} = 1; } |
| elsif ($result eq 'softfail') { $scanner->{spf_softfail} = 1; } |
| |
| if ($result eq 'neutral' || $result eq 'fail' || $result eq 'softfail') { |
| $scanner->{spf_failure_comment} = "SPF failed: $comment"; |
| } |
| } |
| |
| dbg("spf: query for $scanner->{sender}/$ip/$helo: result: $result, comment: $comment"); |
| } |
| |
| sub _get_relay { |
| my ($self, $scanner) = @_; |
| |
| # dos: first external relay, not first untrusted |
| return $scanner->{relays_external}->[0]; |
| } |
| |
| sub _get_sender { |
| my ($self, $scanner) = @_; |
| my $sender; |
| |
| $scanner->{sender_got} = 1; |
| $scanner->{sender} = ''; |
| |
| my $relay = $self->_get_relay($scanner); |
| if (defined $relay) { |
| $sender = $relay->{envfrom}; |
| } |
| |
| if ($sender) { |
| dbg("spf: found Envelope-From in first external Received header"); |
| } |
| else { |
| # We cannot use the env-from data, since it went through 1 or more relays |
| # since the untrusted sender and they may have rewritten it. |
| if ($scanner->{num_relays_trusted} > 0 && !$scanner->{conf}->{always_trust_envelope_sender}) { |
| dbg("spf: relayed through one or more trusted relays, cannot use header-based Envelope-From, skipping"); |
| return; |
| } |
| |
| # we can (apparently) use whatever the current Envelope-From was, |
| # from the Return-Path, X-Envelope-From, or whatever header. |
| # it's better to get it from Received though, as that is updated |
| # hop-by-hop. |
| $sender = $scanner->get ("EnvelopeFrom"); |
| } |
| |
| if (!$sender) { |
| dbg("spf: cannot get Envelope-From, cannot use SPF"); |
| return; # avoid setting $scanner->{sender} to undef |
| } |
| |
| return $scanner->{sender} = lc $sender; |
| } |
| |
| sub _check_spf_whitelist { |
| my ($self, $scanner) = @_; |
| |
| return unless $scanner->is_dns_available(); |
| |
| $scanner->{spf_whitelist_from_checked} = 1; |
| $scanner->{spf_whitelist_from} = 0; |
| |
| $self->_get_sender($scanner) unless $scanner->{sender_got}; |
| |
| unless ($scanner->{sender}) { |
| dbg("spf: spf_whitelist_from: could not find useable envelope sender"); |
| return; |
| } |
| |
| if (defined ($scanner->{conf}->{whitelist_from_spf}->{$scanner->{sender}})) { |
| $scanner->{spf_whitelist_from} = 1; |
| } else { |
| study $scanner->{sender}; |
| foreach my $regexp (values %{$scanner->{conf}->{whitelist_from_spf}}) { |
| if ($scanner->{sender} =~ qr/$regexp/i) { |
| $scanner->{spf_whitelist_from} = 1; |
| last; |
| } |
| } |
| } |
| |
| # if the message doesn't pass SPF validation, it can't pass an SPF whitelist |
| if ($scanner->{spf_whitelist_from}) { |
| if ($self->check_for_spf_pass($scanner)) { |
| dbg("spf: whitelist_from_spf: $scanner->{sender} is in user's WHITELIST_FROM_SPF and passed SPF check"); |
| } else { |
| dbg("spf: whitelist_from_spf: $scanner->{sender} is in user's WHITELIST_FROM_SPF but failed SPF check"); |
| $scanner->{spf_whitelist_from} = 0; |
| } |
| } else { |
| dbg("spf: whitelist_from_spf: $scanner->{sender} is not in user's WHITELIST_FROM_SPF"); |
| } |
| } |
| |
| sub _check_def_spf_whitelist { |
| my ($self, $scanner) = @_; |
| |
| return unless $scanner->is_dns_available(); |
| |
| $scanner->{def_spf_whitelist_from_checked} = 1; |
| $scanner->{def_spf_whitelist_from} = 0; |
| |
| $self->_get_sender($scanner) unless $scanner->{sender_got}; |
| |
| unless ($scanner->{sender}) { |
| dbg("spf: def_spf_whitelist_from: could not find useable envelope sender"); |
| return; |
| } |
| |
| if (defined ($scanner->{conf}->{def_whitelist_from_spf}->{$scanner->{sender}})) { |
| $scanner->{def_spf_whitelist_from} = 1; |
| } else { |
| study $scanner->{sender}; |
| foreach my $regexp (values %{$scanner->{conf}->{def_whitelist_from_spf}}) { |
| if ($scanner->{sender} =~ qr/$regexp/i) { |
| $scanner->{def_spf_whitelist_from} = 1; |
| last; |
| } |
| } |
| } |
| |
| # if the message doesn't pass SPF validation, it can't pass an SPF whitelist |
| if ($scanner->{def_spf_whitelist_from}) { |
| if ($self->check_for_spf_pass($scanner)) { |
| dbg("spf: def_whitelist_from_spf: $scanner->{sender} is in DEF_WHITELIST_FROM_SPF and passed SPF check"); |
| } else { |
| dbg("spf: def_whitelist_from_spf: $scanner->{sender} is in DEF_WHITELIST_FROM_SPF but failed SPF check"); |
| $scanner->{def_spf_whitelist_from} = 0; |
| } |
| } else { |
| dbg("spf: def_whitelist_from_spf: $scanner->{sender} is not in DEF_WHITELIST_FROM_SPF"); |
| } |
| } |
| |
| ########################################################################### |
| |
| 1; |
| |
| =back |
| |
| =cut |