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   372951 use strict;
  9         51  
  9         202  
4 9     9   35 use warnings;
  9         12  
  9         239  
5              
6 9     9   2819 use Digest::SHA qw(sha256);
  9         16313  
  9         532  
7 9     9   3938 use Net::IP::Lite qw();
  9         36088  
  9         168  
8 9     9   2823 use URI;
  9         35376  
  9         11673  
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 268     268 1 101740 my $class = shift;
56 268         483 my @args = @_;
57              
58 268 100 100     1044 if ((scalar(@args) == 0) || !$args[0]) {
59 3         9 return undef;
60             }
61              
62 265         580 my $self = {
63             rawuri => $args[0],
64             };
65              
66 265         385 bless($self, $class);
67 265 100       472 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 439     439 1 62271 my $self = shift;
82              
83 439         1068 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 9     9 1 72 my $self = shift;
95 9         15 my @uris = ();
96              
97 9         14 $self->as_string() =~ /^(https?:\/\/)([^\/]+)(\/[^\?]*)(\??.*)$/i;
98 9         90 my ($scheme, $host, $path, $query) = ($1, $2, $3, $4);
99              
100             # Collect host suffixes
101 9         12 my @domains = ();
102 9 100       23 if ($host !~ /^\d+\.\d+\.\d+\.\d+$/) {
103 8         25 my @parts = split(/\./, $host);
104 8         13 splice(@parts, 0, -6); # take 5 top most components
105              
106 8         17 while (scalar(@parts) > 2) {
107 10         12 shift(@parts);
108 10         30 push(@domains, join(".", @parts));
109             }
110             }
111 9         15 push(@domains, $host);
112              
113             # Collect path & query prefixes
114 9         14 my @paths = ();
115 9 100       16 if ($path ne '/') {
116 4         13 my @parts = split(/\//, $path);
117 4         4 my $part_count = scalar(@parts);
118 4 100       11 $part_count = $part_count > 4 ? 4 : $part_count - 1; # limit to 4
119 4         6 my $previous = "";
120            
121 4         4 push(@paths, "/");
122             # Skip the first entry in @parts as it is always an empty string
123 4         18 for (my $i = 1; $i < $part_count; $i++) {
124 4         10 $previous .= "/" . $parts[$i] ."/";
125 4         15 push(@paths, $previous);
126             }
127             }
128 9         13 push(@paths, $path);
129 9 100       18 if ($query =~ /^\?./) {
130 2         5 push(@paths, $path . $query);
131             }
132              
133             # Assemble the list of Net::Google::SafeBrowsing4::URI objects
134 9         21 foreach my $domain (@domains) {
135 19         27 foreach my $path (@paths) {
136 37         99 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 37 50       236 if (defined($gsb_uri)) {
139 37         73 push(@uris, $gsb_uri);
140             }
141             }
142             }
143              
144 9         28 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 20 my $self = shift;
155              
156 2         5 my $uri = $self->as_string();
157 2         15 $uri =~ s/^https?:\/\///i;
158              
159 2         19 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 265     265   351 my $self = shift;
176 265         401 my $modified_rawuri = $self->{rawuri};
177              
178             # Remove third and more slashes after the scheme
179 265         525 $modified_rawuri =~ s/^(\s*https?:\/\/)\/+/$1/si;
180             # Remove any Tab, CR, LF characters from the URI
181 265         534 $modified_rawuri =~ s/[\r\n\t]+//sgi;
182             # Recursive percent-unescape (everything but '#' not to confuse URI parser)
183 265         538 while ($modified_rawuri =~ s{%(?!23)([[:xdigit:]]{2})}{chr(hex($1))}esg) { }
  98         291  
184              
185             # Parse URI
186 265         635 my $uri_obj = URI->new($modified_rawuri);
187 265 100       55273 if (ref($uri_obj) !~ /^URI::https?$/) {
188 29 100 100     87 if (!$uri_obj->scheme() || (!$uri_obj->has_recognized_scheme() && $modified_rawuri =~ /^[^:]+:\d{1,5}(?:\/|$)/)) {
      100        
189 25         481 $uri_obj = URI->new("http://" . $modified_rawuri);
190             }
191             }
192             # Only http and https URIs are supported
193 265 100       11692 if (ref($uri_obj) !~ /^URI::https?$/) {
194 4         26 return undef;
195             }
196              
197             # Remove userinfo
198 261         676 $uri_obj->userinfo(undef);
199             # Remove port
200 261         11251 $uri_obj->port(undef);
201             # Remove Fragment
202 261         11184 $uri_obj->fragment(undef);
203              
204             # Host modifications
205 261         2186 my $modified_host = $uri_obj->host();
206             # Host part cannot be empty
207 261 100       5454 if ($modified_host =~ /^\s*$/) {
208 2         35 return undef;
209             }
210             # Collapse consecutive dots into one
211 259         427 $modified_host =~ s/\.\.+/\./sg;
212             # Remove leading and trailing dot
213 259         785 $modified_host =~ s/^\.|\.$//sg;
214              
215             # IPv4 canonicalizations
216 259         448 $modified_host = _normalize_ip($modified_host);
217 259 100       8791 if (!defined($modified_host)) {
218 11         44 return undef;
219             }
220 248         580 $uri_obj->host($modified_host);
221              
222             # Empty/separator-only host
223 248 100       14195 if ($uri_obj->host() =~ /^[\s.\/]*$/) {
224 1         31 return undef;
225             }
226              
227             # Numeric TLD (and not IPv4)
228 247 100 100     4912 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         126 return undef;
231             }
232              
233 244         7901 my $modified_path = $uri_obj->path();
234             # Eliminate current directory /./ parts
235 244         2065 while ($modified_path =~ s/\/\.(?:\/|$)/\//sg) {};
236             # Eliminate parent directory /something/./ parts
237 244         419 while ($modified_path =~ s/\/[^\/]+\/\.\.(?:\/|$)/\//sg) {};
238             # Eliminate double // slashes from path
239 244         297 $modified_path =~ s/\/\/+/\//sg;
240 244 100       420 if ($modified_path eq '') {
241 136         170 $modified_path = '/';
242             }
243 244         500 $uri_obj->path($modified_path);
244              
245             # Fix some percent encoding
246 244         5365 my $modified_path_query = $uri_obj->path_query();
247             # Fix lone percent signs %
248 244         1959 $modified_path_query =~ s/%(?![[:xdigit:]]{2})/%25/sg;
249 244         502 $uri_obj->path_query($modified_path_query);
250              
251 244         5132 my $canonical = $uri_obj->canonical();
252             # Fix caret escaping
253 244         17269 $canonical=~ s/%5E/\^/sg;
254              
255 244         1154 $self->{uri} = $canonical;
256              
257 244         614 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 259     259   342 my $host = shift;
284              
285             # Shortcut: If it doesn't look like an IPv4, then return early
286 259 100       728 if ($host !~ /^(?:0x[[:xdigit:]]+|\d+)(?:\.(?:0x[[:xdigit:]]+|\d+))*$/si) {
287 119         236 return $host;
288             }
289              
290             # Most formats are detected and converted by Net::IP::Lite
291 140         357 my $ip = Net::IP::Lite->new($host);
292 140 100       7960 if ($ip) {
293 93         207 return $ip->transform();
294             }
295              
296             # One and two dots case is missing: xxx.xxxxxxxxxx, xxx.xxx.xxxxxx
297 47         63 my $bits = 32;
298 47         104 my @segments = split(/\./, $host);
299 47         72 my $segment_count = scalar(@segments);
300              
301 47         54 my $decimal = 0;
302 47         115 for (my $i = 0; $i < $segment_count; $i++) {
303 122         146 my $is_last_segment = $i >= $segment_count - 1;
304 122 100       220 my $segment = _parse_ipv4_segment($segments[$i], !$is_last_segment ? 8 : $bits);
305 122 100       184 if (!defined($segment)) {
306 11         15 return undef;
307             }
308 111         126 $bits -= 8;
309 111 100       228 $decimal += $segment << (!$is_last_segment ? $bits : 0);
310             }
311              
312 36         78 $ip = Net::IP::Lite->new($decimal);
313 36         766 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   168 my $segment = shift;
348 122         126 my $bits = shift;
349 122         124 my $decimal;
350              
351 122 100       384 if ($segment =~ /^0+([0-7]{0,10}|[0-3][0-7]{10})$/) {
    100          
    100          
352 34         57 $decimal = oct($1);
353             }
354             elsif ($segment =~ /^0x0*([[:xdigit:]]{1,8})$/si) {
355 35         62 $decimal = hex($1);
356             }
357             elsif ($segment =~ /^[1-9]\d+$/) {
358 49         60 $decimal = $segment;
359             }
360             else {
361 4         7 return undef;
362             }
363              
364 118 100       222 if ($decimal >= (1 << $bits)) {
365 7         13 return undef;
366             }
367 111         174 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;