File Coverage

blib/lib/Mail/Milter/Authentication/Handler/SPF.pm
Criterion Covered Total %
statement 188 226 83.1
branch 68 100 68.0
condition 15 15 100.0
subroutine 16 18 88.8
pod 1 11 9.0
total 288 370 77.8


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::SPF;
2 38     38   16704 use 5.20.0;
  38         187  
3 38     38   284 use strict;
  38         123  
  38         1040  
4 38     38   252 use warnings;
  38         144  
  38         1255  
5 38     38   302 use Mail::Milter::Authentication::Pragmas;
  38         106  
  38         381  
6             # ABSTRACT: Handler class for SPF
7             our $VERSION = '3.20230911'; # VERSION
8 38     38   9079 use base 'Mail::Milter::Authentication::Handler';
  38         138  
  38         4183  
9 38     38   343 use Mail::SPF;
  38         139  
  38         112044  
10              
11             sub default_config {
12             return {
13 0     0 0 0 'hide_received-spf_header' => 0,
14             'hide_none' => 0,
15             'best_guess' => 0,
16             'spfu_detection' => 0,
17             };
18             }
19              
20             sub grafana_rows {
21 0     0 0 0 my ( $self ) = @_;
22 0         0 my @rows;
23 0         0 push @rows, $self->get_json( 'SPF_metrics' );
24 0         0 return \@rows;
25             }
26              
27             sub setup_callback {
28 145     145 0 608 my ( $self ) = @_;
29              
30             $self->set_object_maker( 'spf_server' , sub {
31 65     65   217 my ( $self, $name ) = @_;
32 65         177 my $thischild = $self->{'thischild'};
33 65         378 $self->dbgout( 'Object created', $name, LOG_DEBUG );
34 65         221 my $object;
35 65         267 eval {
36 65         572 my $resolver = $self->get_object('resolver');
37 65         532 $object = Mail::SPF::Server->new(
38             'hostname' => $self->get_my_hostname(),
39             'dns_resolver' => $resolver,
40             );
41             };
42 65 50       30880 if ( my $error = $@ ) {
43 0         0 $self->handle_exception( $error );
44 0         0 $self->log_error( 'SPF Object Setup Error ' . $error );
45             }
46 65         526 $thischild->{'object'}->{$name} = {
47             'object' => $object,
48             'destroy' => 0,
49             };
50 145         2909 });
51             }
52              
53             sub register_metrics {
54             return {
55 40     40 1 366 'spf_total' => 'The number of emails processed for SPF',
56             };
57             }
58              
59             sub wrap_header {
60 109     109 0 423 my ( $self, $value ) = @_;
61 109         888 $value =~ s/ /\n /;
62 109         749 $value =~ s/\) /\)\n /;
63 109         791 $value =~ s/; /;\n /g;
64 109         388 return $value;
65             }
66              
67             sub helo_callback {
68 123     123 0 507 my ( $self, $helo_host ) = @_;
69 123         635 $self->{'failmode'} = 0;
70 123         566 $self->{'helo_name'} = $helo_host;
71             }
72              
73             sub envfrom_callback {
74              
75             # On MAILFROM
76             #...
77 123     123 0 666 my ( $self, $env_from ) = @_;
78 123         790 my $config = $self->handler_config();
79 123 100       955 return if ( $self->is_local_ip_address() );
80 115 100       730 return if ( $self->is_trusted_ip_address() );
81 113 50       776 return if ( $self->is_authenticated() );
82              
83 113 100       740 if ( $config->{'spfu_detection'} ) {
84 8         36 $self->{'spfu_from_domain'} = '';
85 8         33 $self->{'spfu_chain'} = [];
86             }
87 113         376 delete $self->{'spf_header'};
88 113         305 delete $self->{'spf_metric'};
89              
90 113         653 my $spf_server = $self->get_object('spf_server');
91 113 50       568 if ( ! $spf_server ) {
92 0         0 $self->log_error( 'SPF Setup Error' );
93 0         0 $self->{ 'spf_metric' } = 'error';
94 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( 'temperror' );
95 0         0 $self->{'spf_header'} = $header;
96 0         0 return;
97             }
98              
99 113         418 my $scope = 'mfrom';
100              
101 113 100       527 $env_from = q{} if $env_from eq '<>';
102              
103 113         331 my $identity;
104             my $domain;
105 113 100       422 if ( !$env_from ) {
106 5         14 $identity = $self->{'helo_name'};
107 5         14 $domain = $identity;
108 5         13 $scope = 'helo';
109             }
110             else {
111 108         801 $identity = $self->get_address_from($env_from);
112 108         1008 $domain = $self->get_domain_from($identity);
113             }
114              
115 113 50       585 if ( !$identity ) {
116 0         0 $identity = $self->{'helo_name'};
117 0         0 $domain = $identity;
118 0         0 $scope = 'helo';
119             }
120              
121 113         331 eval {
122             my $spf_request = Mail::SPF::Request->new(
123             'versions' => [1],
124             'scope' => $scope,
125             'identity' => $identity,
126             'ip_address' => $self->ip_address(),
127 113         954 'helo_identity' => $self->{'helo_name'},
128             );
129              
130 113         151722 my $spf_result = $spf_server->process($spf_request);
131 113         1533699 my $spf_results = $self->get_object('spf_results');
132 113 50       565 $spf_results = [] if ! $spf_results;
133 113         358 push @$spf_results, $spf_result;
134 113         849 $self->set_object('spf_results',$spf_results,1);
135              
136 113         814 my $result_code = $spf_result->code();
137              
138             # Best Guess SPF based on org domain
139             # ToDo report this in both metrics and AR header
140 113         367 my $auth_domain;
141 113 100       504 if ( $result_code eq 'none' ) {
142 14 50       70 if ( $config->{'best_guess'} ) {
143 0 0       0 if ( $self->is_handler_loaded( 'DMARC' ) ) {
144 0         0 my $dmarc_handler = $self->get_handler('DMARC');
145 0         0 my $dmarc_object = $dmarc_handler->get_dmarc_object();
146 0 0       0 if ( $domain ) {
147 0         0 my $org_domain = eval{ $dmarc_object->get_organizational_domain( $domain ); };
  0         0  
148 0         0 $self->handle_exception( $@ );
149 0 0       0 if ( $org_domain ne $domain ) {
150 0         0 $auth_domain = $org_domain;
151             $spf_request = Mail::SPF::Request->new(
152             'versions' => [1],
153             'scope' => $scope,
154             'identity' => $identity,
155             'authority_domain' => $org_domain,
156             'ip_address' => $self->ip_address(),
157 0         0 'helo_identity' => $self->{'helo_name'},
158             );
159 0         0 $spf_result = $spf_server->process($spf_request);
160 0         0 my $spf_results = $self->get_object('spf_results');
161 0 0       0 $spf_results = [] if ! $spf_results;
162 0         0 push @$spf_results, $spf_result;
163 0         0 $self->set_object('spf_results',$spf_results,1);
164 0         0 $result_code = $spf_result->code();
165             }
166             }
167             }
168             }
169             }
170              
171 113         484 $self->{ 'spf_metric' } = $result_code;
172              
173 113         1454 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( $result_code );
174 113 50       10289 if ( $auth_domain ) {
175 0         0 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'policy.authdomain' )->safe_set_value( $auth_domain ) );
176             }
177 113         822 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.mailfrom' )->safe_set_value( $self->get_address_from( $env_from ) ) );
178 113         10610 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.helo' )->safe_set_value( $self->{ 'helo_name' } ) );
179 113 100 100     11143 if ( !( $config->{'hide_none'} && $result_code eq 'none' ) ) {
180 109         442 $self->{'spf_header'} = $header;
181             }
182              
183             # Set for DMARC
184             # Note, these are the SPF results to be processed
185             # BY the DMARC handler, they are not DMARC results.
186 113         466 $self->{'dmarc_domain'} = $domain;
187 113         516 $self->{'dmarc_scope'} = $scope;
188 113         475 $self->{'dmarc_result'} = $result_code;
189 113         448 $self->{'dmarc_spfu_downgrade'} = 0;
190              
191 113         643 $self->dbgout( 'SPFCode', $result_code, LOG_DEBUG );
192              
193 113 50       835 if ( !( $config->{'hide_received-spf_header'} ) ) {
194 113 100 100     849 if ( !( $config->{'hide_none'} && $result_code eq 'none' ) ) {
195 109         1192 my $result_header = $spf_result->received_spf_header();
196 109         91237 my ( $header, $value ) = split( ': ', $result_header, 2 );
197 109         671 $value = $self->wrap_header( $value );
198 109         1253 $self->prepend_header( $header, $value );
199 109         538 $self->dbgout( 'SPFHeader', $result_header, LOG_DEBUG );
200             }
201             }
202             };
203 113 50       894 if ( my $error = $@ ) {
204 0         0 $self->handle_exception( $error );
205 0         0 $self->log_error( 'SPF Error ' . $error );
206 0         0 $self->{'failmode'} = 1;
207 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'spf' )->safe_set_value( 'temperror' );
208 0         0 $self->{'spf_header'} = $header;
209 0         0 $self->{'spf_metric'} = 'error';
210             }
211             }
212              
213             sub header_callback {
214 1073     1073 0 3188 my ( $self, $header, $value ) = @_;
215              
216 1073 100       4008 return unless exists $self->{'spfu_chain'};
217 51 50       151 return unless $self->{'dmarc_result'} eq 'pass'; # Did we have an SPF pass for DMARC
218              
219 51         126 my $lc_header = lc $header;
220              
221 51 100       141 if ( $lc_header eq 'from') {
222 8         40 my $spfu_from_domain = lc $self->get_address_from($value);
223 8 50       90 $spfu_from_domain = $self->get_domain_from($spfu_from_domain) if $spfu_from_domain =~ /\@/;
224 8         31 $self->{'spfu_from_domain'} = $spfu_from_domain;
225              
226 8         34 return;
227             }
228              
229 43 100 100     338 if ( $lc_header eq 'received-spf' ||
      100        
      100        
230             $lc_header eq 'x-ms-exchange-authentication-results' ||
231             $lc_header eq 'arc-authentication-results' ||
232             $lc_header =~ 'authentication-results$'
233             ) {
234 18         97 push $self->{'spfu_chain'}->@*, { header => $header, value => $value };
235             }
236             }
237              
238             sub eoh_callback {
239 123     123 0 553 my ($self) = @_;
240 123 100       616 if ( $self->{'spf_header'} ) {
241 109         279 eval {
242 109         592 $self->spfu_checks();
243             };
244 109         4836 $self->handle_exception( $@ );
245 109         830 $self->add_auth_header($self->{'spf_header'});
246             }
247 123 100       1390 $self->metric_count( 'spf_total', { 'result' => $self->{'spf_metric'} } ) if $self->{'spf_metric'};
248             }
249              
250             sub spfu_checks {
251 109     109 0 358 my ($self) = @_;
252              
253 109 100       558 return unless exists $self->{'spfu_chain'};
254 8 50       31 return unless exists $self->{'spfu_from_domain'};
255 8 50       64 return unless $self->{'dmarc_result'} eq 'pass'; # Did we have an SPF pass for DMARC
256 8         22 my $dmarc_object;
257 8 50       32 if ( $self->is_handler_loaded( 'DMARC' ) ) {
258 8         52 my $dmarc_handler = $self->get_handler('DMARC');
259 8         49 $dmarc_object = $dmarc_handler->get_dmarc_object();
260             }
261              
262 8         39 my $spfu_from_domain = $self->{'spfu_from_domain'};
263 8         29 my $dmarc_domain = $self->{'dmarc_domain'};
264 8 50       27 if ($dmarc_object) {
265             # Work with org domain if possible
266 8         29 $dmarc_domain = $dmarc_object->get_organizational_domain( $dmarc_domain );
267 8         1070 $spfu_from_domain = $dmarc_object->get_organizational_domain( $spfu_from_domain );
268             }
269              
270 8 50       1047 return unless lc $dmarc_domain eq $spfu_from_domain;
271              
272             ENTRY:
273 8         48 for my $chain_entry ( reverse $self->{'spfu_chain'}->@* ) {
274 12 100       57 last ENTRY if $self->{'spfu_detected'};
275 10         41 my $header = lc $chain_entry->{'header'};
276 10         34 my $value = $chain_entry->{'value'};
277              
278             # Check for a Received-SPF we can parse
279 10 100       62 if ( $header eq 'received-spf' ) {
280             # We can parse the domain from the comment in most cases
281             # Received-SPF: Fail (protection.outlook.com: domain of ups.com does not designate 23.26.253.8 as permitted sender) receiver=protection.outlook.com; client-ip=23.26.253.8; helo=fa83.windbound.org.uk;
282 4         19 my $lc_value = lc $value;
283 4 50       31 next ENTRY unless $lc_value =~ /^fail /;
284 4         41 my ($for_value) = $lc_value =~ /^.*: domain of (\S+) .*/;
285 4 50       20 next ENTRY unless $for_value;
286 4         17 my $failed_domain = lc $self->get_address_from($for_value);
287 4 50       36 $failed_domain = $self->get_domain_from($failed_domain) if $failed_domain =~ /\@/;
288 4         109 print "$for_value *** $failed_domain";
289 4 50       37 $failed_domain = $dmarc_object->get_organizational_domain( $failed_domain ) if $dmarc_object;
290 4         452 print "$failed_domain ******** $spfu_from_domain\n\n\n";
291 4 100       31 if ( $failed_domain eq $spfu_from_domain ) {
292 3         18 $self->{'spfu_detected'} = 1; # suspicious...
293             }
294              
295 4         19 next ENTRY;
296             }
297              
298             # Check for Authentication-Results style headers
299             # NOTE, We look for ARC-Authentication-Results but do
300             # not verify ARC here, this is used as a negative signal
301             # so forgery will not be of benefit
302 6         20 my $ar_object;
303 6 100       46 if ( $header eq 'x-ms-exchange-authentication-results' ) {
    100          
    50          
304             # We can parse this slightly nonstandard format into an object
305 2         9 $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $value ) };
  2         27  
306 2         8804 $self->handle_exception( $@ );
307 2 50       8 unless ( $ar_object ) {
308             # Try again with a synthesized authserv id (this is often missing)
309 2         5 $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( "authserv.example.com; $value" ) };
  2         11  
310 2         11616 $self->handle_exception( $@ );
311             }
312             } elsif ( $header eq 'arc-authentication-results' ) {
313             # We can parse this into an object, remove the instance
314 2         13 my ($null, $arc_value) = split ';', $value, 2;
315 2         17 $arc_value =~ s/^ +//;
316 2         8 $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $arc_value ) };
  2         14  
317 2         14042 $self->handle_exception( $@ );
318             } elsif ( $header =~ 'authentication-results$' ) {
319             # We can parse this into an object, best effort with subtypes
320 2         7 $ar_object = eval{ Mail::AuthenticationResults->parser()->parse( $value ) };
  2         26  
321 2         14395 $self->handle_exception( $@ );
322             }
323 6 50       27 next ENTRY unless $ar_object; # We didn't find one we could parse
324              
325 6         11 eval {
326 6         44 my $spf_fail_entries = $ar_object->search({ 'isa' => 'entry', 'key' => 'spf', 'value' => 'fail' })->children();
327 6         5942 for my $spf_fail_entry ($spf_fail_entries->@*) {
328 6         40 my $mailfrom_domain_entries = $spf_fail_entry->search({ 'isa' => 'subentry', 'key' => 'smtp.mailfrom'})->children();
329             # should be only 1, but let's iterate anyway
330 6         2099 for my $mailfrom_domain_entry ($mailfrom_domain_entries->@*) {
331 6         21 my $mailfrom_domain = $mailfrom_domain_entry->value();
332 6 50       86 $mailfrom_domain = $dmarc_object->get_organizational_domain( $mailfrom_domain ) if $dmarc_object;
333 6 100       677 if ( lc $mailfrom_domain eq $spfu_from_domain ) {
334 4         29 $self->{'spfu_detected'} = 1; # suspicious...
335             }
336             }
337             }
338              
339             };
340 6         30 $self->handle_exception( $@ );
341              
342             }
343              
344 8 100       85 if ( $self->{'spfu_detected'} ) {
345 7         30 my $config = $self->handler_config();
346 7         30 $self->{'spf_metric'} = 'spf_upgrade';
347 7 100       28 if ( $config->{'spfu_detection'} == 1 ) {
348 6         23 $self->{'dmarc_spfu_downgrade'} = 1;
349 6         29 $self->{'spf_header'}->safe_set_value( 'fail' );
350 6         246 $self->{'spf_header'}->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'spf pass downgraded due to suspicious path' ) );
351             }
352             else {
353 1         7 $self->{'spf_header'}->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'warning: aligned spf fail in history' ) );
354             }
355             }
356             }
357              
358             sub close_callback {
359 118     118 0 379 my ( $self ) = @_;
360 118         363 delete $self->{'spfu_from_domain'};
361 118         312 delete $self->{'spfu_chain'};
362 118         326 delete $self->{'spfu_detected'};
363 118         366 delete $self->{'spf_header'};
364 118         327 delete $self->{'spf_metric'};
365 118         310 delete $self->{'dmarc_domain'};
366 118         311 delete $self->{'dmarc_scope'};
367 118         316 delete $self->{'dmarc_result'};
368 118         347 delete $self->{'dmarc_spfu_downgrade'};
369 118         291 delete $self->{'failmode'};
370 118         289 delete $self->{'helo_name'};
371 118         600 $self->destroy_object('spf_results');
372             }
373              
374             1;
375              
376             __END__
377              
378             =pod
379              
380             =encoding UTF-8
381              
382             =head1 NAME
383              
384             Mail::Milter::Authentication::Handler::SPF - Handler class for SPF
385              
386             =head1 VERSION
387              
388             version 3.20230911
389              
390             =head1 DESCRIPTION
391              
392             Implements the SPF standard checks.
393              
394             =head1 CONFIGURATION
395              
396             "SPF" : { | Config for the SPF Module
397             "hide_received-spf_header" : 0, | Do not add the "Received-SPF" header
398             "hide_none" : 0, | Hide auth line if the result is 'none'
399             | if not hidden at all
400             "best_guess" : 0, | Fallback to Org domain for SPF checks
401             | if result is none.
402             "spfu_detection" : 0 | Add some mitigation for SPF upgrade attacks
403             | 0 = off (default)
404             | 1 = mitigate
405             | 2 = report only
406             },
407              
408             =head1 AUTHOR
409              
410             Marc Bradshaw <marc@marcbradshaw.net>
411              
412             =head1 COPYRIGHT AND LICENSE
413              
414             This software is copyright (c) 2020 by Marc Bradshaw.
415              
416             This is free software; you can redistribute it and/or modify it under
417             the same terms as the Perl 5 programming language system itself.
418              
419             =cut