1#!/usr/local/bin/perl 2 3# postfix-policyd-spf-perl 4# http://www.openspf.org/Software 5# version 2.010 6# 7# (C) 2007-2008,2012 Scott Kitterman <scott@kitterman.com> 8# (C) 2012 Allison Randal <allison@perl.org> 9# (C) 2007 Julian Mehnle <julian@mehnle.net> 10# (C) 2003-2004 Meng Weng Wong <mengwong@pobox.com> 11# 12# This program is free software; you can redistribute it and/or modify 13# it under the terms of the GNU General Public License as published by 14# the Free Software Foundation; either version 2 of the License, or 15# (at your option) any later version. 16# 17# This program is distributed in the hope that it will be useful, 18# but WITHOUT ANY WARRANTY; without even the implied warranty of 19# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 20# GNU General Public License for more details. 21# 22# You should have received a copy of the GNU General Public License along 23# with this program; if not, write to the Free Software Foundation, Inc., 24# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 25 26use version; our $VERSION = qv('2.010'); 27 28use strict; 29 30use IO::Handle; 31use Sys::Syslog qw(:DEFAULT setlogsock); 32use NetAddr::IP; 33use Mail::SPF; 34use Sys::Hostname::Long 'hostname_long'; 35 36# ---------------------------------------------------------- 37# configuration 38# ---------------------------------------------------------- 39 40my $resolver = Net::DNS::Resolver->new( 41 retrans => 5, # Net::DNS::Resolver default: 5 42 retry => 2, # Net::DNS::Resolver default: 4 43 # Makes for a total timeout for UDP queries of 5s * 2 = 10s. 44); 45 46# query_rr_type_all will query both type TXT and type SPF. This upstream 47# default is changed due to there being essentiall no type SPF deployment. 48my $spf_server = Mail::SPF::Server->new( 49 dns_resolver => $resolver, 50 query_rr_types => Mail::SPF::Server->query_rr_type_txt, 51 default_authority_explanation => 52 'Please see http://www.openspf.net/Why?s=%{_scope};id=%{S};ip=%{C};r=%{R}' 53); 54 55# Adding more handlers is easy: 56my @HANDLERS = ( 57 { 58 name => 'exempt_localhost', 59 code => \&exempt_localhost 60 }, 61 { 62 name => 'exempt_relay', 63 code => \&exempt_relay 64 }, 65 { 66 name => 'sender_policy_framework', 67 code => \&sender_policy_framework 68 } 69); 70 71my $VERBOSE = 0; 72 73my $DEFAULT_RESPONSE = 'DUNNO'; 74 75# 76# Syslogging options for verbose mode and for fatal errors. 77# NOTE: comment out the $syslog_socktype line if syslogging does not 78# work on your system. 79# 80 81my $syslog_socktype = 'unix'; # inet, unix, stream, console 82my $syslog_facility = 'mail'; 83my $syslog_options = 'pid'; 84my $syslog_ident = 'postfix/policy-spf'; 85 86use constant localhost_addresses => map( 87 NetAddr::IP->new($_), 88 qw( 127.0.0.0/8 ::ffff:127.0.0.0/104 ::1 ) 89); # Does Postfix ever say "client_address=::ffff:<ipv4-address>"? 90 91use constant relay_addresses => map( 92 NetAddr::IP->new($_), 93 qw( ) 94); # add addresses to qw ( ) above separated by spaces using CIDR notation. 95 96# Fully qualified hostname, if available, for use in authentication results 97# headers now provided by the localhost and whitelist checks. 98my $host = hostname_long; 99 100my %results_cache; # by message instance 101 102# ---------------------------------------------------------- 103# initialization 104# ---------------------------------------------------------- 105 106# 107# Log an error and abort. 108# 109sub fatal_exit { 110 syslog(err => "fatal_exit: @_"); 111 syslog(warning => "fatal_exit: @_"); 112 syslog(info => "fatal_exit: @_"); 113 die("fatal: @_"); 114} 115 116# 117# Unbuffer standard output. 118# 119STDOUT->autoflush(1); 120 121# 122# This process runs as a daemon, so it can't log to a terminal. Use 123# syslog so that people can actually see our messages. 124# 125setlogsock($syslog_socktype); 126openlog($syslog_ident, $syslog_options, $syslog_facility); 127 128# ---------------------------------------------------------- 129# main 130# ---------------------------------------------------------- 131 132# 133# Receive a bunch of attributes, evaluate the policy, send the result. 134# 135my %attr; 136while (<STDIN>) { 137 chomp; 138 139 if (/=/) { 140 my ($key, $value) =split (/=/, $_, 2); 141 $attr{$key} = $value; 142 next; 143 } 144 elsif (length) { 145 syslog(warning => sprintf("warning: ignoring garbage: %.100s", $_)); 146 next; 147 } 148 149 if ($VERBOSE) { 150 for (sort keys %attr) { 151 syslog(debug => "Attribute: %s=%s", $_ || '<UNKNOWN>', $attr{$_} || '<UNKNOWN>'); 152 } 153 }; 154 155 my $message_instance = $attr{instance}; 156 my $cache = defined($message_instance) ? $results_cache{$message_instance} ||= {} : {}; 157 158 my $action = $DEFAULT_RESPONSE; 159 160 foreach my $handler (@HANDLERS) { 161 my $handler_name = $handler->{name}; 162 my $handler_code = $handler->{code}; 163 164 my $response = $handler_code->(attr => \%attr, cache => $cache); 165 166 if ($VERBOSE) { 167 syslog(debug => "handler %s: %s", $handler_name || '<UNKNOWN>', $response || '<UNKNOWN>'); 168 }; 169 170 # Pick whatever response is not 'DUNNO' 171 if ($response and $response !~ /^DUNNO/i) { 172 if ($VERBOSE) { 173 syslog(info => "handler %s: is decisive.", $handler_name || '<UNKNOWN>'); 174 } 175 $action = $response; 176 last; 177 } 178 } 179 180 syslog(info => "Policy action=%s", $action || '<UNKNOWN>'); 181 182 STDOUT->print("action=$action\n\n"); 183 %attr = (); 184} 185 186# ---------------------------------------------------------- 187# handler: localhost exemption 188# ---------------------------------------------------------- 189 190sub exempt_localhost { 191 my %options = @_; 192 my $attr = $options{attr}; 193 if ($attr->{client_address} ne '') { 194 my $client_address = NetAddr::IP->new($attr->{client_address}); 195 return "PREPEND Authentication-Results: $host; none (SPF not checked for localhost)" 196 if grep($_->contains($client_address), localhost_addresses); 197 }; 198 return 'DUNNO'; 199} 200 201# ---------------------------------------------------------- 202# handler: relay exemption 203# ---------------------------------------------------------- 204 205sub exempt_relay { 206 my %options = @_; 207 my $attr = $options{attr}; 208 if ($attr->{client_address} ne '') { 209 my $client_address = NetAddr::IP->new($attr->{client_address}); 210 return "PREPEND Authentication-Results: $host; none (SPF not checked for whitelisted relay)" 211 if grep($_->contains($client_address), relay_addresses); 212 }; 213 return 'DUNNO'; 214} 215 216# ---------------------------------------------------------- 217# handler: SPF 218# ---------------------------------------------------------- 219 220sub sender_policy_framework { 221 my %options = @_; 222 my $attr = $options{attr}; 223 my $cache = $options{cache}; 224 225 # ------------------------------------------------------------------------- 226 # Always do HELO check first. If no HELO policy, it's only one lookup. 227 # This avoids the need to do any MAIL FROM processing for null sender. 228 # ------------------------------------------------------------------------- 229 230 my $helo_result = $cache->{helo_result}; 231 232 if (not defined($helo_result)) { 233 # No HELO result has been cached from earlier checks on this message. 234 235 my $helo_request = eval { 236 Mail::SPF::Request->new( 237 scope => 'helo', 238 identity => $attr->{helo_name}, 239 ip_address => $attr->{client_address} 240 ); 241 }; 242 243 if ($@) { 244 # An unexpected error occurred during request creation, 245 # probably due to invalid input data! 246 my $errmsg = $@; 247 $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); 248 if ($VERBOSE) { 249 syslog( 250 info => "HELO check failed - Mail::SPF->new(%s, %s, %s) failed: %s", 251 $attr->{client_address} || '<UNKNOWN>', 252 $attr->{sender} || '<UNKNOWN>', $attr->{helo_name} || '<UNKNOWN>', 253 $errmsg || '<UNKNOWN>' 254 ); 255 }; 256 return; 257 } 258 259 $helo_result = $cache->{helo_result} = $spf_server->process($helo_request); 260 } 261 262 my $helo_result_code = $helo_result->code; # 'pass', 'fail', etc. 263 my $helo_local_exp = nullchomp($helo_result->local_explanation); 264 my $helo_authority_exp = nullchomp($helo_result->authority_explanation) 265 if $helo_result->is_code('fail'); 266 my $helo_spf_header = $helo_result->received_spf_header; 267 268 if ($VERBOSE) { 269 syslog( 270 info => "SPF %s: HELO/EHLO: %s, IP Address: %s, Recipient: %s", 271 $helo_result || '<UNKNOWN>', 272 $attr->{helo_name} || '<UNKNOWN>', $attr->{client_address} || '<UNKNOWN>', 273 $attr->{recipient} || '<UNKNOWN>' 274 ); 275 }; 276 277 # Reject on HELO fail. Defer on HELO temperror if message would otherwise 278 # be accepted. Use the HELO result and return for null sender. 279 if ($helo_result->is_code('fail')) { 280 if ($VERBOSE) { 281 syslog( 282 info => "SPF %s: HELO/EHLO: %s", 283 $helo_result || '<UNKNOWN>', 284 $attr->{helo_name} || '<UNKNOWN>' 285 ); 286 }; 287 return "550 $helo_authority_exp"; 288 } 289 elsif ($helo_result->is_code('temperror')) { 290 if ($VERBOSE) { 291 syslog( 292 info => "SPF %s: HELO/EHLO: %s", 293 $helo_result || '<UNKNOWN>', 294 $attr->{helo_name} || '<UNKNOWN>' 295 ); 296 }; 297 return "DEFER_IF_PERMIT SPF-Result=$helo_local_exp"; 298 } 299 elsif ($attr->{sender} eq '') { 300 if ($VERBOSE) { 301 syslog( 302 info => "SPF %s: HELO/EHLO (Null Sender): %s", 303 $helo_result || '<UNKNOWN>', 304 $attr->{helo_name} || '<UNKNOWN>' 305 ); 306 }; 307 return "PREPEND $helo_spf_header" 308 unless $cache->{added_spf_header}++; 309 } 310 311 # ------------------------------------------------------------------------- 312 # Do MAIL FROM check (as HELO did not give a definitive result) 313 # ------------------------------------------------------------------------- 314 315 my $mfrom_result = $cache->{mfrom_result}; 316 317 if (not defined($mfrom_result)) { 318 # No MAIL FROM result has been cached from earlier checks on this message. 319 320 my $mfrom_request = eval { 321 Mail::SPF::Request->new( 322 scope => 'mfrom', 323 identity => $attr->{sender}, 324 ip_address => $attr->{client_address}, 325 helo_identity => $attr->{helo_name} # for %{h} macro expansion 326 ); 327 }; 328 329 if ($@) { 330 # An unexpected error occurred during request creation, 331 # probably due to invalid input data! 332 my $errmsg = $@; 333 $errmsg = $errmsg->text if UNIVERSAL::isa($@, 'Mail::SPF::Exception'); 334 if ($VERBOSE) { 335 syslog( 336 info => "Mail From (sender) check failed - Mail::SPF->new(%s, %s, %s) failed: %s", 337 $attr->{client_address} || '<UNKNOWN>', 338 $attr->{sender} || '<UNKNOWN>', $attr->{helo_name} || '<UNKNOWN>', $errmsg || '<UNKNOWN>' 339 ); 340 }; 341 return; 342 } 343 344 $mfrom_result = $cache->{mfrom_result} = $spf_server->process($mfrom_request); 345 } 346 347 my $mfrom_result_code = $mfrom_result->code; # 'pass', 'fail', etc. 348 my $mfrom_local_exp = nullchomp($mfrom_result->local_explanation); 349 my $mfrom_authority_exp = nullchomp($mfrom_result->authority_explanation) 350 if $mfrom_result->is_code('fail'); 351 my $mfrom_spf_header = $mfrom_result->received_spf_header; 352 353 if ($VERBOSE) { 354 syslog( 355 info => "SPF %s: Envelope-from: %s, IP Address: %s, Recipient: %s", 356 $mfrom_result || '<UNKNOWN>', 357 $attr->{sender} || '<UNKNOWN>', $attr->{client_address} || '<UNKNOWN>', 358 $attr->{recipient} || '<UNKNOWN>' 359 ); 360 }; 361 362 # Same approach as HELO.... 363 if ($VERBOSE) { 364 syslog( 365 info => "SPF %s: Envelope-from: %s", 366 $mfrom_result || '<UNKNOWN>', 367 $attr->{sender} || '<UNKNOWN>' 368 ); 369 }; 370 if ($mfrom_result->is_code('fail')) { 371 return "550 $mfrom_authority_exp"; 372 } 373 elsif ($mfrom_result->is_code('temperror')) { 374 return "DEFER_IF_PERMIT SPF-Result=$mfrom_local_exp"; 375 } 376 else { 377 return "PREPEND $mfrom_spf_header" 378 unless $cache->{added_spf_header}++; 379 } 380 381 return; 382} 383 384# ---------------------------------------------------------- 385# utility, string cleaning 386# ---------------------------------------------------------- 387 388sub nullchomp { 389 my $value = shift; 390 391 # Remove one or more null characters from the 392 # end of the input. 393 $value =~ s/\0+$//; 394 return $value; 395} 396