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