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   17113 use 5.20.0;
  29         148  
3 29     29   193 use strict;
  29         121  
  29         725  
4 29     29   227 use warnings;
  29         101  
  29         977  
5 29     29   216 use Mail::Milter::Authentication::Pragmas;
  29         96  
  29         286  
6             # ABSTRACT: Handler class for IPRev
7             our $VERSION = '3.20230911'; # VERSION
8 29     29   7127 use base 'Mail::Milter::Authentication::Handler';
  29         125  
  29         2782  
9 29     29   742 use Net::DNS;
  29         2977  
  29         3148  
10 29     29   217 use Net::IP;
  29         105  
  29         68938  
11              
12             sub default_config {
13 0     0 0 0 return {};
14             }
15              
16             sub register_metrics {
17             return {
18 28     28 1 297 '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 243 my ($self) = @_;
49 20         331 my @requires = qw{ LocalIP TrustedIP };
50 20         633 return \@requires;
51             }
52              
53             sub connect_callback {
54 68     68 0 324 my ( $self, $hostname, $ip ) = @_;
55 68 100       676 return if ( $self->is_local_ip_address() );
56 60 100       470 return if ( $self->is_trusted_ip_address() );
57 58         836 my $ip_address = $self->ip_address();
58 58         440 my $i1 = $ip;
59 58         469 my $resolver = $self->get_object('resolver');
60              
61 58         176 my $lookup_limit = 10;
62             # Make this a config item
63              
64 58         181 my $ptr_list = {};
65 58         197 my @error_list;
66              
67             my @cname_hosts;
68              
69 58         1677 my $packet = $resolver->query( $ip_address, 'PTR' );
70 58         28752 $lookup_limit--;
71 58 100       295 if ($packet) {
72 22         157 foreach my $rr ( $packet->answer ) {
73 22 100       432 if ( $rr->type eq "CNAME" ) {
74 2         121 push @cname_hosts, $rr->rdstring;
75 2         273 push @error_list, 'Found CNAME in PTR response';
76             }
77 22 100       599 if ( $rr->type eq "PTR" ) {
78 20         733 $ptr_list->{ $rr->rdstring } = [];
79             }
80             }
81             }
82              
83 58 50       3362 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         775 foreach my $cname_host ( @cname_hosts ) {
89 2         14 my $packet = $resolver->query( $cname_host, 'PTR' );
90 2         500 $lookup_limit--;
91 2 50       14 if ($packet) {
92 2         9 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       22 if ( $rr->type eq "PTR" ) {
98 2         52 $ptr_list->{ $rr->rdstring } = [];
99             }
100             }
101             }
102 2 50       183 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         26 last; # Because multiple CNAMES for a given record is also busted
107             }
108              
109 58 100       378 if ( ! keys %$ptr_list ) {
110 36         200 push @error_list, "NOT FOUND";
111             }
112              
113 58         327 my @lookup_list = sort keys %$ptr_list;
114             DOMAINLOOKUP:
115 58         254 foreach my $domain ( @lookup_list ) {
116 22         76 my $ip_list = [];
117 22         53 my $cname;
118              
119 22 50       164 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         96 my $errors6;
156             my $errors4;
157 22         170 ( $lookup_limit, $ip_list, $errors4, $cname ) = $self->_address_for_domain( 'A', $domain, $lookup_limit );
158 22 100       120 if ( $cname ) {
159 2         9 push @error_list, 'Found CNAME in A response';
160 2         10 ( $lookup_limit, $ip_list, $errors6, $cname ) = $self->_address_for_domain( 'A', $cname, $lookup_limit );
161 2 50       13 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       146 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       164 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         94 $ptr_list->{ $domain } = $ip_list;
188              
189             }
190              
191 58         178 my @match_list;
192 58         292 foreach my $domain ( sort keys %$ptr_list ) {
193 22         67 foreach my $address ( sort @{ $ptr_list->{ $domain } } ) {
  22         94  
194 22         138 my $i2 = Net::IP->new($address);
195 22 50       17053 if ( !$i2 ) {
196 0         0 $self->log_error( 'IPRev: Could not parse IP '.$address );
197             }
198             else {
199 22   50     231 my $is_overlap = $i1->overlaps($i2) || 0;
200 22 50       1218 if ( $is_overlap == $IP_IDENTICAL ) {
201 22         146 $domain =~ s/\.$//;
202 22         163 push @match_list, $domain;
203             }
204             }
205             }
206             }
207              
208 58 100       283 if ( ! @match_list ) {
209             # Failed to match IP against looked up domains
210 36         170 my $comment = join( ',', @error_list );
211 36         321 $self->dbgout( 'IPRevCheck', "fail - $comment", LOG_DEBUG );
212 36         1498 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'fail' );
213 36         5456 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) );
214 36         5883 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
215 36         9267 $self->add_c_auth_header($header);
216 36         435 $self->metric_count( 'iprev_total', { 'result' => 'fail'} );
217             }
218             else {
219             # We have a pass
220 22         100 my $comment = join( ',', @match_list );
221 22         119 $self->{'verified_ptr'} = $comment;
222 22         204 $self->dbgout( 'IPRevCheck', "pass - $comment", LOG_DEBUG );
223 22         647 my $header = Mail::AuthenticationResults::Header::Entry->new()->set_key( 'iprev' )->safe_set_value( 'pass' );
224 22         2716 $header->add_child( Mail::AuthenticationResults::Header::SubEntry->new()->set_key( 'smtp.remote-ip' )->safe_set_value( $ip_address ) );
225 22         3178 $header->add_child( Mail::AuthenticationResults::Header::Comment->new()->safe_set_value( $comment ) );
226 22         9463 $self->add_c_auth_header($header);
227 22         222 $self->metric_count( 'iprev_total', { 'result' => 'pass'} );
228             }
229             }
230              
231             sub _address_for_domain {
232 24     24   111 my ( $self, $type, $domain, $lookup_limit ) = @_;
233              
234 24         129 my @fwd_errors;
235             my @ip_list;
236 24         0 my $cname;
237              
238 24         135 my $resolver = $self->get_object('resolver');
239              
240 24         108 $lookup_limit--;
241 24 50       146 if ( $lookup_limit <= 0 ) {
242 0         0 return ( 0, \@ip_list, [ 'Lookup limit reached' ] );
243             }
244 24         108 my $packet = $resolver->query( $domain, $type );
245              
246 24 50       4829 if ($packet) {
247 24         165 foreach my $rr ( $packet->answer ) {
248 24 100       340 if ( lc $rr->type eq 'cname' ) {
249 2         33 $cname = $rr->rdstring;
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       695 if ( lc $rr->type eq lc $type ) {
254 22         522 push @ip_list, $rr->rdstring;
255             }
256             }
257             }
258              
259 24 50       1812 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         519 return ( $lookup_limit, \@ip_list, \@fwd_errors, $cname );
265             }
266              
267             sub close_callback {
268 104     104 0 361 my ( $self ) = @_;
269 104         398 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.20230911
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