File Coverage

blib/lib/Mail/Milter/Authentication/Handler/BIMI.pm
Criterion Covered Total %
statement 142 224 63.3
branch 69 128 53.9
condition 11 26 42.3
subroutine 13 14 92.8
pod 1 8 12.5
total 236 400 59.0


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::BIMI;
2 2     2   1809 use 5.20.0;
  2         9  
3 2     2   17 use strict;
  2         10  
  2         46  
4 2     2   10 use warnings;
  2         11  
  2         304  
5 2     2   21 use Mail::Milter::Authentication::Pragmas;
  2         4  
  2         18  
6             # ABSTRACT: Handler class for BIMI
7             our $VERSION = '3.20230629'; # VERSION
8 2     2   490 use base 'Mail::Milter::Authentication::Handler';
  2         6  
  2         184  
9 2     2   1161 use Mail::BIMI 2;
  2         2904563  
  2         6626  
10              
11             sub default_config {
12             return {
13 1     1 0 1967 'bimi_options' => {},
14             'rbl_allowlist' => '',
15             'rbl_blocklist' => '',
16             'rbl_no_evidence_allowlist' => '',
17             };
18             }
19              
20             sub register_metrics {
21             return {
22 1     1 1 14 'bimi_total' => 'The number of emails processed for BIMI',
23             };
24             }
25              
26             sub setup_callback {
27 1     1 0 3 my ($self) = @_;
28 1         8 my $config = $self->handler_config();
29 1   50     7 my $sanitize_location_header = $config->{sanitize_location_header} // 'yes';
30 1   50     11 my $sanitize_indicator_header = $config->{sanitize_indicator_header} // 'silent';
31 1 50       13 $self->add_header_to_sanitize_list('bimi-location', $sanitize_location_header eq 'silent') unless $sanitize_location_header eq 'no';
32 1 50       9 $self->add_header_to_sanitize_list('bimi-indicator', $sanitize_indicator_header eq 'silent') unless $sanitize_indicator_header eq 'no';
33 1         9 return;
34             }
35              
36             sub envfrom_callback {
37 10     10 0 52 my ( $self, $env_from ) = @_;
38 10         57 $self->{ 'header_added' } = 0;
39             }
40              
41             sub header_callback {
42 46     46 0 141 my ( $self, $header, $value ) = @_;
43              
44 46 50       150 return if ( $self->is_local_ip_address() );
45 46 50       154 return if ( $self->is_trusted_ip_address() );
46 46 50       166 return if ( $self->is_authenticated() );
47 46 50       145 return if ( $self->{'failmode'} );
48              
49 46 100       173 if ( lc $header eq 'bimi-selector' ) {
50 7 50       51 if ( exists $self->{'selector'} ) {
51 0         0 $self->dbgout( 'BIMIFail', 'Multiple BIMI-Selector fields', LOG_DEBUG );
52 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( 'fail' );
53 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'multiple BIMI-Selector fields in message' ) );
54 0         0 $self->add_auth_header( $header );
55 0         0 $self->metric_count( 'bimi_total', { 'result' => 'fail', 'reason' => 'bad_selector_header' } );
56 0         0 $self->{ 'header_added' } = 1;
57 0         0 $self->{'failmode'} = 1;
58 0         0 return;
59             }
60 7         32 $self->{'selector'} = $value;
61             }
62 46 100       168 if ( lc $header eq 'from' ) {
63 10 50       57 if ( exists $self->{'from_header'} ) {
64 0         0 $self->dbgout( 'BIMIFail', 'Multiple RFC5322 from fields', LOG_DEBUG );
65 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( 'fail' );
66 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'multiple RFC5322 from fields in message' ) );
67 0         0 $self->add_auth_header( $header );
68 0         0 $self->metric_count( 'bimi_total', { 'result' => 'fail', 'reason' => 'bad_from_header' } );
69 0         0 $self->{ 'header_added' } = 1;
70 0         0 $self->{'failmode'} = 1;
71 0         0 return;
72             }
73 10         47 $self->{'from_header'} = $value;
74             }
75             }
76              
77             sub eom_requires {
78 1     1 0 3 my ($self) = @_;
79 1         3 my @requires = qw{ DMARC };
80 1         2 return \@requires;
81             }
82              
83             sub eom_callback {
84 10     10 0 48 my ($self) = @_;
85 10         40 my $config = $self->handler_config();
86              
87 10 0 33     93 if ( $config->{rbl_allowlist} && $config->{rbl_blocklist} ) {
88 0         0 $self->dbgout( 'BIMI Error', 'Cannot specify both rbl_allowlist and rbl_blocklist', LOG_DEBUG );
89 0         0 return;
90             }
91              
92 10 50       52 return if ( $self->{ 'header_added' } );
93 10 50       39 return if ( $self->is_local_ip_address() );
94 10 50       56 return if ( $self->is_trusted_ip_address() );
95 10 50       77 return if ( $self->is_authenticated() );
96 10 50       95 return if ( $self->{'failmode'} );
97 10         48 eval {
98 10         52 my $Domain = $self->get_domain_from( $self->{'from_header'} );
99              
100 10         65 my $DMARCResults = $self->get_object( 'dmarc_results' );
101 10 50       49 if ( ! $DMARCResults ) {
102              
103 0         0 my $failure_type = 'temperror';
104 0         0 my $top_handler = $self->get_top_handler();
105 0         0 my @auth_headers;
106 0 0       0 if ( exists( $top_handler->{'auth_headers'} ) ) {
107 0         0 for my $type ( sort keys $top_handler->{'auth_headers'}->%* ) {
108 0         0 @auth_headers = ( @auth_headers, @{ $top_handler->{'auth_headers'}->{$type} } );
  0         0  
109             }
110             }
111 0 0       0 if (@auth_headers) {
112 0         0 foreach my $auth_header (@auth_headers) {
113 0 0       0 next unless $auth_header->key eq 'dmarc';
114 0 0       0 if ( $auth_header->value eq 'permerror' ) {
115 0         0 $failure_type = 'permerror';
116 0         0 last;
117             }
118             }
119             }
120              
121 0         0 $self->log_error( 'BIMI Error No DMARC Results object');
122 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( $failure_type );
123 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'Internal DMARC error' ) );
124 0         0 $self->add_auth_header( $header );
125 0         0 $self->{ 'header_added' } = 1;
126              
127             }
128             else {
129 10 50       39 if ( scalar @$DMARCResults != 1 ) {
130 0         0 $self->dbgout( 'BIMIFail', 'Multiple DMARC Results', LOG_DEBUG );
131 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( 'fail' );
132 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( 'multiple DMARC results for message' ) );
133 0         0 $self->add_auth_header( $header );
134 0         0 $self->metric_count( 'bimi_total', { 'result' => 'fail', 'reason' => 'multiple_dmarc_results' } );
135 0         0 $self->{ 'header_added' } = 1;
136 0         0 $self->{'failmode'} = 1;
137 0         0 return;
138             }
139             else {
140 10         356 my $DMARCResult = clone $DMARCResults->[0]; # Clone so we can modify without breaking reporting data
141              
142             ## Consider ARC
143             # We only have 1 DMARC result so we find the auth results header that it added
144 10         47 my $selector_arc_pass = 0;
145 10 100       48 if ( $DMARCResult->result ne 'pass' ) {
146 2         23 my $top_handler = $self->get_top_handler();
147 2         9 my @auth_headers;
148 2 50       15 if ( exists( $top_handler->{'auth_headers'} ) ) {
149 2         15 for my $type ( sort keys $top_handler->{'auth_headers'}->%* ) {
150 2         6 @auth_headers = ( @auth_headers, @{ $top_handler->{'auth_headers'}->{$type} } );
  2         12  
151             }
152             }
153 2 50       10 if (@auth_headers) {
154 2         5 foreach my $auth_header ( @auth_headers ) {
155 9 100       99 next if $auth_header->key ne 'dmarc';
156 2   50     21 my $arc_aware_result = eval{ $auth_header->search({key=>'policy.arc-aware-result'})->children->[0]->value } // '';
  2         26  
157 2         1450 $self->handle_exception( $@ );
158 2 100       16 if ( $arc_aware_result eq 'pass' ) {
159 1         7 $self->log_error( 'BIMI DMARC ARC pass detected' );
160 1         6 $DMARCResult->{result} = $arc_aware_result; # Feels hacky, but does the right thing
161             # Note, we can't check for signness of BIMI-Selector for arc forwarded mail where DKIM context has been lost
162             # When we have a pass by arc we skip the DKIM check for BIMI-Selector
163 1         4 $selector_arc_pass = 1;
164             }
165             }
166             }
167             }
168              
169 10         84 my $Selector = $self->{ 'selector' };
170 10 100       78 if ( !$Selector ) {
    50          
171 3         9 $Selector = 'default';
172             }
173             elsif ( $Selector =~ m/^v=BIMI1;\s+s=(\w+);?/i ) {
174 7         26 $Selector = $1;
175 7         27 $Selector = lc $Selector;
176             # Was the BIMI-Selector header DKIM Signed?
177 7         18 my $selector_was_domain_signed = 0;
178 7         16 my $selector_was_org_domain_signed = 0;
179 7         18 my $selector_was_third_party_domain_signed = 0;
180 7         20 my $OrgDomain = eval{ $self->get_handler('DMARC')->get_dmarc_object()->get_organizational_domain( $Domain ) };
  7         41  
181 7         984 $self->handle_exception( $@ );
182 7 50       44 if ( $self->{'selector'} ) {
183 7         35 my $dkim_handler = $self->get_handler('DKIM');
184 7 100       41 if ( $dkim_handler->{'has_dkim'} ) {
185 4         23 my $dkim_object = $self->get_object('dkim');
186 4 50       21 if ( $dkim_object ) {
187 4 50       18 if ( $dkim_object->signatures() ) {
188 4         56 foreach my $signature ( $dkim_object->signatures() ) {
189 4 50       48 next if $signature->result ne 'pass';
190 4         51 my @signed_headers = $signature->headerlist;
191 4 100       230 next if ! grep { lc $_ eq 'bimi-selector' } @signed_headers;
  15         59  
192 3         14 my $signature_domain = $signature->domain;
193 3 100       67 if ( lc $signature_domain eq lc $Domain ) {
    100          
194 1         4 $selector_was_domain_signed = 1;
195             }
196             elsif ( lc $signature_domain eq lc $OrgDomain ) {
197 1         5 $selector_was_org_domain_signed = 1;
198             }
199             else {
200 1         5 $selector_was_third_party_domain_signed = 1;
201             }
202             }
203             }
204             }
205             }
206             }
207 7 100       71 my $Alignment = $selector_was_domain_signed ? 'domain'
    100          
    100          
    100          
208             : $selector_was_org_domain_signed ? 'orgdomain'
209             : $selector_arc_pass ? 'arc'
210             : $selector_was_third_party_domain_signed ? 'thirdparty'
211             : 'unsigned';
212 7 100 100     51 if ( $Alignment eq 'unsigned' || $Alignment eq 'thirdparty' ) {
213 4         39 $self->log_error( 'BIMI Header DKIM '.$Alignment.' for Selector '.$Selector.' - ignoring' );
214 4         34 $Selector = 'default';
215             }
216              
217             }
218             else {
219 0         0 $self->log_error( 'BIMI Invalid Selector Header: ' . $Selector );
220 0         0 $Selector = 'default';
221             }
222              
223 10         26 my $RelevantSPFResult;
224 10         49 my $SPFResults = $self->get_object( 'spf_results' );
225 10 50       59 if ( $SPFResults ) {
226 10         50 foreach my $SPFResult ( $SPFResults->@* ) {
227 10 100       148 next if lc $SPFResult->request->domain ne $Domain;
228 7         1286 $RelevantSPFResult = $SPFResult;
229             }
230             }
231              
232 10         596 my $Skip;
233 10 50       72 if ( $config->{rbl_allowlist} ) {
    50          
234 0         0 my $OrgDomain = $self->get_object('dmarc')->get_organizational_domain($Domain);
235 0 0       0 unless ( $self->rbl_check_domain( $OrgDomain, $config->{'rbl_allowlist'} ) ) {
236 0         0 $self->dbgout( 'BIMISkip', 'Not on allowlist', LOG_DEBUG );
237 0         0 $Skip = 'Local policy; not allowed';
238             }
239             }
240             elsif ( $config->{rbl_blocklist} ) {
241 0         0 my $OrgDomain = $self->get_object('dmarc')->get_organizational_domain($Domain);
242 0 0       0 if ( $self->rbl_check_domain( $OrgDomain, $config->{'rbl_blocklist'} ) ) {
243 0         0 $self->dbgout( 'BIMISkip', 'On blocklist', LOG_DEBUG );
244 0         0 $Skip = 'Local policy; blocked';
245             }
246             }
247              
248 10         26 my %Options;
249 10 50       36 $Options{options} = $config->{'bimi_options'} if exists $config->{'bimi_options'};
250 10         45 $Options{resolver} = $self->get_object( 'resolver' );
251 10         37 $Options{dmarc_object} = $self->get_object('dmarc');
252 10 100       569 $Options{spf_object} = $RelevantSPFResult if $RelevantSPFResult;
253 10         101 $Options{domain} = $Domain;
254 10         33 $Options{selector} = $Selector;
255 10         135 my $BIMI = Mail::BIMI->new(%Options);
256 10         59343 $self->{'bimi_object'} = $BIMI; # For testing!
257              
258 10         35 my $Result;
259 10   50     66 my $timeout = $config->{'timeout'} // 5000000;
260 10         34 eval {
261 10         69 $self->set_handler_alarm( $timeout );
262 10 50       409 $Result = $BIMI->result() if ! $Skip;
263             };
264 10 50       401368 if ( my $Error = $@ ) {
265 0         0 $self->reset_alarm();
266 0         0 my $Type = $self->is_exception_type( $Error );
267 0 0       0 if ( $Type ) {
268 0 0       0 if ( $Type eq 'Timeout' ) {
269             # We have a timeout, is it global or is it ours?
270 0 0       0 if ( $self->get_time_remaining() > 0 ) {
271             # We have time left, but this operation save timed out
272 0         0 $Skip = 'Timeout';
273             }
274             else {
275 0         0 $self->handle_exception( $Error );
276             }
277             }
278             }
279             }
280              
281 10 0 33     112 if ( !$Skip
      33        
      0        
      33        
282             && $config->{rbl_no_evidence_allowlist}
283             && $Result->result eq 'pass'
284             && (
285             !$BIMI->record->authority
286             || !$BIMI->record->authority->vmc
287             || !$BIMI->record->authority->vmc->is_valid
288             )
289             ) {
290 0         0 my $OrgDomain = $self->get_object('dmarc')->get_organizational_domain($Domain);
291 0 0       0 unless ( $self->rbl_check_domain( $OrgDomain, $config->{'rbl_no_evidence_allowlist'} ) ) {
292 0         0 $self->dbgout( 'BIMISkip', 'Not on No Evidence Allowlist', LOG_DEBUG );
293 0         0 $Skip = 'Local policy; not allowed without evidence';
294             }
295             }
296              
297              
298 10 50       41 if ( $Skip ) {
299 0         0 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( 'skipped' );
300 0         0 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $Skip ) );
301 0         0 $self->add_auth_header( $header );
302 0         0 $self->{ 'header_added' } = 1;
303 0         0 $self->metric_count( 'bimi_total', { 'result' => 'skipped', 'reason' => 'rbl' } );
304             }
305             else {
306 10         62 my $AuthResults = $Result->get_authentication_results_object();
307 10         27924 $self->add_auth_header( $AuthResults );
308 10         42 $self->{ 'header_added' } = 1;
309 10         279 my $Record = $BIMI->record();
310 10 100       375 if ( $Result->result() eq 'pass' ) {
311 8         348 my $Headers = $Result->headers;
312 8 50       125 if ( $Headers ) {
313 8 50       90 $self->prepend_header( 'BIMI-Location', $Headers->{'BIMI-Location'} ) if exists $Headers->{'BIMI-Location'} ;
314 8 50       64 $self->prepend_header( 'BIMI-Indicator', $Headers->{'BIMI-Indicator'} ) if exists $Headers->{'BIMI-Indicator'} ;
315             }
316             }
317              
318 10         328 $self->metric_count( 'bimi_total', { 'result' => $Result->result() } );
319             }
320 10         83 $BIMI->finish;
321             }
322             }
323              
324             };
325 10 50       78046 if ( my $error = $@ ) {
326 0           $self->handle_exception( $error );
327 0           $self->log_error( 'BIMI Error ' . $error );
328 0 0         if ( ! $self->{ 'header_added' } ) {
329 0           my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'bimi' )->safe_set_value( 'temperror' );
330 0           $self->add_auth_header( $header );
331 0           $self->{ 'header_added' } = 1;
332             }
333             }
334             }
335              
336             sub close_callback {
337 0     0 0   my ( $self ) = @_;
338 0           delete $self->{'selector'};
339 0           delete $self->{'from_header'};
340 0           delete $self->{'failmode'};
341 0           delete $self->{'bimi_object'};
342 0           delete $self->{'header_added'};
343             }
344              
345             1;
346              
347             __END__
348              
349             =pod
350              
351             =encoding UTF-8
352              
353             =head1 NAME
354              
355             Mail::Milter::Authentication::Handler::BIMI - Handler class for BIMI
356              
357             =head1 VERSION
358              
359             version 3.20230629
360              
361             =head1 DESCRIPTION
362              
363             Module implementing the BIMI standard checks.
364              
365             This handler requires the DMARC handler and its dependencies to be installed and active.
366              
367             =head1 NAME
368              
369             Authentication Milter - BIMI Module
370              
371             =head1 CONFIGURATION
372              
373             "BIMI" : { | Config for the BIMI Module
374             | Requires DMARC
375             "bimi_options" : {}, | Options to pass into Mail::BIMI->new
376             "rbl_allowlist" : "", | Optional RBL Allow list of allowed org domains
377             "rbl_blocklist" : "", | Optional RBL Block list of disallowed org domains
378             | Allow and Block list cannot both be present
379             "rbl_no_evidence_allowlist" : "", | Optonal RBL Allow list of allowed org domains that do NOT require evidence documents
380             | When set, domains not on this list which do not have evidence documents will be 'skipped'
381             "timeout" : 5000000, | Timeout, in microseconds, to apply to a BIMI record check/fetch, detault 5000000 (5s)
382             "sanitize_location_header" : "yes", | Remove existing BIMI-Location header? yes|no|silent (default yes)
383             "sanitize_indicator_header" : "yes", | Remove existing BIMI-Location header? yes|no|silent (default silent)
384             },
385              
386             =head1 AUTHOR
387              
388             Marc Bradshaw <marc@marcbradshaw.net>
389              
390             =head1 COPYRIGHT AND LICENSE
391              
392             This software is copyright (c) 2020 by Marc Bradshaw.
393              
394             This is free software; you can redistribute it and/or modify it under
395             the same terms as the Perl 5 programming language system itself.
396              
397             =cut