diff --git a/CHANGES b/CHANGES index a4262b4..c260f5b 100644 --- a/CHANGES +++ b/CHANGES @@ -4,13 +4,26 @@ # ! = Changed something significant, or removed a feature # * = Fixed a bug, or made a minor improvement +--- 2.011 2018-07-29 + * Added mention of the requirement for Sys::Syslog to INSTALL + * Add references to the current upstream location on Launchpad + + Add option to skip SPF checks on exempt domains based on /etc/postfix/ + exempt_spf_domains (Thanks to Scott Savarese for the patch) + ! Switch relay_addresses to use /etc/postfix/exempt_spf_addresses instead + of hand editing the code (Thanks to Scott Savarese for pushing this over + the finish line) (Debian #902801) + + Add config.sh (see README for details) to support different postfix + configuration directories + * Change domain back to openspf.org, it has been back for a long time + (Debian #900512) + --- 2.010 2012-06-17 - * Fixed incorrect use of != instead of ne for string comparison - (LP: #1014243) - ! Changed non-standard X-Comment header fields for localhost and - whitelisted addresses to use RFC 5451 Authentication Results header - fields - ! Added depenency on Sys::Hostname::Long for local hostname determination + * Fixed incorrect use of != instead of ne for string comparison + (LP: #1014243) + ! Changed non-standard X-Comment header fields for localhost and + whitelisted addresses to use RFC 5451 Authentication Results header + fields + ! Added depenency on Sys::Hostname::Long for local hostname determination --- 2.009 2012-02-03 * Chomp erroneus NULLs off the end of local and authority explanations to diff --git a/INSTALL b/INSTALL index 3e13787..01911c7 100644 --- a/INSTALL +++ b/INSTALL @@ -9,9 +9,12 @@ postfix-policyd-spf-perl: NetAddr-IP 4 Mail::SPF (not Mail-SPF-Query) version 2.006 or later Sys::Hostname::Long + Sys::Syslog Installing ---------- + 0. If your postfix config directory is not /etc/postifx, see the README for + additional instructions and adjust the path in step 2 accordingly. 1. Copy postfix-policyd-spf-perl to /usr/local/lib/policyd-spf-perl diff --git a/README b/README index 9d8fc13..17c334a 100644 --- a/README +++ b/README @@ -5,7 +5,8 @@ A Postfix SMTPd policy server for SPF checking (C) 2007 Julian Mehnle (C) 2003-2004 Meng Weng Wong Thanks for contributions by various members of the SPF project - + + ============================================================================== postfix-policyd-spf-perl is a Postfix SMTPd policy server for SPF checking. @@ -30,10 +31,26 @@ Mail From None even if HELO is Pass). The policy server skips SPF checks for connections from the localhost (127.) and instead prepends and logs 'SPF skipped - localhost is always allowed.' If you -have relays that you want to skip SPF checks for, you can add them to -relay_addresses on line 78 using standard CIDR notation in a space separated -list. For these addresses, 'X-Comment: SPF skipped for whitelisted relay' is -prepended and logged. IPv6 localhost is also skipped. +have relays that you want to skip SPF checks for, create a configuration file, +/etc/postfix/exempt_spf_addresses and add them on one using standard CIDR +notation in a space separated list. For these addresses, 'X-Comment: SPF +skipped for whitelisted relay' is prepended and logged. IPv6 localhost is also +skipped. + +A configuration file, /etc/postfix/exempt_spf_domains, can be used to +ignore domains that have broken SPF configurations that would normally +fail. For those domains, add the domain to the file (one per line), and +restart postfix so that the policy server can reload its configuration. +The policy server will ignore the domain going forward. + +If defined, the configuration files described above need to have permissions +to allow the policy server to read the files. + +The standard build for the policy server assumes that the postfix config file +directory is /etc/postfix. If this is not correct for your operating systemn, +run the provided config.sh file from the package directory and it will update +the config file directory based on the output of postconf -h config_directory. +This needs to be done before package installation. Error conditions within the policy server (that don't result in a crash) or from Mail::SPF will return DUNNO. diff --git a/config.sh b/config.sh new file mode 100755 index 0000000..9a39b5d --- /dev/null +++ b/config.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e + +CDIR=$(postconf -h config_directory) +echo "Config directory: ${CDIR}" +sed -e 's|@@CDIR@@|'"${CDIR}"'|g' postfix-policyd-spf-perl.in > postfix-policyd-spf-perl diff --git a/postfix-policyd-spf-perl b/postfix-policyd-spf-perl index 06c9a51..322111e 100755 --- a/postfix-policyd-spf-perl +++ b/postfix-policyd-spf-perl @@ -1,13 +1,15 @@ #!/usr/bin/perl # postfix-policyd-spf-perl +# https://launchpad.net/postfix-policyd-spf-perl # http://www.openspf.org/Software -# version 2.010 +# version 2.011 # -# (C) 2007-2008,2012 Scott Kitterman -# (C) 2012 Allison Randal -# (C) 2007 Julian Mehnle -# (C) 2003-2004 Meng Weng Wong +# (C) 2007-2008,2012,2018 Scott Kitterman +# (C) 2018 Scott Savarese +# (C) 2012 Allison Randal +# (C) 2007 Julian Mehnle +# (C) 2003-2004 Meng Weng Wong # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -23,7 +25,7 @@ # with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. -use version; our $VERSION = qv('2.010'); +use version; our $VERSION = qv('2.011'); use strict; @@ -49,7 +51,7 @@ my $spf_server = Mail::SPF::Server->new( dns_resolver => $resolver, query_rr_types => Mail::SPF::Server->query_rr_type_txt, default_authority_explanation => - 'Please see http://www.openspf.net/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' + 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' ); # Adding more handlers is easy: @@ -63,6 +65,10 @@ my @HANDLERS = ( code => \&exempt_relay }, { + name => 'exempt_domains', + code => \&exempt_domains + }, + { name => 'sender_policy_framework', code => \&sender_policy_framework } @@ -72,6 +78,10 @@ my $VERBOSE = 0; my $DEFAULT_RESPONSE = 'DUNNO'; +# Read in exemption lists +my $exempt_domains = get_exempt_domains( "/etc/postfix/exempt_spf_domains" ); +my $relay_addresses = get_exempt_address("/etc/postfix/exempt_spf_addresses"); + # # Syslogging options for verbose mode and for fatal errors. # NOTE: comment out the $syslog_socktype line if syslogging does not @@ -88,11 +98,6 @@ use constant localhost_addresses => map( qw( 127.0.0.0/8 ::ffff:127.0.0.0/104 ::1 ) ); # Does Postfix ever say "client_address=::ffff:"? -use constant relay_addresses => map( - NetAddr::IP->new($_), - qw( ) -); # add addresses to qw ( ) above separated by spaces using CIDR notation. - # Fully qualified hostname, if available, for use in authentication results # headers now provided by the localhost and whitelist checks. my $host = hostname_long; @@ -183,6 +188,51 @@ while () { %attr = (); } +# ---------------------------------------------------------- +# handler: domain exemption +# ---------------------------------------------------------- + +sub get_exempt_domains { + my ( $file ) = @_; + + my $list = {}; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $domain ( split( /[\s,]+/, $text ) ) { + $list->{$domain} = 1; + } + + return $list; +} + +sub exempt_domains { + my %options = @_; + my $attr = $options{attr}; + + my $domain = ( split( /\@/, $attr->{sender} ) )[1]; + return 'DUNNO' if ( ( ! defined( $domain ) ) or ( $domain eq '' ) ); + + # Check the domain against our list of ignored domains + if ( defined( $exempt_domains->{$domain} ) ) { + return "PREPEND Authentication-Results: $host; none " . + "(SPF exempted by policy)"; + } + + return 'DUNNO'; +} + # ---------------------------------------------------------- # handler: localhost exemption # ---------------------------------------------------------- @@ -202,13 +252,37 @@ sub exempt_localhost { # handler: relay exemption # ---------------------------------------------------------- +sub get_exempt_address { + my ( $file ) = @_; + + my $list = []; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $addr ( split( /[\s,]+/, $text ) ) { + push( @$list, NetAddr::IP->new($addr) ); + } + return $list; +} + sub exempt_relay { my %options = @_; my $attr = $options{attr}; if ($attr->{client_address} ne '') { my $client_address = NetAddr::IP->new($attr->{client_address}); return "PREPEND Authentication-Results: $host; none (SPF not checked for whitelisted relay)" - if grep($_->contains($client_address), relay_addresses); + if grep($_->contains($client_address), @$relay_addresses); }; return 'DUNNO'; } @@ -274,8 +348,8 @@ sub sender_policy_framework { ); }; - # Reject on HELO fail. Defer on HELO temperror if message would otherwise - # be accepted. Use the HELO result and return for null sender. + # Prepend header on HELO fail instead of rejecting. + # Use the HELO result and return for null sender. if ($helo_result->is_code('fail')) { if ($VERBOSE) { syslog( @@ -284,7 +358,8 @@ sub sender_policy_framework { $attr->{helo_name} || '' ); }; - return "550 $helo_authority_exp"; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; } elsif ($helo_result->is_code('temperror')) { if ($VERBOSE) { @@ -294,7 +369,8 @@ sub sender_policy_framework { $attr->{helo_name} || '' ); }; - return "DEFER_IF_PERMIT SPF-Result=$helo_local_exp"; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; } elsif ($attr->{sender} eq '') { if ($VERBOSE) { @@ -368,10 +444,12 @@ sub sender_policy_framework { ); }; if ($mfrom_result->is_code('fail')) { - return "550 $mfrom_authority_exp"; + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; } elsif ($mfrom_result->is_code('temperror')) { - return "DEFER_IF_PERMIT SPF-Result=$mfrom_local_exp"; + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; } else { return "PREPEND $mfrom_spf_header" diff --git a/postfix-policyd-spf-perl.in b/postfix-policyd-spf-perl.in new file mode 100644 index 0000000..a626c89 --- /dev/null +++ b/postfix-policyd-spf-perl.in @@ -0,0 +1,473 @@ +#!/usr/bin/perl + +# postfix-policyd-spf-perl +# https://launchpad.net/postfix-policyd-spf-perl +# http://www.openspf.org/Software +# version 2.011 +# +# (C) 2007-2008,2012,2018 Scott Kitterman +# (C) 2018 Scott Savarese +# (C) 2012 Allison Randal +# (C) 2007 Julian Mehnle +# (C) 2003-2004 Meng Weng Wong +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +use version; our $VERSION = qv('2.011'); + +use strict; + +use IO::Handle; +use Sys::Syslog qw(:DEFAULT setlogsock); +use NetAddr::IP; +use Mail::SPF; +use Sys::Hostname::Long 'hostname_long'; + +# ---------------------------------------------------------- +# configuration +# ---------------------------------------------------------- + +my $resolver = Net::DNS::Resolver->new( + retrans => 5, # Net::DNS::Resolver default: 5 + retry => 2, # Net::DNS::Resolver default: 4 + # Makes for a total timeout for UDP queries of 5s * 2 = 10s. +); + +# query_rr_type_all will query both type TXT and type SPF. This upstream +# default is changed due to there being essentiall no type SPF deployment. +my $spf_server = Mail::SPF::Server->new( + dns_resolver => $resolver, + query_rr_types => Mail::SPF::Server->query_rr_type_txt, + default_authority_explanation => + 'Please see http://www.openspf.org/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' +); + +# Adding more handlers is easy: +my @HANDLERS = ( + { + name => 'exempt_localhost', + code => \&exempt_localhost + }, + { + name => 'exempt_relay', + code => \&exempt_relay + }, + { + name => 'exempt_domains', + code => \&exempt_domains + }, + { + name => 'sender_policy_framework', + code => \&sender_policy_framework + } +); + +my $VERBOSE = 0; + +my $DEFAULT_RESPONSE = 'DUNNO'; + +# Read in exemption lists +my $exempt_domains = get_exempt_domains( "@@CDIR@@/exempt_spf_domains" ); +my $relay_addresses = get_exempt_address("@@CDIR@@/exempt_spf_addresses"); + +# +# Syslogging options for verbose mode and for fatal errors. +# NOTE: comment out the $syslog_socktype line if syslogging does not +# work on your system. +# + +my $syslog_socktype = 'unix'; # inet, unix, stream, console +my $syslog_facility = 'mail'; +my $syslog_options = 'pid'; +my $syslog_ident = 'postfix/policy-spf'; + +use constant localhost_addresses => map( + NetAddr::IP->new($_), + qw( 127.0.0.0/8 ::ffff:127.0.0.0/104 ::1 ) +); # Does Postfix ever say "client_address=::ffff:"? + +# Fully qualified hostname, if available, for use in authentication results +# headers now provided by the localhost and whitelist checks. +my $host = hostname_long; + +my %results_cache; # by message instance + +# ---------------------------------------------------------- +# initialization +# ---------------------------------------------------------- + +# +# Log an error and abort. +# +sub fatal_exit { + syslog(err => "fatal_exit: @_"); + syslog(warning => "fatal_exit: @_"); + syslog(info => "fatal_exit: @_"); + die("fatal: @_"); +} + +# +# Unbuffer standard output. +# +STDOUT->autoflush(1); + +# +# This process runs as a daemon, so it can't log to a terminal. Use +# syslog so that people can actually see our messages. +# +setlogsock($syslog_socktype); +openlog($syslog_ident, $syslog_options, $syslog_facility); + +# ---------------------------------------------------------- +# main +# ---------------------------------------------------------- + +# +# Receive a bunch of attributes, evaluate the policy, send the result. +# +my %attr; +while () { + chomp; + + if (/=/) { + my ($key, $value) =split (/=/, $_, 2); + $attr{$key} = $value; + next; + } + elsif (length) { + syslog(warning => sprintf("warning: ignoring garbage: %.100s", $_)); + next; + } + + if ($VERBOSE) { + for (sort keys %attr) { + syslog(debug => "Attribute: %s=%s", $_ || '', $attr{$_} || ''); + } + }; + + my $message_instance = $attr{instance}; + my $cache = defined($message_instance) ? $results_cache{$message_instance} ||= {} : {}; + + my $action = $DEFAULT_RESPONSE; + + foreach my $handler (@HANDLERS) { + my $handler_name = $handler->{name}; + my $handler_code = $handler->{code}; + + my $response = $handler_code->(attr => \%attr, cache => $cache); + + if ($VERBOSE) { + syslog(debug => "handler %s: %s", $handler_name || '', $response || ''); + }; + + # Pick whatever response is not 'DUNNO' + if ($response and $response !~ /^DUNNO/i) { + if ($VERBOSE) { + syslog(info => "handler %s: is decisive.", $handler_name || ''); + } + $action = $response; + last; + } + } + + syslog(info => "Policy action=%s", $action || ''); + + STDOUT->print("action=$action\n\n"); + %attr = (); +} + +# ---------------------------------------------------------- +# handler: domain exemption +# ---------------------------------------------------------- + +sub get_exempt_domains { + my ( $file ) = @_; + + my $list = {}; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $domain ( split( /[\s,]+/, $text ) ) { + $list->{$domain} = 1; + } + + return $list; +} + +sub exempt_domains { + my %options = @_; + my $attr = $options{attr}; + + my $domain = ( split( /\@/, $attr->{sender} ) )[1]; + return 'DUNNO' if ( ( ! defined( $domain ) ) or ( $domain eq '' ) ); + + # Check the domain against our list of ignored domains + if ( defined( $exempt_domains->{$domain} ) ) { + return "PREPEND Authentication-Results: $host; none " . + "(SPF exempted by policy)"; + } + + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: localhost exemption +# ---------------------------------------------------------- + +sub exempt_localhost { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for localhost)" + if grep($_->contains($client_address), localhost_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: relay exemption +# ---------------------------------------------------------- + +sub get_exempt_address { + my ( $file ) = @_; + + my $list = []; + + # Return nothing if file not found + if ( ! -r $file ) { + return $list; + } + + # Read the file into one variable, split on space or comma (or all) + open ( FILE, $file ) or die "Can't open $file: $!\n"; + my $text = ""; + while ( my $tmp = ) { + $text .= $tmp; + } + close( FILE ); + + foreach my $addr ( split( /[\s,]+/, $text ) ) { + push( @$list, NetAddr::IP->new($addr) ); + } + return $list; +} + +sub exempt_relay { + my %options = @_; + my $attr = $options{attr}; + if ($attr->{client_address} ne '') { + my $client_address = NetAddr::IP->new($attr->{client_address}); + return "PREPEND Authentication-Results: $host; none (SPF not checked for whitelisted relay)" + if grep($_->contains($client_address), @$relay_addresses); + }; + return 'DUNNO'; +} + +# ---------------------------------------------------------- +# handler: SPF +# ---------------------------------------------------------- + +sub sender_policy_framework { + my %options = @_; + my $attr = $options{attr}; + my $cache = $options{cache}; + + # ------------------------------------------------------------------------- + # Always do HELO check first. If no HELO policy, it's only one lookup. + # This avoids the need to do any MAIL FROM processing for null sender. + # ------------------------------------------------------------------------- + + my $helo_result = $cache->{helo_result}; + + if (not defined($helo_result)) { + # No HELO result has been cached from earlier checks on this message. + + my $helo_request = eval { + Mail::SPF::Request->new( + scope => 'helo', + identity => $attr->{helo_name}, + ip_address => $attr->{client_address} + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "HELO check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', + $errmsg || '' + ); + }; + return; + } + + $helo_result = $cache->{helo_result} = $spf_server->process($helo_request); + } + + my $helo_result_code = $helo_result->code; # 'pass', 'fail', etc. + my $helo_local_exp = nullchomp($helo_result->local_explanation); + my $helo_authority_exp = nullchomp($helo_result->authority_explanation) + if $helo_result->is_code('fail'); + my $helo_spf_header = $helo_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s, IP Address: %s, Recipient: %s", + $helo_result || '', + $attr->{helo_name} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Prepend header on HELO fail instead of rejecting. + # Use the HELO result and return for null sender. + if ($helo_result->is_code('fail')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($helo_result->is_code('temperror')) { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO: %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($attr->{sender} eq '') { + if ($VERBOSE) { + syslog( + info => "SPF %s: HELO/EHLO (Null Sender): %s", + $helo_result || '', + $attr->{helo_name} || '' + ); + }; + return "PREPEND $helo_spf_header" + unless $cache->{added_spf_header}++; + } + + # ------------------------------------------------------------------------- + # Do MAIL FROM check (as HELO did not give a definitive result) + # ------------------------------------------------------------------------- + + my $mfrom_result = $cache->{mfrom_result}; + + if (not defined($mfrom_result)) { + # No MAIL FROM result has been cached from earlier checks on this message. + + my $mfrom_request = eval { + Mail::SPF::Request->new( + scope => 'mfrom', + identity => $attr->{sender}, + ip_address => $attr->{client_address}, + helo_identity => $attr->{helo_name} # for %{h} macro expansion + ); + }; + + if ($@) { + # An unexpected error occurred during request creation, + # probably due to invalid input data! + my $errmsg = $@; + $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); + if ($VERBOSE) { + syslog( + info => "Mail From (sender) check failed - Mail::SPF->new(%s, %s, %s) failed: %s", + $attr->{client_address} || '', + $attr->{sender} || '', $attr->{helo_name} || '', $errmsg || '' + ); + }; + return; + } + + $mfrom_result = $cache->{mfrom_result} = $spf_server->process($mfrom_request); + } + + my $mfrom_result_code = $mfrom_result->code; # 'pass', 'fail', etc. + my $mfrom_local_exp = nullchomp($mfrom_result->local_explanation); + my $mfrom_authority_exp = nullchomp($mfrom_result->authority_explanation) + if $mfrom_result->is_code('fail'); + my $mfrom_spf_header = $mfrom_result->received_spf_header; + + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s, IP Address: %s, Recipient: %s", + $mfrom_result || '', + $attr->{sender} || '', $attr->{client_address} || '', + $attr->{recipient} || '' + ); + }; + + # Same approach as HELO.... + if ($VERBOSE) { + syslog( + info => "SPF %s: Envelope-from: %s", + $mfrom_result || '', + $attr->{sender} || '' + ); + }; + if ($mfrom_result->is_code('fail')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + elsif ($mfrom_result->is_code('temperror')) { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + else { + return "PREPEND $mfrom_spf_header" + unless $cache->{added_spf_header}++; + } + + return; +} + +# ---------------------------------------------------------- +# utility, string cleaning +# ---------------------------------------------------------- + +sub nullchomp { + my $value = shift; + + # Remove one or more null characters from the + # end of the input. + $value =~ s/\0+$//; + return $value; +}