File Coverage

blib/lib/Mail/DMARC/Iterator.pm
Criterion Covered Total %
statement 256 395 64.8
branch 140 354 39.5
condition 43 124 34.6
subroutine 20 24 83.3
pod 5 5 100.0
total 464 902 51.4


line stmt bran cond sub pod time code
1             package Mail::DMARC::Iterator;
2 2     2   72848 use strict;
  2         13  
  2         60  
3 2     2   10 use warnings;
  2         4  
  2         64  
4 2     2   1265 use Mail::DKIM::Iterator 1.002;
  2         37570  
  2         211  
5 2     2   1456 use Mail::SPF::Iterator 1.115 qw(:DEFAULT $DEBUG);
  2         273268  
  2         45  
6 2     2   995 use Net::DNS;
  2         6  
  2         218  
7 2     2   15 use Scalar::Util 'dualvar';
  2         13  
  2         127  
8 2     2   15 use Exporter;
  2         6  
  2         297  
9              
10             our $VERSION = '0.014';
11              
12             # TODO
13             # provide some way to get reports (rua)
14             # But to implement this we need the crude mechanism to verify external rua
15              
16              
17             # constants pass(>0), fail(0), error(<0)
18             # pass: At least one of the identifier aligned DKIM or SPF reported pass
19             # invalid-from: Mail contains no usable From, i.e. none or multiple or invalid
20             # perm-error: Invalid DMARC policy record
21             # temp-error: No pass and at least one temporary error
22             # none: No DMARC policy record found
23             # fail: Everything else
24             use constant {
25 2         4995 DMARC_PASS => dualvar( 1,'pass'),
26             DMARC_FAIL => dualvar( 0,'fail'),
27             DMARC_INVALID_FROM => dualvar(-1,'invalid-from'),
28             DMARC_NONE => dualvar(-2,'none'),
29             DMARC_PERMERROR => dualvar(-3,'perm-error'),
30             DMARC_TEMPERROR => dualvar(-4,'temp-error'),
31 2     2   30 };
  2         5  
32              
33             our @EXPORT_OK = qw($DEBUG);
34             our @EXPORT = qw(
35             DMARC_PASS DMARC_FAIL
36             DMARC_INVALID_FROM DMARC_PERMERROR DMARC_TEMPERROR DMARC_NONE
37             );
38              
39             *debug = \&Mail::SPF::Iterator::DEBUG;
40             sub import {
41 2 50   2   427 goto &Exporter::import if @_ == 1; # implicit :DEFAULT
42 0         0 my $i = 1;
43 0         0 while ( $i<@_ ) {
44 0 0 0     0 if ( $_[$i] eq 'DebugFunc' || $_[$i] eq 'Debug' ) {
45 0         0 Mail::SPF::Iterator->import(splice( @_,$i,2 ));
46 0         0 next;
47             }
48 0         0 ++$i;
49             }
50 0 0       0 goto &Exporter::import if @_ >1; # not implicit :DEFAULT
51             }
52              
53              
54             # defined at the end, based on the public suffix module we have installed
55             sub organizational_domain;
56              
57             sub new {
58 7     7 1 24847 my ($class,%args) = @_;
59             # for SPF: $ip, $mailfrom, $helo, [$myname]
60             # If no SPF information -> try to extract from Received-SPF header in mail
61              
62 7         76 my $self = bless {
63             result => undef, # cached final result
64              
65             domain => undef, # \@domains extracted from mail header
66             record => undef, # DMARC record for domain
67             _hdrbuf => '', # temporary buf to collect header
68             _from => undef, # list of sender domains during collection in header
69             _dmarc_domain => undef, # list of domains to check for DMARC record
70              
71             dkim => undef, # internal DKIM object
72             dkim_sub => undef, # external function which computes dkim_result
73             dkim_result => undef, # result from DKIM
74              
75             spf => undef, # SPF object
76             spf_result => undef, # result from SPF
77              
78             dnscache => undef, # external DNS cache
79             _dnsq => {}, # local mapping to DNS packet for open queries
80             authentication_results => [],
81             },$class;
82              
83 7 50 66     90 if ($args{spf_result}) {
    100 33        
    50          
84 0         0 $self->{spf_result} = delete $args{spf_result};
85             } elsif ($args{ip} && $args{mailfrom} && $args{helo}) {
86             $self->{spf} = Mail::SPF::Iterator->new(
87 4         39 delete @args{qw(ip mailfrom helo myname)});
88 4         4803 $self->{spf_result} = [ $self->{spf}->next ];
89             } elsif (exists $args{spf_result}) {
90             # explicitely set to undef - extract from Received-SPF header
91             } else {
92             # we cannot lookup SPF ourself so we need to rely on DKIM only
93 3         7 $self->{spf_result} = [];
94             }
95              
96 7 50       1030 if ($args{dkim_result}) {
    100          
97 0         0 $self->{dkim_result}[0] = delete $args{dkim_result};
98             } elsif ($args{dkim_sub}) {
99 2         5 $self->{dkim_sub} = delete $args{dkim_sub};
100 2         6 $self->{dkim_result}[0] = $self->{dkim_sub}();
101             } else {
102 5         23 $self->{dkim} = Mail::DKIM::Iterator->new;
103 5         68 $self->{dkim_result} = [ $self->{dkim}->next ];
104             }
105              
106 7         76 $self->{domain} = delete $args{domain};
107 7         14 $self->{dnscache} = delete $args{dnscache};
108              
109             # maybe we have already enough data to compute result?
110 7         20 $self->next;
111 7         18 return $self;
112             }
113              
114              
115             # input
116             # - (string): data from mail
117             # - (Net::DNS::Packet): DNS packet with answer for DKIM or SPF
118             # - ([Net::DNS::Packet, error]): DNS query where lookup failed
119             # - (): just recompute final result
120             # output:
121             # - ($rv,@todo) with $rv the (preliminary) results and @todo the list of things
122             # to do, that is either need more data ('D') or DNS lookups (DNS query packet)
123             sub next {
124 34     34 1 33511 my ($self,@input) = @_;
125              
126             process_input:
127 60 50       147 goto return_result if $self->{result};
128 60 100       173 goto recalc if ! @input;
129              
130 26         47 my $data = shift(@input);
131              
132             # If we got a string append it to mail and if this is part of the header
133             # extract data from it. The string '' means EOF.
134             # ---------------------------------------------------------------------
135 26 100       58 if (!ref($data)) {
136 12 50       25 $DEBUG && debug("new mail data");
137 12 100 66     48 if (!$self->{domain} && defined $self->{_hdrbuf}) {
138             # Scan for From header, fills self.domain
139 8         22 _inspect_header($self,$data);
140             }
141 12 100       31 if ($self->{dkim}) {
142             # feed into DKIM object
143 10         54 $self->{dkim_result} = [ $self->{dkim}->next($data) ];
144             }
145 12         7615 goto process_input;
146             }
147              
148             # Assume DNS packet. It might also be [ dns-question, error ].
149             # Find the related callback to handle the response.
150             # ---------------------------------------------------------------------
151 14         24 my $error;
152 14 50       55 if ( ! UNIVERSAL::isa( $data, 'Net::DNS::Packet' )) {
153 0         0 ($data,$error) = @$data;
154 0   0     0 $error ||= 'unknown error';
155 0 0       0 $DEBUG && debug("error for DNS response to %s: %s ",
156             ($data->question)[0]->string, $error);
157             } else {
158 14 50       37 $DEBUG && debug("got DNS response to ".($data->question)[0]->string);
159             }
160              
161 14         53 my $dq = ($data->question)[0];
162 14         108 my $cachekey = $dq->qtype.':'.$dq->qname;
163 14         322 my $qid = $cachekey.':'.$data->header->id;
164 14         192 my $cb = $self->{cb}{$qid};
165 14 50       34 if (!$cb) {
166             # undefined -> unexpected response: complain
167             # defined but false -> possible duplicate: ignore
168 0 0       0 warn "unexpected packet $qid does not match any of the todos\n"
169             if !defined $cb;
170 0         0 goto process_input;
171             };
172              
173 14         33 delete $self->{_dnsq}{$cachekey};
174 14 50       31 $self->{dnscache}{$cachekey} = $data if $self->{dnscache};
175              
176 14         31 ($cb,my @arg) = @$cb;
177 14         44 $cb->($self,$data,$error,@arg);
178 14         983 goto process_input;
179              
180              
181             recalc:
182 35 50       73 goto return_result if $self->{result};
183 35 100       87 goto compute_todos if ! $self->{domain};
184              
185             # Check if we can compute a final result based on the existing DKIM
186             # and SPF results
187             # ---------------------------------------------------------------------
188              
189 27 100       69 my $rec = $self->{record} or goto compute_todos;
190              
191 20         26 my $dkim_result;
192 20 100 100     60 if ($self->{dkim_sub} and
193             my $r = $self->{dkim_result}[0] = $self->{dkim_sub}()) {
194 2 50       16 @$r = grep { $_->sig->{d} =~ $self->{domrx} } @$r if $self->{domrx};
  4         23  
195             }
196 20 50       69 if ($self->{dkim_result}) {
197 20 100 100     78 if ($self->{dkim} and !$self->{dkim_result}[1]) {
198 4         15 push @{$self->{authentication_results}}, $_->authentication_results
199 4 50       7 for @{ $self->{dkim_result}[0] || []};
  4         15  
200 4 50       63 $DEBUG && debug("internal dkim done");
201 4         29 delete $self->{dkim};
202             }
203 20 100       36 for(@{ $self->{dkim_result}[0] || [] }) {
  20         55  
204 19 50 0     34 $DEBUG && debug("got identifier aligned DKIM record, status=%s",
205             $_->status // '');
206 19   100     47 my $st = $_->status // next;
207 6 50 0     35 if ($st == DKIM_SUCCESS) {
    0          
    0          
208             # Identifier aligned DKIM-Received passed.
209             # Alignment was already checked in _got_dmarc_record.
210 6         15 $self->{result} = [ DMARC_PASS, 'DKIM' ];
211 6         70 goto return_result;
212              
213             } elsif ( $st == DKIM_SOFTFAIL || $st == DKIM_TEMPFAIL) {
214 0         0 $dkim_result = [ DMARC_TEMPERROR, $_->error ];
215             } elsif ($st == DKIM_PERMFAIL) {
216 0         0 $dkim_result = [ DMARC_FAIL, $_->error ];
217             } else {
218 0         0 $dkim_result = [ DMARC_PERMERROR, $_->error ];
219             }
220             }
221             }
222              
223 14         59 my $spf_result;
224             {
225 14 50       23 my $sr = $self->{spf_result} or last;
  14         30  
226 14 100       33 defined $sr->[0] or last;
227              
228             # check if envelope-from of SPF-Record matches from
229 5   0     14 my $from = $sr->[2]{'envelope-from'} || $sr->[2]{helo} || last;
230 5         28 $from =~s{.*\@}{};
231 5         19 $from =~s{>.*}{};
232 5 50       165 if ( $rec->{aspf} eq 's'
    100          
233             ? lc($from) ne $rec->{domain}
234             : $from !~m{^([\w\-\.]+\.)?\Q$rec->{domain}\E}i) {
235             # Identifier alignment failed
236 1 50       4 $DEBUG && debug("SPF identifier alignment failed");
237 1         5 $spf_result = [ DMARC_FAIL,
238             'envelope-from does not match From header' ];
239 1         8 delete $self->{spf};
240 1         2 $self->{spf_result} = [];
241 1         4 last;
242             }
243             # Successful identifier alignment, use result from check.
244 4 50       12 $DEBUG && debug("SPF identifier alignment sucess, status=%s",
245             $sr->[0]);
246 4 100       11 if ($sr->[0] eq SPF_Pass) {
247             # fast pass through - it is enough if SPF passes
248 1         4 $self->{result} = [ DMARC_PASS, 'SPF' ];
249 1         18 goto return_result;
250             }
251              
252             $spf_result =
253 3 0 50     16 $sr->[0] eq SPF_Fail ? [ DMARC_FAIL, $sr->[3] // 'SPF Fail' ] :
    0 0        
    0 0        
    50 0        
254             $sr->[0] eq SPF_SoftFail ? [ DMARC_FAIL, $sr->[3] // 'SPF SoftFail' ] :
255             $sr->[0] eq SPF_PermError ? [ DMARC_PERMERROR, $sr->[3] // 'SPF PermError' ] :
256             $sr->[0] eq SPF_TempError ? [ DMARC_TEMPERROR, $sr->[3] // 'SPF TempError' ] :
257             [ DMARC_NONE, "SPF result neutral or none" ];
258             }
259              
260 13 50 66     56 if ($dkim_result || !$self->{dkim} and $spf_result || !$self->{spf}) {
      33        
      66        
261             # We can compute the final result since we either have both DKIM and SPF
262             # or we will not be able to get additional information for the missing
263             # validator.
264             # Pick the result with the best rating. This makes use of the fact that
265             # DMARC_PASS > DMARC_FAIL > DMARC_...ERROR ..
266 1         3 my $best;
267 1 0       3 $DEBUG && debug("compute final result from dkim=%s spf=%s",
    0          
    50          
268             $dkim_result ? $dkim_result->[0] : '',
269             $spf_result ? $spf_result->[0] : '');
270 1         4 for($dkim_result,$spf_result) {
271 2 50       7 defined $_->[0] or next;
272 0 0 0     0 if (!$best) {
    0          
273 0         0 $best = $_
274             } elsif ($_->[0] && $_->[0]>$best->[0]) {
275 0         0 $best = $_
276             }
277             }
278 1 50 33     11 if ($self->{dkim_sub} and
      33        
      33        
      33        
279             !$best || $best->[0] != DMARC_PASS and (
280             ! $self->{dkim_result}[0] ||
281             grep { !$_->status } @{$self->{dkim_result}[0]})
282             ) {
283 1 50       4 $DEBUG && debug("wating with final result for DKIM to complete");
284 1         7 return (undef);
285             }
286 0   0     0 $self->{result} = $best ||
287             [ DMARC_FAIL, "neither DKIM nor SPF information" ];
288 0         0 goto return_result;
289             }
290              
291             compute_todos:
292              
293             # No final result yet - compute list of todos.
294             # ---------------------------------------------------------------------
295 27         160 my (@need_dns,$need_data,@todo) = ();
296 27 100       82 if (!$self->{domain}) {
    100          
297             # Need more data to find From header
298 8 50       21 $DEBUG && debug("no domain yet, need more data from mail");
299 8         14 $need_data++;
300             } elsif (my $dom = $self->{_dmarc_domain}) {
301             # Ask for the DMARC TXT record
302 7 50       15 $DEBUG && debug("need DMARC record for @$dom");
303             push @need_dns, [
304 7   33     58 $self->{_dnsq}{"TXT:_dmarc.$dom->[0]"}
305             ||= Net::DNS::Packet->new('_dmarc.'.$dom->[0],'TXT'),
306             \&_got_dmarc_record,
307             $dom
308             ];
309             }
310              
311             # we have no DMARC record yet, so wait before handling DKIM and SPF
312 27 100       531 goto return_todos if ! $self->{record};
313              
314 12 50       25 if ($self->{dkim}) {
315             # Still have a DKIM object so we probably don't have the final DKIM
316             # result yet. Check the first element of the result to see if the result
317             # is final (defined) or if we still have something to do.
318 12 50       23 if (!$self->{dkim_result}[1]) {
319             # no more todos from DKIM - remove DKIM object and keep result
320 0 0       0 $DEBUG && debug("DKIM done (no more todos)");
321 0         0 goto recalc;
322             } else {
323             # Parse todos in dkim_result and translate them to local todos.
324             # Todo in dkim_result is either \'' for more data or the DNS
325             # name to look up the the DKIM record.
326 12         20 for(my $i=1;1;$i++) {
327 32   100     550 my $todo = $self->{dkim_result}[$i] // last;
328 20 100       41 if (ref($todo)) {
329 12 50       25 $DEBUG && debug("DKIM needs more mail data");
330 12         22 $need_data++;
331             } else {
332 8 50       14 $DEBUG && debug("DKIM needs TXT record for $todo");
333             push @need_dns, [
334 8   66     56 $self->{_dnsq}{"TXT:$todo"}
335             ||= Net::DNS::Packet->new($todo,'TXT'),
336             \&_feed_dkim,
337             $todo
338             ];
339             }
340             }
341             }
342             }
343              
344 12 100       33 if ($self->{spf}) {
    50          
345             # Still have a SPF object so we probably don't have the final SPF
346             # result yet. Check the first element of the result to see if the result
347             # is final (defined) or we still have something to do.
348 6 100       13 if ($self->{spf_result}[0]) {
349 1         3 my $sr = $self->{spf_result};
350             # no more todos - remove SPF object and keep result
351 1 50       4 $DEBUG && debug("SPF is final - $sr->[0]");
352 1         17 push @{$self->{authentication_results}}, "spf=$sr->[0] " .
353 1   50     2 ($sr->[2] && $sr->[2]{problem} && " ($sr->[2]{problem})" || "").
354             " smtp.mailfrom=$self->{spf}{sender}";
355 1         9 delete $self->{spf};
356 1         12 goto recalc;
357             } else {
358 5         9 for(my $i=1;1;$i++) {
359             # Todos in spf_result are Net::DNS objects.
360 10   100     27 my $dnspkt = $self->{spf_result}[$i] // last;
361 5 50       10 $DEBUG && debug("SPF needs DNS lookup for %s",
362             ($dnspkt->question)[0]->string);
363 5         13 push @need_dns, [ $dnspkt, \&_feed_spf ]
364             }
365             }
366             } elsif (!$self->{spf_result}) {
367             # Extract Received-SPF information from mail
368 0 0       0 $DEBUG && debug("SPF needs more mail data to extract Received-SPF");
369 0         0 $need_data++;
370             }
371              
372             # Translate $need_data and @need_dns in todos we can return
373             # ---------------------------------------------------------------------
374             return_todos:
375 26 100       62 push @todo,'D' if $need_data;
376 26         65 my $qid2cb = $self->{cb} = {};
377 26         57 for(@need_dns) {
378 19         45 my ($pkt,$sub,@arg) = @$_;
379 19         51 my ($q) = $pkt->question;
380 19         145 $qid2cb->{ join(':', $q->qtype, $q->qname, $pkt->header->id) }
381             = [ $sub, @arg ];
382 19 50 33     846 if ($self->{dnscache} and
383             my $cached = $self->{dnscache}{ $q->qtype.':'.$q->qname }) {
384             # we have a cache hit - adapt header id
385 0 0       0 $DEBUG && debug("answer %s:%s from dns cache",
386             $q->qtype,$q->qname);
387 0         0 $cached->header->id($pkt->header->id);
388 0         0 unshift @input,$cached;
389             } else {
390 19         33 push @todo,$pkt;
391 19 50       56 $DEBUG && debug("NEW TODO qid=".join(':',
392             $q->qtype, $q->qname, $pkt->header->id)." q=".$pkt->string);
393             }
394             }
395 26 50       55 goto process_input if @input; # process results from cache
396              
397 26 50       55 if ($DEBUG) {
398 0         0 for(@todo) {
399 0 0       0 if (!ref($_)) {
400 0         0 debug("TODO: need more mail data");
401             } else {
402 0         0 debug("TODO: DNS ".($_->question)[0]->string);
403             }
404             }
405             }
406 26         137 return (undef,@todo);
407              
408             # We have a final result
409             # ---------------------------------------------------------------------
410             return_result:
411 7 50       17 $self->{result} or die "why am I here?";
412 7 50       17 if (!defined $self->{result}[2]) {
413 7 50       17 if ($self->{result}[0] == DMARC_FAIL) {
414 0 0 0     0 if ($rec->{sp} && $rec->{domain} ne $self->{domain}[0]) {
415 0         0 $self->{result}[2] = $rec->{sp};
416             } else {
417 0         0 $self->{result}[2] = $rec->{p};
418             }
419             } else {
420 7         16 $self->{result}[2] = '';
421             }
422             }
423 2 50   2   18 $DEBUG && do { no warnings; debug("final result: @{$self->{result}}"); };
  2         6  
  2         8349  
  7         16  
  0         0  
  0         0  
424 7         13 return @{$self->{result}};
  7         34  
425             }
426              
427             sub authentication_results {
428 0     0 1 0 my $self = shift;
429 0 0       0 $self->{result} or return;
430             return "dmarc=$self->{result}[0] header.from=" . $self->domain
431             . ' reason="'.($self->{result}[1] // '').'"',
432 0   0     0 @{$self->{authentication_results}};
  0         0  
433             }
434              
435             # returns DMARC record
436 0     0 1 0 sub record { return shift->{record} }
437              
438             # returns extracted domain
439             sub domain {
440 0     0 1 0 my $self = shift;
441 0   0     0 return $self->{domain} && $self->{domain}[0];
442             }
443              
444             *parse_taglist = \&Mail::DKIM::Iterator::parse_taglist;
445             sub _got_dmarc_record {
446 7     7   19 my ($self,$pkt,$error,$dom) = @_;
447 7 50       17 goto error if $error; # NXDOMAIN or similar
448              
449             # Answer received, if we need to ask again we will set it again
450             # to the new value.
451 7         24 delete $self->{_dmarc_domain};
452              
453             # extract any usable DMARC records...
454 7         14 my @record;
455 7         21 for($pkt->answer) {
456 7 50       63 $_->type eq 'TXT' or next;
457 7         87 my $error;
458 7         20 my $txt = $_->txtdata;
459 7 50       323 $txt =~m{^\s*v=DMARC1[\s;]} or next;
460 7 50       17 $DEBUG && debug("found possible DMARC record '$txt'");
461 7 50       32 my $v = parse_taglist($txt,\$error) or next;
462 7 50       591 $v = _check_dmarc_record($v) or next;
463 7         19 push @record,$v;
464             }
465              
466 7 50       17 goto error if !@record;
467              
468             # take first usable record and ignore the rest
469 7         20 $record[0]{domain} = $dom->[0];
470 7         15 $self->{record} = $record[0];
471              
472 7 50 33     21 if ($record[0]{pct}<100 && rand(100)<$record[0]{pct}) {
473             $DEBUG && debug("skipping policy validation because of pct=%d",
474 0 0       0 $record[0]{pct});
475             $self->{result} = [
476 0         0 DMARC_NONE,
477             'skipped policy validation due to pct<100'
478             ];
479 0         0 return;
480             }
481              
482             # if the DMARC record was for the organizational domain ignore sp
483 7 50 33     8 if (@{$self->{domain}}>1 && $dom ne $self->{domain}[0]) {
  7         21  
484 0         0 $record[0]{sp} = undef;
485             }
486              
487             $DEBUG && debug("use DMARC record ".join(" ",
488 7 50       18 map { "$_=$record[0]{$_}" } sort keys %{$record[0]}));
  0         0  
  0         0  
489              
490             # only consider DKIM signatures which match From
491 7         10 my $domrx;
492 7 50       18 if ($record[0]{adkim} eq 'r') {
493             # relaxed mode - must match organizational domain
494 7         57 $domrx = qr{(^|\.)\Q$self->{domain}[-1]\E\z};
495             } else {
496             # strict mode - must match domain of from
497 0         0 $domrx = qr{^\Q$self->{domain}[0]\E\z};
498             }
499 7         16 $self->{domrx} = $domrx;
500 7 100       22 if ($self->{dkim}) {
    50          
501 5     6   37 $self->{dkim}->filter(sub { shift->{d} =~ $domrx });
  6         91  
502 5         31 $self->{dkim_result} = [ $self->{dkim}->next ];
503             } elsif ($self->{dkim_result}) {
504 2         5 @{ $self->{dkim_result}[0] } = grep { $_->sig->{d} =~ $domrx }
  0         0  
505 2         4 @{ $self->{dkim_result}[0] };
  2         7  
506             }
507              
508             # If we have spf_result built from Received-SPF header filter then
509             # spf_result[0] contains all the Received-SPF headers found and we need
510             # to extract the one which is usable for identifier alignment.
511 7 50 33     433 if ($self->{spf_result} && ref($self->{spf_result}[0]) eq 'ARRAY') {
512             $domrx =
513             $record[0]{aspf} eq $record[0]{adkim} ? $domrx :
514 0 0       0 $record[0]{aspf} eq 'r' ? qr{(^|\.)\Q$self->{domain}[-1]\E\z} :
    0          
515             qr{^\Q$self->{domain}[0]\E\z};
516              
517 0         0 my @aligned;
518 0         0 for(@{ $self->{spf_result}[0] }) {
  0         0  
519 0 0       0 my $from = $_->[1]{'envelope-from'} or next;
520 0         0 $from =~s{.*\@}{}s;
521 0         0 $from =~s{>.*}{}s;
522 0 0       0 $from =~ $domrx or next;
523 0         0 push @aligned, $_
524             }
525 0 0       0 if (@aligned>1) {
    0          
526             # if we have multiple aligned records match the best
527 0         0 for(SPF_Pass,SPF_Fail,SPF_SoftFail) {
528 0 0       0 my @a = grep { $_->[0] eq $_ } @aligned or next;
  0         0  
529 0         0 @aligned = @a;
530 0         0 last;
531             }
532 0 0       0 $DEBUG && debug(
533             "multiple aligned Received-SPF found, pick $aligned[0][0]");
534             } elsif (@aligned) {
535 0 0       0 $DEBUG && debug("found aligned Received-SPF with $aligned[0][0] ");
536             } else {
537 0 0       0 $DEBUG && debug("none of the Received-SPF is aligned with $domrx");
538             }
539 0 0       0 $self->{spf_result} = !@aligned ? [ SPF_None ] : [
540             $aligned[0][0], # result
541             '', # comment
542             $aligned[0][1], # hash
543             ];
544             }
545 7         19 return;
546              
547 0 0 0     0 error:
    0          
548             # retry with next domain if possible
549             $DEBUG && debug("error for DMARC query %s: %s - %s",
550             $dom->[0],$error || 'no DMARC records',
551 0         0 (@$dom>1 ? "retry with @{$dom}[1..$#$dom]":"no retries"));
552              
553 0         0 shift @$dom;
554 0 0       0 if (@$dom) {
555 0         0 $self->{_dmarc_domain} = $dom;
556             } else {
557             # No usable record found and no retries possible
558 0         0 $self->{record} = '';
559             # XXX This is not fully correct - some errors might be permanent
560             # (NXDOMAIN) while others might be temporary only. For now we assume
561             # that any given error is temporary only.
562 0 0 0     0 $DEBUG && debug("finally no DMARC record: %s",
563             $error || 'no DMARC records');
564 0 0       0 $self->{result} = $error
565             ? [ DMARC_TEMPERROR, $error ]
566             : [ DMARC_PERMERROR, 'no DMARC record found' ];
567             }
568 0         0 return;
569             }
570              
571             sub _check_dmarc_record {
572 7     7   26 my $v = shift;
573 7         37 my %h;
574 7         137 for (
575             [ v => qr{^DMARC1\z}, \'' ],
576             [ adkim => qr{^[rs]\z}, 'r' ],
577             [ aspf => qr{^[rs]\z}, 'r' ],
578             [ p => qr{^(none|quarantine|reject)\z}, \'' ],
579             [ sp => qr{^(none|quarantine|reject)\z} ],
580             # These are extracted but ignored for now
581             [ fo => qr{^[01ds]\z}, '0' ],
582             [ pct => qr{^\d+\z}, 100 ],
583             [ rf => qr{^afrf\z},'afrf' ],
584             [ ri => qr{^\d+\z}, 86400 ],
585             [ rua => qr{.}, ],
586             [ ruf => qr{.}, ],
587             ) {
588 77         161 my ($k,$rx,$default) = @$_;
589 77 100       185 if (defined $v->{$k}) {
    100          
590 21 50       110 $v->{$k} =~ $rx or do {
591 0 0       0 $DEBUG && debug("DMARC $k does not match $rx");
592 0         0 return;
593             };
594 21         53 $h{$k} = $v->{$k}
595             } elsif (defined $default) {
596 42 50       82 ref($default) and do {
597 0 0       0 $DEBUG && debug("DMARC $k is missing but mandatory");
598 0         0 return;
599             };
600 42         93 $h{$k} = $default;
601             }
602             }
603 7         68 return \%h;
604             }
605              
606             sub _feed_dkim {
607 4     4   9 my ($self,$pkt,$error,$name) = @_;
608 4 50       11 if ($error) {
609 0 0       0 $DEBUG && debug("error getting DKIM record for $name");
610 0         0 $self->{dkim_result} = [ $self->{dkim}->next({ $name => undef }) ];
611             } else {
612 4 50       11 my @txt = map { $_->type eq 'TXT' ? ($_->txtdata) : () } $pkt->answer;
  4         28  
613 4 50       209 $DEBUG && debug("got %d txt records for $name",int(@txt));
614 4         20 $self->{dkim_result} = [ $self->{dkim}->next({ $name => \@txt }) ];
615             }
616             }
617              
618             sub _feed_spf {
619 3     3   8 my ($self,$pkt,$error) = @_;
620 3 50       8 if ($error) {
621 0         0 $self->{spf_result} = [ $self->{spf}->next([ $pkt,$error ]) ];
622             } else {
623 3         13 my @rv = $self->{spf}->next($pkt);
624             # Mail::SPF::Iterator returns '' as result if there are still
625             # open questions and it needs input from these
626 3 50 33     1311 if (!defined $rv[0] || $rv[0] ne '') {
627 3         11 $self->{spf_result} = \@rv;
628             } else {
629             # ask SPF object for the open todos
630 0         0 $self->{spf_result} = [ undef, $self->{spf}->todo ];
631             }
632             }
633             }
634              
635             # Extract information from header. We need:
636             # - domain of From header
637             # - information from Received-SPF header if no SPF object
638              
639             sub _inspect_header {
640 8     8   17 my ($self,$data) = @_;
641 8         11 my @hdr;
642              
643             # on EOF analyze the last field in the header
644 8 50       20 goto analyze if $data eq '';
645              
646             # Extract full headers from mail, i.e. make sure that no more parts of the
647             # header line could follow (incl. line folding).
648             # Look out for end of header too.
649 8         28 $self->{_hdrbuf} .= $data;
650 8         651 while ( $self->{_hdrbuf} =~m{\G
651             (
652             (?:\S.*?) # line starting with no space (hopefully key:...)
653             (?:\n[ \t].*?)* # optional line folding
654             )
655             \r?\n
656             (?=(\r?\n)|([^ \t\r\n]))
657             }xgc) {
658 31         77 push @hdr,$1;
659 31 100       407 if ($2) {
660             # empty line: end of header
661 7 50       17 $DEBUG && debug("end of mail header");
662 7         12 $self->{_hdrbuf} = undef;
663 7         13 last;
664             }
665             }
666             # remove what we extracted from the header
667             substr($self->{_hdrbuf},0,pos($self->{_hdrbuf}),'')
668 8 50 66     36 if @hdr && defined $self->{_hdrbuf};
669              
670             # Look for useful stuff in @hdr
671             # RFC 2822 does not allow white-space before colon but RFC 822 did.
672             # Because we never know what the MUA does we accept it for the From
673             # header, but not for the Received-SPF header.
674 8         21 for(@hdr) {
675             ($self->{spf} || $self->{spf_result})
676 31 50 66     161 ? s{^(From)\s*:\s*}{}i
    100          
677             : s{^(?:(From)\s*|Received-SPF):\s*}{}i
678             or next;
679 7 50       25 if($1) {
680             # From
681 7 50       16 $DEBUG && debug("mail header from: $_");
682 7   50     12 push @{ $self->{_from} ||= [] }, _extract_domains_from_address($_);
  7         37  
683             } else {
684             # Received-SPF
685 0 0       0 $DEBUG && debug("mail header received-spf: $_");
686 0   0     0 push @{ $self->{_spfr} ||= [] }, $_;
  0         0  
687             }
688             }
689              
690             analyze:
691 8 100       22 if (defined $self->{_hdrbuf}) {
692 1 50       5 return if $data ne ''; # no end of header yet, collect more
693             # end of data = end of header - set to undef to no longer collect data
694 0         0 $self->{_hdrbuf} = undef;
695             }
696              
697             # header done
698 7 50       17 if (!$self->{domain}) {
699 7         16 my $from = delete $self->{_from};
700 7 50       25 if (!$from) {
    50          
701 0 0       0 $DEBUG && debug("DMARC no usable From header found");
702 0         0 $self->{result} = [ DMARC_PERMERROR, 'no sender domain in From' ];
703 0         0 return;
704             } elsif (@$from!=1) {
705 0 0       0 $DEBUG && debug("DMARC multiple domains in From");
706 0         0 $self->{result} = [ DMARC_PERMERROR,
707             'multiple sender domains in From' ];
708 0         0 return;
709             }
710 7         16 $self->{domain} = [ $from->[0] ];
711 7 50       25 if (my $dom = organizational_domain($from->[0])) {
712 7 50       644 push @{$self->{domain}}, $dom if $dom ne $from->[0];
  0         0  
713             }
714             # Check for DMARC record in from-domain. If nothing is found check in
715             # organizational domain.
716 7 50       17 $DEBUG && debug("domains from: @{$self->{domain}}");
  0         0  
717 7         10 $self->{_dmarc_domain} = [ @{$self->{domain}} ];
  7         21  
718             }
719              
720 7 50 33     34 if (!$self->{spf_result} && !$self->{spf}) {
721 0         0 my @records;
722 0 0       0 for(@{ delete $self->{_spfr} || [] }) {
  0         0  
723 0 0       0 my ($result,$hash) = _parse_spfreceived($_) or next;
724 0 0       0 my $from = $hash->{'envelope-from'} or do {
725 0 0       0 $DEBUG && debug(
726             "skip Received-SPF because of no envelope-from: $_");
727 0         0 next;
728             };
729 0         0 my @dom = _extract_domains_from_address($from);
730 0 0       0 @dom == 1 or next;
731             $DEBUG && debug("found Received-SPF: $result ".
732 0 0       0 join(" ",map { "$_=$hash->{$_}" } sort keys %$hash));
  0         0  
733 0         0 push @records, [ $result, $hash ];
734             }
735 0 0       0 $self->{spf_result} = @records ? [ \@records ] : [ SPF_None ];
736             }
737             }
738              
739             {
740             # Extract domains from addresslist.
741             my $addr = qr{[^\s<>@]+\@([\w\-.]+)};
742             sub _extract_domains_from_address {
743 7     7   15 local $_ = shift;
744 7         14 s{\r?\n([ \t])}{$1}sg;
745 7         23 my (@state,%domains);
746 7         10 while (1) {
747 14 50       35 if (!@state) {
    0          
    0          
    0          
748 14 50       69 m{\G ([^<,\"\(]*) (?: ([<\(\"]) | (,) | \z) }xgc or last;
749 14 50       55 if ($2) {
    100          
    50          
750 0         0 push @state,$2
751             } elsif ($1 ne '') {
752 7 50       175 $domains{lc($1)}++ if (my $x = $1) =~ m{^\s*$addr\s*\z};
753             } elsif (!$3) {
754 7         44 last; # end of string
755             }
756             } elsif ($state[-1] eq '<') {
757             # address - extract domain
758 0 0       0 m{\G(?: $addr | (?:[^>]*) ) > }xgc or last; # missing final '>'
759 0         0 pop @state;
760 0 0       0 $domains{lc($1)}++ if $1;
761             } elsif ($state[-1] eq '"') {
762             # skip quoted text
763 0 0       0 m{\G (?:[^"\\]+|\\.)* \"}xgc or last; # missing final \"
764 0         0 pop @state;
765             } elsif ($state[-1] eq '(') {
766             # skip comments (can be nested)
767 0 0       0 m{\G .*? ([()]) }xsgc or last; # missing final ')'
768 0 0       0 if ($1 eq ')') {
769 0         0 pop @state;
770             } else {
771 0         0 push @state,'('
772             }
773             }
774             }
775 7 50       21 $DEBUG && debug("extract: $_ -> ".join(" ",sort keys %domains));
776 7         43 return sort keys %domains;
777             }
778             }
779              
780             {
781             # Parse Received-SPF header into (result,\%hash).
782             my %res;
783             $res{ lc($_) } = $_ for(SPF_Pass, SPF_Fail, SPF_SoftFail, SPF_Neutral,
784             SPF_None, SPF_TempError, SPF_PermError);
785             my $res = join("|",keys %res);
786             $res = qr{$res}i;
787             my $fws = qr{(?:[ \t]*\r?\n)?[ \t]+};
788             my $key = qr{\w[\w\-]*};
789             my $atext = qr{[0-9a-zA-Z!#$%&'*+\-/=?^_`{|}~]+};
790             my $dotatom = qr{$atext(?:\.$atext)*};
791             my $qstring = qr{"(?:[^"\\]+|\\.)*"};
792             my $val = qr{$dotatom|$qstring};
793              
794             sub _parse_spfreceived {
795 0     0   0 local $_ = shift;
796 0 0       0 m{\G($res)\s+}igc or return;
797 0         0 my $result = $res{ lc($1) };
798 0         0 my %hash;
799             my $comment;
800 0         0 while (1) {
801 0 0 0     0 if ($comment) {
    0 0        
    0          
802 0 0       0 last if ! m{\G[^()]*([()])\s*}gc; # no end of comment found
803 0 0       0 $comment += $1 eq '(' ? +1:-1;
804             } elsif (m{\G($key)$fws?=$fws?($val)\s*(;\s*)?}gc) {
805 0         0 my ($k,$v,$delim) = ($1,$2,$3);
806 0 0       0 $v =~s{\\(.)}{$1}g if $v =~s{\A\"(.*)\"\z}{$1};
807 0         0 $hash{$k} = $v;
808 0 0       0 last if ! $delim; # no delimeter-> end
809             } elsif (!%hash && !defined $comment && m{\G\(}gc) {
810 0         0 $comment++;
811             } else {
812             last
813 0         0 }
814             }
815 0         0 return ($result,\%hash);
816             }
817             }
818              
819             {
820             # Define function organizational_domain based on which package we have to
821             # calculate the public suffix.
822             if (eval { require IO::Socket::SSL::PublicSuffix }) {
823             my $ps = IO::Socket::SSL::PublicSuffix->default;
824             *organizational_domain = sub {
825 7   33 7   32 return $ps->public_suffix($_[0],1) || $_[0];
826             };
827             } elsif (eval { require Domain::PublicSuffix }) {
828             my $ps = Domain::PublicSuffix->new;
829             *organizational_domain = sub {
830             return $ps->get_root_domain($_[0]) || $_[0];
831             };
832              
833             } elsif (eval { require Mozilla::PublicSuffix }) {
834             *organizational_domain = sub {
835             my $domain = shift;
836             if (my $suffix = Mozilla::PublicSuffix::public_suffix($domain)) {
837             return $1 if $domain =~m{([^\.]+\.\Q$suffix\E$)}i;
838             }
839             return $domain;
840             }
841             } else {
842             die "failed to find any package for calculating the public suffix";
843             }
844             }
845              
846             1;
847              
848             __END__