File Coverage

blib/lib/Mail/MtPolicyd/Plugin/SPF.pm
Criterion Covered Total %
statement 68 87 78.1
branch 20 38 52.6
condition 5 15 33.3
subroutine 11 11 100.0
pod 1 1 100.0
total 105 152 69.0


line stmt bran cond sub pod time code
1             package Mail::MtPolicyd::Plugin::SPF;
2              
3 2     2   2381 use Moose;
  2         5  
  2         18  
4 2     2   13316 use namespace::autoclean;
  2         4  
  2         23  
5              
6             our $VERSION = '1.23'; # VERSION
7             # ABSTRACT: mtpolicyd plugin to apply SPF checks
8              
9              
10             extends 'Mail::MtPolicyd::Plugin';
11              
12             with 'Mail::MtPolicyd::Plugin::Role::Scoring';
13             with 'Mail::MtPolicyd::Plugin::Role::UserConfig' => {
14             'uc_attributes' => [ 'enabled', 'fail_mode', 'pass_mode' ],
15             };
16              
17 2     2   272 use Mail::MtPolicyd::Plugin::Result;
  2         4  
  2         47  
18 2     2   534 use Mail::MtPolicyd::AddressList;
  2         5  
  2         91  
19 2     2   1745 use Mail::SPF;
  2         228813  
  2         63  
20              
21 2     2   19 use Net::DNS::Resolver;
  2         4  
  2         2461  
22              
23             has 'enabled' => ( is => 'rw', isa => 'Str', default => 'on' );
24              
25             has 'pass_score' => ( is => 'rw', isa => 'Maybe[Num]' );
26             has 'pass_mode' => ( is => 'rw', isa => 'Str', default => 'passive' );
27              
28             has 'fail_score' => ( is => 'rw', isa => 'Maybe[Num]' );
29             has 'fail_mode' => ( is => 'rw', isa => 'Str', default => 'reject' );
30              
31             has 'reject_message' => ( is => 'rw', isa => 'Str',
32             default => 'SPF validation failed: %LOCAL_EXPL%' );
33              
34             has 'default_authority_explanation' => ( is => 'ro', isa => 'Str',
35             default => 'See http://www.%{d}/why/id=%{S};ip=%{I};r=%{R}' );
36             has 'hostname' => ( is => 'ro', isa => 'Str', default => '' );
37              
38             has 'whitelist' => ( is => 'rw', isa => 'Str',
39             default => '');
40              
41             has '_whitelist' => ( is => 'ro', isa => 'Mail::MtPolicyd::AddressList',
42             lazy => 1, default => sub {
43             my $self = shift;
44             my $list = Mail::MtPolicyd::AddressList->new;
45             $list->add_localhost;
46             $list->add_string( $self->whitelist );
47             return $list;
48             },
49             );
50              
51             # use a custom resolver to be able to provide a mock in unit tests
52             has '_dns_resolver' => (
53             is => 'ro', isa => 'Net::DNS::Resolver', lazy => 1,
54             default => sub { Net::DNS::Resolver->new; },
55             );
56              
57             has '_spf' => ( is => 'ro', isa => 'Mail::SPF::Server', lazy => 1,
58             default => sub {
59             my $self = shift;
60             return Mail::SPF::Server->new(
61             default_authority_explanation => $self->default_authority_explanation,
62             hostname => $self->hostname,
63             dns_resolver => $self->_dns_resolver,
64             );
65             },
66             );
67              
68             has 'check_helo' => ( is => 'rw', isa => 'Str', default => 'on');
69              
70             sub run {
71 2     2 1 672 my ( $self, $r ) = @_;
72              
73 2 50       82 if( $self->get_uc($r->session, 'enabled') eq 'off' ) {
74 0         0 return;
75             }
76              
77 2 50       10 if( ! $r->is_attr_defined('client_address') ) {
78 0         0 $self->log( $r, 'cant check SPF without client_address');
79 0         0 return;
80             }
81              
82 2 50       81 if( $self->_whitelist->match_string( $r->attr('client_address') ) ) {
83 0         0 $self->log( $r, 'skipping SPF checks for local or whitelisted ip');
84 0         0 return;
85             }
86              
87 2         91 my $sender = $r->attr('sender');
88              
89 2 50 33     9 if( $r->is_attr_defined('helo_name') && $self->check_helo ne 'off' ) {
90 2         8 my $helo_result = $self->_check_helo( $r );
91 2 50       16 if( defined $helo_result ) {
92 0         0 return( $helo_result ); # return action if present
93             }
94 2 50       21 if( ! $r->is_attr_defined('sender') ) {
95 0         0 $sender = 'postmaster@'.$r->attr('helo_name');
96 0         0 $self->log( $r, 'null sender, building sender from HELO: '.$sender );
97             }
98             }
99              
100 2 50       11 if( ! defined $sender ) {
101 0         0 $self->log( $r, 'skipping SPF check because of null sender, consider setting check_helo=on');
102 0         0 return;
103             }
104              
105 2         13 return $self->_check_mfrom( $r, $sender );
106             }
107              
108             sub _check_helo {
109 2     2   3 my ( $self, $r ) = @_;
110 2         87 my $ip = $r->attr('client_address');
111 2         85 my $helo = $r->attr('helo_name');
112 2         70 my $session = $r->session;
113              
114 2         19 my $request = Mail::SPF::Request->new(
115             scope => 'helo',
116             identity => $helo,
117             ip_address => $ip,
118             );
119 2         1729 my $result = $self->_spf->process($request);
120              
121 2         67605 return $self->_check_spf_result( $r, $result, 1 );
122             }
123              
124             sub _check_mfrom {
125 2     2   6 my ( $self, $r, $sender ) = @_;
126 2         137 my $ip = $r->attr('client_address');
127 2         130 my $helo = $r->attr('helo_name');
128              
129 2 50 33     35 my $request = Mail::SPF::Request->new(
130             scope => 'mfrom',
131             identity => $sender,
132             ip_address => $ip,
133             defined $helo && length($helo) ? ( helo_identity => $helo ) : (),
134             );
135 2         2140 my $result = $self->_spf->process($request);
136              
137 2         233768 return $self->_check_spf_result( $r, $result, 0 );
138             }
139              
140             sub _check_spf_result {
141 4     4   13 my ( $self, $r, $result, $no_pass_action ) = @_;
142 4         30 my $scope = $result->request->scope;
143 4         440 my $session = $r->session;
144 4         34 my $fail_mode = $self->get_uc($session, 'fail_mode');
145 4         18 my $pass_mode = $self->get_uc($session, 'pass_mode');
146              
147 4 50       43 if( $result->code eq 'neutral') {
    100          
    100          
148 0         0 $self->log( $r, 'SPF '.$scope.' status neutral. (no SPF records)');
149 0         0 return;
150             } elsif( $result->code eq 'fail') {
151 1         9 $self->log( $r, 'SPF '.$scope.' check failed: '.$result->local_explanation);
152 1 50 33     81 if( defined $self->fail_score && ! $r->is_already_done($self->name.'-score') ) {
153 1         57 $self->add_score( $r, $self->name => $self->fail_score );
154             }
155 1 50       8 if( $fail_mode eq 'reject') {
156 1         7 return Mail::MtPolicyd::Plugin::Result->new(
157             action => $self->_get_reject_action($result),
158             abort => 1,
159             );
160             }
161 0         0 return;
162             } elsif( $result->code eq 'pass' ) {
163 1         8 $self->log( $r, 'SPF '.$scope.' check passed');
164 1 50       6 if( $no_pass_action ) { return; }
  0         0  
165 1 50 33     47 if( defined $self->pass_score && ! $r->is_already_done($self->name.'-score') ) {
166 1         36 $self->add_score( $r, $self->name => $self->pass_score );
167             }
168 1 50 33     9 if( $pass_mode eq 'accept' || $pass_mode eq 'dunno') {
169 0         0 return Mail::MtPolicyd::Plugin::Result->new_dunno;
170             }
171 1         16 return;
172             }
173              
174 2         29 $self->log( $r, 'spf '.$scope.' check failed: '.$result->local_explanation );
175 2         23 return;
176             }
177              
178             sub _get_reject_action {
179 1     1   5 my ( $self, $result ) = @_;
180 1         66 my $message = $self->reject_message;
181              
182 1 50       10 if( $message =~ /%LOCAL_EXPL%/) {
183 1         9 my $expl = $result->local_explanation;
184 1         16 $message =~ s/%LOCAL_EXPL%/$expl/;
185             }
186 1 50       11 if( $message =~ /%AUTH_EXPL%/) {
187 0         0 my $expl = '';
188 0 0       0 if( $result->can('authority_explanation') ) {
189 0         0 $expl = $result->authority_explanation;
190             }
191 0         0 $message =~ s/%AUTH_EXPL%/$expl/;
192             }
193              
194 1         81 return('reject '.$message);
195             }
196              
197             __PACKAGE__->meta->make_immutable;
198              
199             1;
200              
201             __END__
202              
203             =pod
204              
205             =encoding UTF-8
206              
207             =head1 NAME
208              
209             Mail::MtPolicyd::Plugin::SPF - mtpolicyd plugin to apply SPF checks
210              
211             =head1 VERSION
212              
213             version 1.23
214              
215             =head1 DESCRIPTION
216              
217             This plugin applies Sender Policy Framework(SPF) checks.
218              
219             Checks are implemented using the Mail::SPF perl module.
220              
221             =head1 PARAMETERS
222              
223             =over
224              
225             =item (uc_)enabled (default: on)
226              
227             Enable/disable the plugin.
228              
229             =item (uc_)pass_mode (default: passive)
230              
231             How to behave if the SPF checks passed successfully:
232              
233             =over
234              
235             =item passive
236              
237             Just apply score. Do not return an action.
238              
239             =item accept, dunno
240              
241             Will return an 'dunno' action.
242              
243             =back
244              
245             =item pass_score (default: empty)
246              
247             Score to apply when the sender has been successfully checked against SPF.
248              
249             =item (uc_)fail_mode (default: reject)
250              
251             =over
252              
253             =item reject
254              
255             Return an reject action.
256              
257             =item passive
258              
259             Just apply score and do not return an action.
260              
261             =back
262              
263             =item reject_message (default: )
264              
265             If fail_mode is set to 'reject' this message is used in the reject.
266              
267             The following pattern will be replaced in the string:
268              
269             =over
270              
271             =item %LOCAL_EXPL%
272              
273             Will be replaced with a (local) explanation of the check result.
274              
275             =item %AUTH_EXPL%
276              
277             Will be replaced with a URL to the explanation of the result.
278              
279             This URL could be configured with 'default_authority_explanation'.
280              
281             =back
282              
283             =item fail_score (default: empty)
284              
285             Score to apply if the sender failed the SPF checks.
286              
287             =item default_authority_explanation (default: See http://www.%{d}/why/id=%{S};ip=%{I};r=%{R})
288              
289             String to return as an URL pointing to an explanation of the SPF check result.
290              
291             See Mail::SPF::Server for details.
292              
293             =item hostname (default: empty)
294              
295             An hostname to show in the default_authority_explanation as generating server.
296              
297             =item whitelist (default: '')
298              
299             A comma separated list of IP addresses to skip.
300              
301             =item check_helo (default: "on")
302              
303             Set to 'off' to disable SPF check on helo.
304              
305             =back
306              
307             =head1 EXAMPLE
308              
309             <Plugin spf>
310             module = "SPF"
311             pass_mode = passive
312             pass_score = -10
313             fail_mode = reject
314             #fail_score = 10
315             </Plugin>
316              
317             =head1 AUTHOR
318              
319             Markus Benning <ich@markusbenning.de>
320              
321             =head1 COPYRIGHT AND LICENSE
322              
323             This software is Copyright (c) 2014 by Markus Benning <ich@markusbenning.de>.
324              
325             This is free software, licensed under:
326              
327             The GNU General Public License, Version 2, June 1991
328              
329             =cut