File Coverage

blib/lib/Mail/Milter/Authentication/Handler/IPRev.pm
Criterion Covered Total %
statement 120 168 71.4
branch 33 74 44.5
condition 1 2 50.0
subroutine 14 17 82.3
pod 1 6 16.6
total 169 267 63.3


line stmt bran cond sub pod time code
1             package Mail::Milter::Authentication::Handler::IPRev;
2 26     26   12314 use strict;
  26         75  
  26         752  
3 26     26   137 use warnings;
  26         66  
  26         698  
4 26     26   136 use base 'Mail::Milter::Authentication::Handler';
  26         61  
  26         2808  
5             our $VERSION = '20191206'; # VERSION
6              
7 26     26   5259 use Net::DNS;
  26         22103  
  26         2345  
8 26     26   207 use Net::IP;
  26         61  
  26         5294  
9 26     26   185 use Sys::Syslog qw{:standard :macros};
  26         59  
  26         7112  
10 26     26   201 use Mail::AuthenticationResults::Header::Entry;
  26         53  
  26         581  
11 26     26   132 use Mail::AuthenticationResults::Header::SubEntry;
  26         55  
  26         654  
12 26     26   144 use Mail::AuthenticationResults::Header::Comment;
  26         57  
  26         39898  
13              
14             sub default_config {
15 0     0 0 0 return {};
16             }
17              
18             sub register_metrics {
19             return {
20 25     25 1 171 'iprev_total' => 'The number of emails processed for IPRev',
21             };
22             }
23              
24             sub grafana_rows {
25 0     0 0 0 my ( $self ) = @_;
26 0         0 my @rows;
27 0         0 push @rows, $self->get_json( 'IPRev_metrics' );
28 0         0 return \@rows;
29             }
30              
31             sub _dns_error {
32 0     0   0 my ( $self, $type, $data, $error ) = @_;
33 0 0       0 if ( $error eq 'NXDOMAIN' ) {
    0          
34 0         0 $self->dbgout( "DNS $type Lookup", "$data gave $error", LOG_DEBUG );
35             }
36             elsif ( $error eq 'NOERROR' ) {
37 0         0 $self->dbgout( "DNS $type Lookup", "$data gave $error", LOG_DEBUG );
38             }
39             else {
40             # Could be SERVFAIL or something else
41 0         0 $self->log_error(
42             'DNS ' . $type . ' query failed for '
43             . $data
44             . ' with '
45             . $error );
46             }
47 0         0 return;
48             }
49              
50             sub connect_requires {
51 17     17 0 136 my ($self) = @_;
52 17         248 my @requires = qw{ LocalIP TrustedIP Auth };
53 17         187 return \@requires;
54             }
55              
56             sub connect_callback {
57 68     68 0 276 my ( $self, $hostname, $ip ) = @_;
58 68 100       434 return if ( $self->is_local_ip_address() );
59 60 100       293 return if ( $self->is_trusted_ip_address() );
60 58 50       450 return if ( $self->is_authenticated() );
61 58         419 my $ip_address = $self->ip_address();
62 58         429 my $i1 = $ip;
63 58         525 my $resolver = $self->get_object('resolver');
64              
65 58         185 my $lookup_limit = 10;
66             # Make this a config item
67              
68 58         163 my $ptr_list = {};
69 58         168 my @error_list;
70              
71             my @cname_hosts;
72              
73 58         1475 my $packet = $resolver->query( $ip_address, 'PTR' );
74 58         107850 $lookup_limit--;
75 58 100       261 if ($packet) {
76 22         196 foreach my $rr ( $packet->answer ) {
77 22 100       230 if ( $rr->type eq "CNAME" ) {
78 2         50 push @cname_hosts, $rr->rdatastr;
79 2         213 push @error_list, 'Found CNAME in PTR response';
80             }
81 22 100       262 if ( $rr->type eq "PTR" ) {
82 20         464 $ptr_list->{ $rr->rdatastr } = [];
83             }
84             }
85             }
86              
87 58 50       3291 if ( $resolver->errorstring() ) {
88 0         0 $self->_dns_error( 'PTR', $ip_address, $resolver->errorstring );
89 0 0       0 push @error_list, 'Error ' . $resolver->errorstring() . " looking up $ip_address PTR" if ( $resolver->errorstring() ne 'unknown error or no error' );
90             }
91              
92 58         657 foreach my $cname_host ( @cname_hosts ) {
93 2         8 my $packet = $resolver->query( $cname_host, 'PTR' );
94 2         2612 $lookup_limit--;
95 2 50       10 if ($packet) {
96 2         8 foreach my $rr ( $packet->answer ) {
97             #if ( $rr->type eq "CNAME" ) {
98             # NO! We only follow the first level CNAMES
99             # Because anything more is probably busted anyway
100             #}
101 2 50       20 if ( $rr->type eq "PTR" ) {
102 2         29 $ptr_list->{ $rr->rdatastr } = [];
103             }
104             }
105             }
106 2 50       122 if ( $resolver->errorstring() ) {
107 0         0 $self->_dns_error( 'PTR', $cname_host, $resolver->errorstring );
108 0 0       0 push @error_list, 'Error ' . $resolver->errorstring() . " looking up $cname_host PTR" if ( $resolver->errorstring() ne 'unknown error or no error' );
109             }
110 2         22 last; # Because multiple CNAMES for a given record is also busted
111             }
112              
113 58 100       343 if ( ! keys %$ptr_list ) {
114 36         139 push @error_list, "NOT FOUND";
115             }
116              
117 58         260 my @lookup_list = sort keys %$ptr_list;
118             DOMAINLOOKUP:
119 58         202 foreach my $domain ( @lookup_list ) {
120 22         59 my $ip_list = [];
121 22         47 my $cname;
122              
123 22 50       162 if ( $ip_address =~ /:/ ) {
124             # We are living in the future!
125              
126 0         0 my $errors6;
127             my $errors4;
128 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $domain, $lookup_limit );
129 0 0       0 if ( $cname ) {
130 0         0 push @error_list, 'Found CNAME in AAAA response';
131 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit );
132 0 0       0 if ( ! @$ip_list ) {
133 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit );
134             }
135             }
136 0 0       0 if ( ! @$ip_list ) {
137             # We got nothing, try ip4
138 0         0 ( $lookup_limit, $ip_list, $errors4, $cname ) = $self->_address_for_domain( 'A', $domain, $lookup_limit );
139 0 0       0 if ( $cname ) {
140 0         0 push @error_list, 'Found CNAME in A response';
141 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit );
142 0 0       0 if ( ! @$ip_list ) {
143 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit );
144             }
145             }
146             }
147 0 0       0 if ( ! @$ip_list ) {
148 0         0 foreach my $error ( @$errors4 ) {
149 0         0 push @error_list, "Error $error looking up $domain A";
150             }
151 0         0 foreach my $error ( @$errors6 ) {
152 0         0 push @error_list, "Error $error looking up $domain AAAA";
153             }
154             }
155              
156             }
157             else {
158              
159 22         64 my $errors6;
160             my $errors4;
161 22         126 ( $lookup_limit, $ip_list, $errors4, $cname ) = $self->_address_for_domain( 'A', $domain, $lookup_limit );
162 22 100       102 if ( $cname ) {
163 2         6 push @error_list, 'Found CNAME in A response';
164 2         11 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit );
165 2 50       12 if ( ! @$ip_list ) {
166 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit );
167             }
168             }
169 22 50       97 if ( ! @$ip_list ) {
170             # We got nothing, try ip6
171 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $domain, $lookup_limit );
172 0 0       0 if ( $cname ) {
173 0         0 push @error_list, 'Found CNAME in AAAA response';
174 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit );
175 0 0       0 if ( ! @$ip_list ) {
176 0         0 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'AAAA', $cname, $lookup_limit );
177             }
178             }
179             }
180 22 50       103 if ( ! @$ip_list ) {
181 0         0 foreach my $error ( @$errors4 ) {
182 0 0       0 push @error_list, "Error $error looking up $domain A" if ( $resolver->errorstring() ne 'unknown error or no error' );
183             }
184 0         0 foreach my $error ( @$errors6 ) {
185 0 0       0 push @error_list, "Error $error looking up $domain AAAA" if ( $resolver->errorstring() ne 'unknown error or no error' );
186             }
187             }
188              
189             }
190              
191 22         85 $ptr_list->{ $domain } = $ip_list;
192              
193             }
194              
195 58         146 my @match_list;
196 58         276 foreach my $domain ( sort keys %$ptr_list ) {
197 22         59 foreach my $address ( sort @{ $ptr_list->{ $domain } } ) {
  22         82  
198 22         123 my $i2 = Net::IP->new($address);
199 22   50     14609 my $is_overlap = $i1->overlaps($i2) || 0;
200 22 50       1097 if ( $is_overlap == $IP_IDENTICAL ) {
201 22         150 $domain =~ s/\.$//;
202 22         138 push @match_list, $domain;
203             }
204             }
205             }
206              
207 58 100       267 if ( ! @match_list ) {
208             # Failed to match IP against looked up domains
209 36         138 my $comment = join( ',', @error_list );
210 36         234 $self->dbgout( 'IPRevCheck', "fail - $comment", LOG_DEBUG );
211 36         1154 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'fail' );
212 36         4379 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) );
213 36         5076 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
214 36         7954 $self->add_c_auth_header($header);
215 36         819 $self->metric_count( 'iprev_total', { 'result' => 'fail'} );
216             }
217             else {
218             # We have a pass
219 22         81 my $comment = join( ',', @match_list );
220 22         90 $self->{'verified_ptr'} = $comment;
221 22         172 $self->dbgout( 'IPRevCheck', "pass - $comment", LOG_DEBUG );
222 22         528 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'pass' );
223 22         2256 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) );
224 22         2786 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
225 22         8799 $self->add_c_auth_header($header);
226 22         174 $self->metric_count( 'iprev_total', { 'result' => 'pass'} );
227             }
228              
229 58         373 return;
230             }
231              
232             sub _address_for_domain {
233 24     24   105 my ( $self, $type, $domain, $lookup_limit ) = @_;
234              
235 24         98 my @fwd_errors;
236             my @ip_list;
237 24         0 my $cname;
238              
239 24         111 my $resolver = $self->get_object('resolver');
240              
241 24         69 $lookup_limit--;
242 24 50       94 if ( $lookup_limit <= 0 ) {
243 0         0 return ( 0, \@ip_list, [ 'Lookup limit reached' ] );
244             }
245 24         92 my $packet = $resolver->query( $domain, $type );
246              
247 24 50       30388 if ($packet) {
248 24         96 foreach my $rr ( $packet->answer ) {
249 24 100       182 if ( lc $rr->type eq 'cname' ) {
250 2         27 $cname = $rr->rdatastr;
251             # Multiple CNAMES are broken, but we don't check for that
252             # We just take the last one we found
253             }
254 24 100       446 if ( lc $rr->type eq lc $type ) {
255 22         311 push @ip_list, $rr->rdatastr;
256             }
257             }
258             }
259              
260 24 50       1308 if ( $resolver->errorstring() ) {
261 0         0 $self->_dns_error( $type, $domain, $resolver->errorstring );
262 0         0 push @fwd_errors, 'Error ' . $resolver->errorstring() . " looking up $domain $type";
263             }
264              
265 24         403 return ( $lookup_limit, \@ip_list, \@fwd_errors, $cname );
266             }
267              
268             sub close_callback {
269 104     104 0 309 my ( $self ) = @_;
270 104         283 delete $self->{'verified_ptr'};
271 104         284 return;
272             }
273              
274             1;
275              
276             __END__
277              
278             =pod
279              
280             =encoding UTF-8
281              
282             =head1 NAME
283              
284             Mail::Milter::Authentication::Handler::IPRev
285              
286             =head1 VERSION
287              
288             version 20191206
289              
290             =head1 DESCRIPTION
291              
292             Check reverse IP lookups.
293              
294             =head1 CONFIGURATION
295              
296             No configuration options exist for this handler.
297              
298             =head1 AUTHOR
299              
300             Marc Bradshaw <marc@marcbradshaw.net>
301              
302             =head1 COPYRIGHT AND LICENSE
303              
304             This software is copyright (c) 2018 by Marc Bradshaw.
305              
306             This is free software; you can redistribute it and/or modify it under
307             the same terms as the Perl 5 programming language system itself.
308              
309             =cut