File Coverage

blib/lib/Mail/MtPolicyd/Plugin/SPF.pm
Criterion Covered Total %
statement 69 95 72.6
branch 21 46 45.6
condition 5 21 23.8
subroutine 11 11 100.0
pod 1 1 100.0
total 107 174 61.4


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