File Coverage

blib/lib/Mail/SPF/Util.pm
Criterion Covered Total %
statement 64 104 61.5
branch 11 50 22.0
condition 17 33 51.5
subroutine 21 26 80.7
pod 7 8 87.5
total 120 221 54.3


line stmt bran cond sub pod time code
1             #
2             # Mail::SPF::Util
3             # Mail::SPF utility class.
4             #
5             # (C) 2005-2012 Julian Mehnle
6             # 2005 Shevek
7             # $Id: Util.pm 57 2012-01-30 08:15:31Z julian $
8             #
9             ##############################################################################
10              
11             package Mail::SPF::Util;
12              
13             =head1 NAME
14              
15             Mail::SPF::Util - Mail::SPF utility class
16              
17             =head1 VERSION
18              
19             version 3.20250505
20              
21             =cut
22              
23 6     6   98705 use warnings;
  6         16  
  6         453  
24 6     6   34 use strict;
  6         10  
  6         159  
25              
26 6     6   1537 use utf8; # Hack to keep Perl 5.6 from whining about /[\p{}]/.
  6         808  
  6         36  
27              
28 6     6   271 use base 'Mail::SPF::Base';
  6         15  
  6         1217  
29              
30 6     6   44 use Mail::SPF::Exception;
  6         11  
  6         83  
31              
32 6     6   435 use Error ':try';
  6         18  
  6         27  
33 6     6   3957 use Sys::Hostname ();
  6         9826  
  6         203  
34 6     6   2192 use NetAddr::IP;
  6         98774  
  6         49  
35              
36 6     6   1021 use constant TRUE => (0 == 0);
  6         14  
  6         640  
37 6     6   56 use constant FALSE => not TRUE;
  6         13  
  6         691  
38              
39 6         7365 use constant ipv4_mapped_ipv6_address_pattern =>
40 6     6   61 qr/^::ffff:(\p{IsXDigit}{1,4}):(\p{IsXDigit}{1,4})/i;
  6         12  
41              
42             # Interface:
43             ##############################################################################
44              
45             =head1 SYNOPSIS
46              
47             use Mail::SPF::Util;
48              
49             $hostname = Mail::SPF::Util->hostname;
50              
51             $ipv6_address_v4mapped =
52             Mail::SPF::Util->ipv4_address_to_ipv6($ipv4_address);
53              
54             $ipv4_address =
55             Mail::SPF::Util->ipv6_address_to_ipv4($ipv6_address_v4mapped);
56              
57             $is_v4mapped =
58             Mail::SPF::Util->ipv6_address_is_ipv4_mapped($ipv6_address);
59              
60             $ip_address_string = Mail::SPF::Util->ip_address_to_string($ip_address);
61             $reverse_name = Mail::SPF::Util->ip_address_reverse($ip_address);
62              
63             $validated_domain = Mail::SPF::Util->valid_domain_for_ip_address(
64             $spf_server, $request,
65             $ip_address, $domain,
66             $find_best_match, # defaults to false
67             $accept_any_domain # defaults to false
68             );
69              
70             $sanitized_string = Mail::SPF::Util->sanitize_string($string);
71              
72             =cut
73              
74             # Implementation:
75             ##############################################################################
76              
77             =head1 DESCRIPTION
78              
79             B is Mail::SPF's utility class.
80              
81             =head2 Class methods
82              
83             The following class methods are provided:
84              
85             =over
86              
87             =item B: returns I
88              
89             Returns the fully qualified domain name (FQDN) of the local host.
90              
91             =cut
92              
93             my $hostname;
94              
95             sub hostname {
96 7     7 0 15 my ($self) = @_;
97 7   66     47 return $hostname ||= (gethostbyname(Sys::Hostname::hostname))[0];
98             # Thanks to Sys::Hostname::FQDN for that trick!
99             }
100              
101             =item B: returns I; throws
102             I
103              
104             Converts the specified I IPv4 address into an IPv4-mapped IPv6
105             address. Throws a I exception if the specified
106             IP address is not an IPv4 address.
107              
108             =cut
109              
110             sub ipv4_address_to_ipv6 {
111 14     14 1 175124 my ($self, $ipv4_address) = @_;
112 14 100 100     113 UNIVERSAL::isa($ipv4_address, 'NetAddr::IP') and
113             $ipv4_address->version == 4
114             or throw Mail::SPF::EInvalidOptionValue('NetAddr::IP IPv4 address expected');
115 12         156 return NetAddr::IP->new(
116             '::ffff:' . $ipv4_address->addr, # address
117             $ipv4_address->masklen - 32 + 128 # netmask length
118             );
119             }
120              
121             =item B: returns I; throws
122             I
123              
124             Converts the specified I IPv4-mapped IPv6 address into a proper
125             IPv4 address. Throws a I exception if the
126             specified IP address is not an IPv4-mapped IPv6 address.
127              
128             =cut
129              
130             sub ipv6_address_to_ipv4 {
131 6     6 1 2956 my ($self, $ipv6_address) = @_;
132 6 100 100     46 UNIVERSAL::isa($ipv6_address, 'NetAddr::IP') and
      100        
133             $ipv6_address->version == 6 and
134             $ipv6_address->short =~ $self->ipv4_mapped_ipv6_address_pattern
135             or throw Mail::SPF::EInvalidOptionValue('NetAddr::IP IPv4-mapped IPv6 address expected');
136 3 50       1580 return NetAddr::IP->new(
137             join('.', unpack('C4', pack('H8', sprintf('%04s%04s', $1, $2)))), # address
138             $ipv6_address->masklen >= 128 - 32 ? $ipv6_address->masklen - 128 + 32 : 0 # netmask length
139             );
140             }
141              
142             =item B: returns I
143              
144             Returns B if the specified I IPv6 address is an IPv4-mapped
145             address, B otherwise.
146              
147             =cut
148              
149             sub ipv6_address_is_ipv4_mapped {
150 18     18 1 1651 my ($self, $ipv6_address) = @_;
151             return (
152 18   100     125 UNIVERSAL::isa($ipv6_address, 'NetAddr::IP') and
153             $ipv6_address->version == 6 and
154             $ipv6_address->short =~ $self->ipv4_mapped_ipv6_address_pattern
155             );
156             }
157              
158             =item B: returns I;
159             throws I
160              
161             Returns the given I IPv4 or IPv6 address compactly formatted as a
162             I. For IPv4 addresses, this is equivalent to calling L
163             C|NetAddr::IP/addr> method. For IPv6 addresses, this is equivalent to
164             calling L|NedAddr::IP/short> method. Throws a
165             I exception if the specified object is not a
166             I IPv4 or IPv6 address object.
167              
168             =cut
169              
170             sub ip_address_to_string {
171 0     0 1 0 my ($self, $ip_address) = @_;
172 0 0 0     0 UNIVERSAL::isa($ip_address, 'NetAddr::IP') and
      0        
173             ($ip_address->version == 4 or $ip_address->version == 6)
174             or throw Mail::SPF::EInvalidOptionValue('NetAddr::IP IPv4 or IPv6 address expected');
175 0 0       0 return $ip_address->version == 4 ? $ip_address->addr : lc($ip_address->short);
176             }
177              
178             =item B: returns I;
179             throws I
180              
181             Returns the C/C reverse notation of the given
182             I IPv4 or IPv6 address. Throws a I
183             exception if the specified object is not a I IPv4 or IPv6 address
184             object.
185              
186             =cut
187              
188             sub ip_address_reverse {
189 3     3 1 3177 my ($self, $ip_address) = @_;
190 3 50 66     27 UNIVERSAL::isa($ip_address, 'NetAddr::IP') and
      33        
191             ($ip_address->version == 4 or $ip_address->version == 6)
192             or throw Mail::SPF::EInvalidOptionValue('NetAddr::IP IPv4 or IPv6 address expected');
193             try {
194             # Treat IPv4-mapped IPv6 addresses as IPv4 addresses:
195 3 100   3   117 if($ip_address->version == 6) {
196 2         11 $ip_address = $self->ipv6_address_to_ipv4($ip_address);
197             }
198             }
199 3     1   55 catch Mail::SPF::EInvalidOptionValue with {};
200             # ...deliberately ignoring conversion errors.
201 3 100       291 if ($ip_address->version == 4) {
    50          
202 2         59 my @octets = split(/\./, $ip_address->addr);
203 2         208 @octets = @octets[0 .. int($ip_address->masklen / 8) - 1];
204 2         41 return join('.', reverse(@octets)) . '.in-addr.arpa.';
205             }
206             elsif ($ip_address->version == 6) {
207 1         34 my @nibbles = split(//, unpack("H32", $ip_address->aton));
208 1         20 @nibbles = @nibbles[0 .. int($ip_address->masklen / 4) - 1];
209 1         35 return join('.', reverse(@nibbles)) . '.ip6.arpa.';
210             }
211             }
212              
213             =item B
214             $find_best_match = false, $accept_any_domain = false)>:
215             returns I or B
216              
217             Finds a valid domain name for the given I IP address that matches
218             the given domain or a sub-domain thereof. A domain name is valid for the given
219             IP address if the IP address reverse-maps to that domain name in DNS, and the
220             domain name in turn forward-maps to the IP address. Uses the given
221             I and I objects to perform DNS look-ups.
222             Returns the validated domain name.
223              
224             If C<$find_best_match> is B, the one domain name is selected that best
225             matches the given domain name, preferring direct matches over sub-domain
226             matches. Defaults to B.
227              
228             If C<$accept_any_domain> is B, I domain names are considered
229             acceptable, even if they differ completely from the given domain name (which
230             is then effectively unused unless a best match is requested). Defaults to
231             B.
232              
233             =cut
234              
235 6     6   54 use constant valid_domain_match_none => 0;
  6         12  
  6         509  
236 6     6   38 use constant valid_domain_match_subdomain => 1;
  6         28  
  6         445  
237 6     6   68 use constant valid_domain_match_identical => 2;
  6         13  
  6         5291  
238              
239             sub valid_domain_for_ip_address {
240 0     0 1   my ($self, $server, $request, $ip_address, $domain, $find_best_match, $accept_any_domain) = @_;
241              
242 0 0         my $addr_rr_type = $ip_address->version == 4 ? 'A' : 'AAAA';
243              
244 0           my $reverse_ip_name = $self->ip_address_reverse($ip_address);
245 0           my $ptr_packet = $server->dns_lookup($reverse_ip_name, 'PTR');
246 0 0         my @ptr_rrs = $ptr_packet->answer
247             or $server->count_void_dns_lookup($request);
248              
249             # Respect the PTR mechanism lookups limit (RFC 4408, 5.5/3/4):
250 0 0         @ptr_rrs = splice(@ptr_rrs, 0, $server->max_name_lookups_per_ptr_mech)
251             if defined($server->max_name_lookups_per_ptr_mech);
252              
253 0           my $best_match_type;
254             my $valid_domain;
255              
256             # Check PTR records:
257 0           foreach my $ptr_rr (@ptr_rrs) {
258 0 0         if ($ptr_rr->type eq 'PTR') {
259 0           my $ptr_domain = $ptr_rr->ptrdname;
260              
261 0           my $match_type;
262 0 0         if ($ptr_domain =~ /^\Q$domain\E$/i) {
    0          
263 0           $match_type = valid_domain_match_identical;
264             }
265             elsif ($ptr_domain =~ /\.\Q$domain\E$/i) {
266 0           $match_type = valid_domain_match_subdomain;
267             }
268             else {
269 0           $match_type = valid_domain_match_none;
270             }
271              
272             # If we're not accepting _any_ domain, and the PTR domain does not match
273             # the requested domain at all, ignore this PTR domain (RFC 4408, 5.5/5):
274 0 0 0       next if not $accept_any_domain and $match_type == valid_domain_match_none;
275              
276 0           my $is_valid_domain = FALSE;
277              
278             try {
279 0     0     my $addr_packet = $server->dns_lookup($ptr_domain, $addr_rr_type);
280 0 0         my @addr_rrs = $addr_packet->answer
281             or $server->count_void_dns_lookup($request);
282 0           foreach my $addr_rr (@addr_rrs) {
283 0 0         if ($addr_rr->type eq $addr_rr_type) {
    0          
284 0 0         $is_valid_domain = TRUE, last
285             if $ip_address == NetAddr::IP->new($addr_rr->address);
286             # IP address reverse and forward mapping match,
287             # PTR domain validated!
288             }
289             elsif ($addr_rr->type =~ /^(CNAME|A|AAAA)$/) {
290             # A CNAME (which has hopefully been resolved by the server
291             # for us already), or an address RR of an unrequested type.
292             # Silently ignore any of those.
293             # FIXME Silently ignoring address RRs of an "unrequested"
294             # FIXME type poses a disparity with how the "ip{4,6}", "a",
295             # FIXME and "mx" mechanisms tolerantly handle alien but
296             # FIXME convertible IP address types.
297             }
298             else {
299             # Unexpected RR type.
300             # TODO Generate debug info or ignore silently.
301             }
302             }
303             }
304 0     0     catch Mail::SPF::EDNSError with {};
305             # Ignore DNS errors on doing A/AAAA RR lookups (RFC 4408, 5.5/5/5).
306              
307 0 0         if ($is_valid_domain) {
308             # If we're not looking for the _best_ match, any acceptable validated
309             # domain will do (RFC 4408, 5.5/5):
310 0 0         return $ptr_domain if not $find_best_match;
311              
312             # Otherwise, is this PTR domain the best possible match?
313 0 0         return $ptr_domain if $match_type == valid_domain_match_identical;
314              
315             # Lastly, record this match as the best one as of yet:
316 0 0 0       if (
317             not defined($best_match_type) or
318             $match_type > $best_match_type
319             ) {
320 0           $valid_domain = $ptr_domain;
321 0           $best_match_type = $match_type;
322             }
323             }
324             }
325             else {
326             # Unexpected RR type.
327             # TODO Generate debug info or ignore silently.
328             }
329             }
330              
331             # Return best match, possibly none (undef):
332 0           return $valid_domain;
333             }
334              
335             =item B: returns I or B
336              
337             Replaces all non-printable or non-ascii characters in a string with their
338             hex-escaped representation (e.g., C<\x00>).
339              
340             =cut
341              
342             sub sanitize_string {
343 0     0 1   my ($self, $string) = @_;
344              
345 0 0         return undef if not defined($string);
346              
347 0           $string =~ s/([\x00-\x1f\x7f-\xff])/sprintf("\\x%02x", ord($1))/gex;
  0            
348 0           $string =~ s/([\x{0100}-\x{ffff}]) /sprintf("\\x{%04x}", ord($1))/gex;
  0            
349              
350 0           return $string;
351             }
352              
353             =back
354              
355             =head1 SEE ALSO
356              
357             L
358              
359             For availability, support, and license information, see the README file
360             included with Mail::SPF.
361              
362             =head1 AUTHORS
363              
364             Julian Mehnle Ejulian@mehnle.netE, Shevek Ecpan@anarres.orgE
365              
366             =cut
367              
368             TRUE;