File Coverage

blib/lib/Net/Google/SafeBrowsing4/URI.pm
Criterion Covered Total %
statement 131 131 100.0
branch 47 48 97.9
condition 12 12 100.0
subroutine 12 12 100.0
pod 4 4 100.0
total 206 207 99.5


line stmt bran cond sub pod time code
1             package Net::Google::SafeBrowsing4::URI;
2              
3 9     9   479387 use strict;
  9         75  
  9         295  
4 9     9   50 use warnings;
  9         17  
  9         290  
5              
6 9     9   3908 use Digest::SHA qw(sha256);
  9         22321  
  9         736  
7 9     9   5138 use Net::IP::Lite qw();
  9         48665  
  9         283  
8 9     9   3917 use URI;
  9         48437  
  9         15765  
9              
10             =encoding utf-8
11              
12             =head1 NAME
13              
14             Net::Google::SafeBrowsing4::URI - URI management Class for the Google SafeBrowsing (version 4) API.
15              
16              
17             =head1 SYNOPSIS
18              
19             use Net::Google::SafeBrowsing4::URI;
20              
21             my $gsb_uri = Net::Google::SafeBrowsing4::URI->new('http://my.example.site:80/path/to/file.html?query=param#fragment');
22             my @uris = $gsb_uri->generate_lookupuris();
23              
24             =head1 DESCRIPTION
25              
26             Net::Google::SafeBrowsing4::URI takes care of normalizing URLs, extracting suffix/prefix expressions, calculating hashes.
27              
28             =head1 METHODS
29              
30             =over
31              
32             =item new
33              
34             =back
35              
36             my $gsb_uri = Net::Google::SafeBrowsing4::URI->new('http://my.example.site:80/path/to/file.html?query=param#fragment');
37              
38             =over
39              
40             Initializes the object.
41              
42             Arguments:
43              
44             =over
45              
46             =item $uri The URL to parse
47              
48             =back
49              
50             =back
51              
52             =cut
53              
54             sub new {
55 281     281 1 130889 my $class = shift;
56 281         665 my @args = @_;
57              
58 281 100 100     1420 if ((scalar(@args) == 0) || !$args[0]) {
59 3         14 return undef;
60             }
61              
62 278         848 my $self = {
63             rawuri => $args[0],
64             };
65              
66 278         545 bless($self, $class);
67 278 100       660 return $self->_normalize() ? $self : undef;
68             }
69              
70             =over
71              
72             =item as_string
73              
74             Returns the normalized URI as string.
75              
76             =back
77              
78             =cut
79              
80             sub as_string {
81 456     456 1 81137 my $self = shift;
82              
83 456         1481 return '' . $self->{uri};
84             }
85              
86             =item generate_lookupuris
87              
88             Generates all partial/full URIs supported by Google SafeBrowsing. See "suffix/prefix expressions" topic in GSBv4 API reference.
89             Returns a list of L objects.
90              
91             =cut
92              
93             sub generate_lookupuris {
94 10     10 1 111 my $self = shift;
95 10         21 my @uris = ();
96              
97 10         25 $self->as_string() =~ /^(https?:\/\/)([^\/]+)(\/[^\?]*)(\??.*)$/i;
98 10         152 my ($scheme, $host, $path, $query) = ($1, $2, $3, $4);
99              
100             # Collect host suffixes
101 10         21 my @domains = ();
102 10 100       35 if ($host !~ /^\d+\.\d+\.\d+\.\d+$/) {
103 9         40 my @parts = split(/\./, $host);
104 9         19 splice(@parts, 0, -6); # take 5 top most components
105              
106 9         24 while (scalar(@parts) > 2) {
107 11         19 shift(@parts);
108 11         42 push(@domains, join(".", @parts));
109             }
110             }
111 10         33 push(@domains, $host);
112              
113             # Collect path & query prefixes
114 10         17 my @paths = ();
115 10 100       29 if ($path ne '/') {
116 5         17 my @parts = split(/\//, $path);
117 5         10 my $part_count = scalar(@parts);
118 5 100       27 $part_count = $part_count > 4 ? 4 : $part_count - 1; # limit to 4
119 5         11 my $previous = "";
120            
121 5         10 push(@paths, "/");
122             # Skip the first entry in @parts as it is always an empty string
123 5         18 for (my $i = 1; $i < $part_count; $i++) {
124 5         16 $previous .= "/" . $parts[$i] ."/";
125 5         20 push(@paths, $previous);
126             }
127             }
128 10         19 push(@paths, $path);
129 10 100       29 if ($query =~ /^\?.*/) {
130 3         9 push(@paths, $path . $query);
131             }
132              
133             # Assemble the list of Net::Google::SafeBrowsing4::URI objects
134 10         22 foreach my $domain (@domains) {
135 21         37 foreach my $path (@paths) {
136 45         164 my $gsb_uri = Net::Google::SafeBrowsing4::URI->new($scheme . $domain . $path);
137             # @TODO Sub-URI of a valid URI should be a valid URI. Condition should not be necessary.
138 45 50       377 if (defined($gsb_uri)) {
139 45         117 push(@uris, $gsb_uri);
140             }
141             }
142             }
143              
144 10         45 return @uris;
145             }
146              
147             =item hash
148              
149             Generates the SHA-256 hash of the URI (with scheme removed).
150              
151             =cut
152              
153             sub hash {
154 2     2 1 22 my $self = shift;
155              
156 2         5 my $uri = $self->as_string();
157 2         19 $uri =~ s/^https?:\/\///i;
158              
159 2         24 return sha256($uri);
160             }
161              
162             =head1 PRIVATE METHODS
163              
164             =over
165              
166             =item _normalize
167              
168             Parses and normalizes the URI.
169              
170             =back
171              
172             =cut
173              
174             sub _normalize {
175 278     278   490 my $self = shift;
176 278         536 my $modified_rawuri = $self->{rawuri};
177              
178             # Remove third and more slashes after the scheme
179 278         737 $modified_rawuri =~ s/^(\s*https?:\/\/)\/+/$1/si;
180             # Remove any Tab, CR, LF characters from the URI
181 278         748 $modified_rawuri =~ s/[\r\n\t]+//sgi;
182             # Recursive percent-unescape (everything but '#' not to confuse URI parser)
183 278         782 while ($modified_rawuri =~ s{%(?!23)([[:xdigit:]]{2})}{chr(hex($1))}esg) { }
  98         373  
184              
185             # Parse URI
186 278         863 my $uri_obj = URI->new($modified_rawuri);
187 278 100       74456 if (ref($uri_obj) !~ /^URI::https?$/) {
188 29 100 100     114 if (!$uri_obj->scheme() || (!$uri_obj->has_recognized_scheme() && $modified_rawuri =~ /^[^:]+:\d{1,5}(?:\/|$)/)) {
      100        
189 25         687 $uri_obj = URI->new("http://" . $modified_rawuri);
190             }
191             }
192             # Only http and https URIs are supported
193 278 100       15555 if (ref($uri_obj) !~ /^URI::https?$/) {
194 4         31 return undef;
195             }
196              
197             # Remove userinfo
198 274         909 $uri_obj->userinfo(undef);
199             # Remove port
200 274         15339 $uri_obj->port(undef);
201             # Remove Fragment
202 274         15231 $uri_obj->fragment(undef);
203              
204             # Host modifications
205 274         3066 my $modified_host = $uri_obj->host();
206             # Host part cannot be empty
207 274 100       7399 if ($modified_host =~ /^\s*$/) {
208 2         11 return undef;
209             }
210             # Collapse consecutive dots into one
211 272         565 $modified_host =~ s/\.\.+/\./sg;
212             # Remove leading and trailing dot
213 272         987 $modified_host =~ s/^\.|\.$//sg;
214              
215             # IPv4 canonicalizations
216 272         615 $modified_host = _normalize_ip($modified_host);
217 272 100       11238 if (!defined($modified_host)) {
218 11         60 return undef;
219             }
220 261         767 $uri_obj->host($modified_host);
221              
222             # Empty/separator-only host
223 261 100       19063 if ($uri_obj->host() =~ /^[\s.\/]*$/) {
224 1         104 return undef;
225             }
226              
227             # Numeric TLD (and not IPv4)
228 260 100 100     6778 if ($uri_obj->host() =~ /\.\d[^\.]*$/
229             && $uri_obj->host() !~ /^(?:2(?:5[0-5]|[0-4][0-9])|[01]?[0-9]{1,2})(?:\.(?:2(?:5[0-5]|[0-4][0-9])|[01]?[0-9]{1,2})){3}$/) {
230 3         156 return undef;
231             }
232              
233 257         10118 my $modified_path = $uri_obj->path();
234             # Eliminate current directory /./ parts
235 257         2749 while ($modified_path =~ s/\/\.(?:\/|$)/\//sg) {};
236             # Eliminate parent directory /something/./ parts
237 257         592 while ($modified_path =~ s/\/[^\/]+\/\.\.(?:\/|$)/\//sg) {};
238             # Eliminate double // slashes from path
239 257         405 $modified_path =~ s/\/\/+/\//sg;
240 257 100       564 if ($modified_path eq '') {
241 138         226 $modified_path = '/';
242             }
243 257         667 $uri_obj->path($modified_path);
244              
245             # Fix some percent encoding
246 257         7185 my $modified_path_query = $uri_obj->path_query();
247             # Fix lone percent signs %
248 257         2589 $modified_path_query =~ s/%(?![[:xdigit:]]{2})/%25/sg;
249 257         658 $uri_obj->path_query($modified_path_query);
250              
251 257         6830 my $canonical = $uri_obj->canonical();
252             # Fix caret escaping
253 257         23746 $canonical=~ s/%5E/\^/sg;
254              
255 257         1548 $self->{uri} = $canonical;
256              
257 257         832 return $self->{uri};
258             }
259              
260             =head1 PRIVATE FUNCTIONS
261              
262             =over
263              
264             =item _normalize_ip
265              
266             Function for recognising various IPv4 formatted addresses and convert them to I format (111.11.1.1)
267              
268             Arguments:
269              
270             =over
271              
272             =item $host
273              
274             Hostname to parse as IP Address
275              
276             =back
277              
278             =back
279              
280             =cut
281              
282             sub _normalize_ip {
283 272     272   477 my $host = shift;
284              
285             # Shortcut: If it doesn't look like an IPv4, then return early
286 272 100       1005 if ($host !~ /^(?:0x[[:xdigit:]]+|\d+)(?:\.(?:0x[[:xdigit:]]+|\d+))*$/si) {
287 132         339 return $host;
288             }
289              
290             # Most formats are detected and converted by Net::IP::Lite
291 140         460 my $ip = Net::IP::Lite->new($host);
292 140 100       10410 if ($ip) {
293 93         269 return $ip->transform();
294             }
295              
296             # One and two dots case is missing: xxx.xxxxxxxxxx, xxx.xxx.xxxxxx
297 47         71 my $bits = 32;
298 47         117 my @segments = split(/\./, $host);
299 47         69 my $segment_count = scalar(@segments);
300              
301 47         80 my $decimal = 0;
302 47         101 for (my $i = 0; $i < $segment_count; $i++) {
303 122         209 my $is_last_segment = $i >= $segment_count - 1;
304 122 100       300 my $segment = _parse_ipv4_segment($segments[$i], !$is_last_segment ? 8 : $bits);
305 122 100       295 if (!defined($segment)) {
306 11         30 return undef;
307             }
308 111         152 $bits -= 8;
309 111 100       283 $decimal += $segment << (!$is_last_segment ? $bits : 0);
310             }
311              
312 36         94 $ip = Net::IP::Lite->new($decimal);
313 36         1011 return $ip->transform();
314             }
315              
316             =over
317              
318             =item _parse_ipv4_segment
319              
320             =back
321              
322             my $decimal = _parse_ipv4_part($segment, $bits)
323              
324             =over
325              
326             Transforms one IPv4 segment to decimal with range checking.
327              
328             Arguments:
329              
330             =over
331              
332             =item $segment
333              
334             Decimal/octal/hexadecimal value to parse
335              
336             =item $bits
337              
338             Bit length for range checking
339              
340             =back
341              
342             =back
343              
344             =cut
345              
346             sub _parse_ipv4_segment {
347 122     122   203 my $segment = shift;
348 122         170 my $bits = shift;
349 122         158 my $decimal;
350              
351 122 100       498 if ($segment =~ /^0+([0-7]{0,10}|[0-3][0-7]{10})$/) {
    100          
    100          
352 34         77 $decimal = oct($1);
353             }
354             elsif ($segment =~ /^0x0*([[:xdigit:]]{1,8})$/si) {
355 35         82 $decimal = hex($1);
356             }
357             elsif ($segment =~ /^[1-9]\d+$/) {
358 49         81 $decimal = $segment;
359             }
360             else {
361 4         8 return undef;
362             }
363              
364 118 100       280 if ($decimal >= (1 << $bits)) {
365 7         16 return undef;
366             }
367 111         213 return $decimal;
368             }
369              
370             =head1 BUGS
371              
372             Some URI normalizatuion cases are still missing:
373              
374             =over
375              
376             =item Highbit characters in hostname are punycoded, not percent encoded.
377              
378             Google's GO Client percent-escapes based on the hostname is a valid unicode string or not
379              
380             =back
381              
382             =head1 AUTHORS
383              
384             Tamás Fehérvári, Egeever@users.sourceforge.net
385             Julien Sobrier, Ejulien@sobrier.netE,
386              
387             =head1 COPYRIGHT AND LICENSE
388              
389             Copyright (C) 2016 by Julien Sobrier, Tamás Fehérvári
390              
391             This library is free software; you can redistribute it and/or modify
392             it under the same terms as Perl itself, either Perl version 5.8.8 or,
393             at your option, any later version of Perl 5 you may have available.
394              
395             =cut
396              
397             1;