File Coverage

blib/lib/Mail/Milter/Authentication/Handler/IPRev.pm
Criterion Covered Total %
statement 111 159 69.8
branch 33 74 44.5
condition 1 2 50.0
subroutine 12 15 80.0
pod 1 6 16.6
total 158 256 61.7


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