File Coverage

File:blib/lib/CGI/Info.pm
Coverage:78.6%

linestmtbrancondsubtimecode
1package CGI::Info;
2
3# TODO: remove the expect argument
4# TODO: look into params::check or params::validate
5
6
27
27
27
1921459
19
589
use warnings;
7
27
27
27
47
19
234
use strict;
8
9
27
27
27
4118
11709
66
use boolean;
10
27
27
27
776
23
580
use Carp;
11
27
27
27
5933
1425003
431
use Object::Configure 0.12;
12
27
27
27
101
17
350
use File::Spec;
13
27
27
27
44
141
244
use Log::Abstraction 0.10;
14
27
27
27
49
112
384
use Params::Get 0.13;
15
27
27
27
45
117
301
use Params::Validate::Strict 0.21;
16
27
27
27
6042
67844
595
use Net::CIDR;
17
27
27
27
52
18
297
use Return::Set;
18
27
27
27
41
16
266
use Scalar::Util;
19
27
27
27
38
16
4505
use Socket;     # For AF_INET
20
27
27
208
56
use 5.008;
21# use Cwd;
22# use JSON::Parse;
23
27
27
27
69
31
210
use List::Util ();      # Can go when expect goes
24# use Sub::Private;
25
27
27
27
5012
335409
376
use Sys::Path;
26
27
27
27
27
5164
156232
74
use namespace::clean;
28
29sub _sanitise_input($);
30
31 - 39
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.08

=cut
40
41our $VERSION = '1.08';
42
43 - 144
=head1 SYNOPSIS

The C<CGI::Info> module is a Perl library designed to provide information about the environment in which a CGI script operates.
It aims to eliminate hard-coded script details,
enhancing code readability and portability.
Additionally, it offers a simple web application firewall to add a layer of security.

All too often,
Perl programs have information such as the script's name
hard-coded into their source.
Generally speaking,
hard-coding is a bad style since it can make programs difficult to read and reduces readability and portability.
CGI::Info attempts to remove that.

Furthermore, to aid script debugging, CGI::Info attempts to do sensible
things when you're not running the program in a CGI environment.

Whilst you shouldn't rely on it alone to provide security to your website,
it is another layer and every little helps.

    use CGI::Info;

    my $info = CGI::Info->new(allow => { id => qr/^\d+$/ });
    my $params = $info->params();

    if($info->is_mobile()) {
        print "Mobile view\n";
    } else {
        print "Desktop view\n";
    }

    my $id = $info->param('id');     # Validated against allow schema

=head1 SUBROUTINES/METHODS

=head2 new

Creates a CGI::Info object.

It takes four optional arguments: allow, logger, expect and upload_dir,
which are documented in the params() method.

It takes other optional parameters:

=over 4

=item * C<auto_load>

Enable/disable the AUTOLOAD feature.
The default is to have it enabled.

=item * C<config_dirs>

Where to look for C<config_file>

=item * C<config_file>

Points to a configuration file which contains the parameters to C<new()>.
The file can be in any common format,
including C<YAML>, C<XML>, and C<INI>.
This allows the parameters to be set at run time.

On non-Windows system,
the class can be configured using environment variables starting with "CGI::Info::".
For example:

  export CGI::Info::max_upload_size=65536

It doesn't work on Windows because of the case-insensitive nature of that system.

If the configuration file has a section called C<CGI::Info>,
only that section,
and the C<global> section,
if any exists,
is used.

=item * C<syslog>

Takes an optional parameter syslog, to log messages to
L<Sys::Syslog>.
It can be a boolean to enable/disable logging to syslog, or a reference
to a hash to be given to Sys::Syslog::setlogsock.

=item * C<cache>

An object that is used to cache IP lookups.
This cache object is an object that understands get() and set() messages,
such as a L<CHI> object.

=item * C<max_upload_size>

The maximum file size you can upload (-1 for no limit), the default is 512MB.

=back

The class can be configured at runtime using environments and configuration files,
for example,
setting C<$ENV{'CGI__INFO__carp_on_warn'}> causes warnings to use L<Carp>.
For more information about configuring object constructors at runtime,
see L<Object::Configure>.

=cut
145
146our $stdin_data;        # Class variable storing STDIN in case the class
147                        # is instantiated more than once
148
149sub new
150{
151
237
2061903
        my $class = shift;
152
153        # Handle hash or hashref arguments
154
237
353
        my $params = Params::Get::get_params(undef, @_) || {};
155
156
236
2629
        if(!defined($class)) {
157
1
1
1
2
                if((scalar keys %{$params}) > 0) {
158                        # Using CGI::Info:new(), not CGI::Info->new()
159
0
0
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
160                }
161
162                # FIXME: this only works when no arguments are given
163
1
1
                $class = __PACKAGE__;
164        } elsif(Scalar::Util::blessed($class)) {
165                # If $class is an object, clone it with new arguments
166
5
5
5
6
7
24
                return bless { %{$class}, %{$params} }, ref($class);
167        }
168
169        # Load the configuration from a config file, if provided
170
231
359
        $params = Object::Configure::configure($class, $params);
171
172        # Validate logger object has required methods
173
230
858729
        if(defined $params->{'logger'}) {
174
230
1559
                unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
175
0
0
                        Carp::croak("Logger must be an object with info() and error() methods");
176                }
177        }
178
179
230
307
        if(defined($params->{'expect'})) {
180                # if(ref($params->{expect}) ne 'ARRAY') {
181                        # Carp::croak(__PACKAGE__, ': expect must be a reference to an array');
182                # }
183                # # warn __PACKAGE__, ': expect is deprecated, use allow instead';
184
2
6
                if(my $logger = $params->{'logger'}) {
185
2
5
                        $logger->error("$class: expect has been deprecated, use allow instead");
186                }
187
2
1348
                Carp::croak("$class: expect has been deprecated, use allow instead");
188        }
189
190        # Return the blessed object
191        return bless {
192                max_upload_size => 512 * 1024,
193                allow => undef,
194                upload_dir => undef,
195
228
228
172
658
                %{$params}      # Overwrite defaults with given arguments
196        }, $class;
197}
198
199 - 226
=head2 script_name

Retrieves the name of the executing CGI script.
This is useful for POSTing,
thus avoiding hard-coded paths into forms.

        use CGI::Info;

        my $info = CGI::Info->new();
        my $script_name = $info->script_name();
        # ...
        print "<form method=\"POST\" action=$script_name name=\"my_form\">\n";

=head3 API SPECIFICATION

=head4 INPUT

None.

=head4 OUTPUT

  {
    type => 'string',
    'min' => 1,
    'nomatch' => qr/^[\/\\]/ # Does not return absolute path
  }

=cut
227
228sub script_name
229{
230
22
1013
        my $self = shift;
231
232
22
34
        unless($self->{script_name}) {
233
15
17
                $self->_find_paths();
234        }
235
22
51
        return $self->{script_name};
236}
237
238sub _find_paths {
239
23
19
        my $self = shift;
240
241
23
35
        if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
242
0
0
                Carp::croak('Illegal Operation: This method can only be called by a subclass or ourself');
243        }
244
245
23
212
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
246
247
23
433
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
248
249        # Determine script name
250
23
29
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
251
23
365
        $self->{script_name} = $self->_untaint_filename({
252                filename => File::Basename::basename($script_name)
253        });
254
255        # Determine script path
256
23
40
        if(my $script_path = $self->_get_env('SCRIPT_FILENAME')) {
257
2
2
                $self->{script_path} = $script_path;
258        } elsif($script_name = $self->_get_env('SCRIPT_NAME')) {
259
12
10
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
260
6
7
                        $script_name = $self->_get_env('SCRIPT_NAME');
261
262                        # It's usually the case, e.g. /cgi-bin/foo.pl
263
6
6
                        $script_name =~ s{^/}{};
264
265
6
24
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
266                } else {
267
6
57
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
268                                # Called from a command line with a full path
269
1
2
                                $self->{script_path} = $script_name;
270                        } else {
271
5
19
                                require Cwd unless Cwd->can('abs_path');
272
273
5
18
                                if($script_name =~ /^\/(.+)/) {
274                                        # It's usually the case, e.g. /cgi-bin/foo.pl
275
2
3
                                        $script_name = $1;
276                                }
277
278
5
34
                                $self->{script_path} = File::Spec->catfile(Cwd::abs_path(), $script_name);
279                        }
280                }
281        } elsif(File::Spec->file_name_is_absolute($0)) {
282                # Called from a command line with a full path
283
0
0
                $self->{script_path} = $0;
284        } else {
285
9
103
                $self->{script_path} = File::Spec->rel2abs($0);
286        }
287
288        # Untaint and finalize script path
289        $self->{script_path} = $self->_untaint_filename({
290                filename => $self->{script_path}
291
23
42
        });
292}
293
294 - 311
=head2 script_path

Finds the full path name of the script.

        use CGI::Info;

        my $info = CGI::Info->new();
        my $fullname = $info->script_path();
        my @statb = stat($fullname);

        if(@statb) {
                my $mtime = localtime $statb[9];
                print "Last-Modified: $mtime\n";
                # TODO: only for HTTP/1.1 connections
                # $etag = Digest::MD5::md5_hex($html);
                printf "ETag: \"%x\"\n", $statb[9];
        }
=cut
312
313sub script_path {
314
26
4031
        my $self = shift;
315
316
26
28
        unless($self->{script_path}) {
317
6
9
                $self->_find_paths();
318        }
319
26
86
        return $self->{script_path};
320}
321
322 - 336
=head2 script_dir

Returns the file system directory containing the script.

        use CGI::Info;
        use File::Spec;

        my $info = CGI::Info->new();

        print 'HTML files are normally stored in ', $info->script_dir(), '/', File::Spec->updir(), "\n";

        # or
        use lib CGI::Info::script_dir() . '../lib';

=cut
337
338sub script_dir
339{
340
14
16
        my $self = shift;
341
342        # Ensure $self is an object
343
14
16
        $self = __PACKAGE__->new() unless ref $self;
344
345        # Set script path if it is not already defined
346
14
16
        $self->_find_paths() unless $self->{script_path};
347
348        # Extract directory from script path based on OS
349        # Don't use File::Spec->splitpath() since that can leave the trailing slash
350
14
27
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
351
352
14
106
        return $self->{script_path} =~ $dir_regex ? $1 : $self->{script_path};
353}
354
355 - 374
=head2 host_name

Return the host-name of the current web server, according to CGI.
If the name can't be determined from the web server, the system's host-name
is used as a fall back.
This may not be the same as the machine that the CGI script is running on,
some ISPs and other sites run scripts on different machines from those
delivering static content.
There is a good chance that this will be domain_name() prepended with either
'www' or 'cgi'.

        use CGI::Info;

        my $info = CGI::Info->new();
        my $host_name = $info->host_name();
        my $protocol = $info->protocol();
        # ...
        print "Thank you for visiting our <A HREF=\"$protocol://$host_name\">Website!</A>";

=cut
375
376sub host_name {
377
10
784
        my $self = shift;
378
379
10
13
        unless($self->{site}) {
380
3
6
                $self->_find_site_details();
381        }
382
383
10
71
        return $self->{site};
384}
385
386sub _find_site_details
387{
388
10
5
        my $self = shift;
389
390        # Log entry to the routine
391
10
18
        $self->_trace('Entering _find_site_details');
392
393
10
142
        return if $self->{site} && $self->{cgi_site};
394
395        # Determine cgi_site using environment variables or hostname
396
8
23
        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
397                # Import necessary module
398
5
270
                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
399
400
5
1225
                $self->{cgi_site} = URI::Heuristic::uf_uristr($host);
401                # Remove trailing dots from the name.  They are legal in URLs
402                # and some sites link using them to avoid spoofing (nice)
403
5
58
                $self->{cgi_site} =~ s/(.*)\.+$/$1/;  # Trim trailing dots
404
405
5
13
                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
406
1
1
                        $self->{cgi_site} =~ s/^http/$protocol/;
407                }
408        } else {
409                # Import necessary module
410
3
14
                require Sys::Hostname unless Sys::Hostname->can('hostname');
411
412
3
6
                $self->_debug('Falling back to using hostname');
413
3
39
                $self->{cgi_site} = Sys::Hostname::hostname();
414        }
415
416        # Set site details if not already defined
417
8
34
        $self->{site} ||= $self->{cgi_site};
418
8
15
        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
419        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
420
8
22
                unless $self->{cgi_site} =~ /^https?:\/\//;
421
422        # Warn if site details could not be determined
423
8
27
        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
424
425        # Log exit
426
8
9
        $self->_trace('Leaving _find_site_details');
427}
428
429 - 436
=head2 domain_name

Domain_name is the name of the controlling domain for this website.
Usually it will be similar to host_name, but will lack the http:// or www prefixes.

Can be called as a class method.

=cut
437
438sub domain_name {
439
7
187
        my $self = shift;
440
441
7
8
        if(!ref($self)) {
442
1
2
                $self = __PACKAGE__->new();
443        }
444
7
14
        return $self->{domain} if $self->{domain};
445
446
4
5
        $self->_find_site_details();
447
448
4
21
        if(my $site = $self->{site}) {
449
4
8
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
450        }
451
452
4
14
        return $self->{domain};
453}
454
455 - 459
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
460
461sub cgi_host_url {
462
7
23
        my $self = shift;
463
464
7
8
        unless($self->{cgi_site}) {
465
3
3
                $self->_find_site_details();
466        }
467
468
7
50
        return $self->{cgi_site};
469}
470
471 - 626
=head2 params

Returns a reference to a hash list of the CGI arguments.

CGI::Info helps you to test your script prior to deployment on a website:
if it is not in a CGI environment (e.g. the script is being tested from the
command line), the program's command line arguments (a list of key=value pairs)
are used, if there are no command line arguments then they are read from stdin
as a list of key=value lines.
Also you can give one of --tablet, --search-engine,
--mobile and --robot to mimic those agents. For example:

        ./script.cgi --mobile name=Nigel

Returns undef if the parameters can't be determined or if none were given.

If an argument is given twice or more, then the values are put in a comma
separated string.

The returned hash value can be passed into L<CGI::Untaint>.

Takes four optional parameters: allow, logger and upload_dir.
The parameters are passed in a hash, or a reference to a hash.
The latter is more efficient since it puts less on the stack.

Allow is a reference to a hash list of CGI parameters that you will allow.
The value for each entry is either a permitted value,
a regular expression of permitted values for
the key,
a code reference,
or a hash of L<Params::Validate::Strict> rules.
Subroutine exceptions propagate normally, allowing custom error handling.
This works alongside existing regex and Params::Validate::Strict patterns.
A undef value means that any value will be allowed.
Arguments not in the list are silently ignored.
This is useful to help to block attacks on your site.


Upload_dir is a string containing a directory where files being uploaded are to
be stored.
It must be a writeable directory in the temporary area.

Takes an optional parameter logger, which is used for warnings and traces.
It can be an object that understands warn() and trace() messages,
such as a L<Log::Log4perl> or L<Log::Any> object,
a reference to code,
a reference to an array,
or a filename.

The allow, logger and upload_dir arguments can also be passed to the
constructor.

        use CGI::Info;
        use CGI::Untaint;
        # ...
        my $info = CGI::Info->new();
        my %params;
        if($info->params()) {
                %params = %{$info->params()};
        }
        # ...
        foreach(keys %params) {
                print "$_ => $params{$_}\n";
        }
        my $u = CGI::Untaint->new(%params);

        use CGI::Info;
        use CGI::IDS;
        # ...
        my $info = CGI::Info->new();
        my $allowed = {
                foo => qr/^\d*$/,    # foo must be a number, or empty
                bar => undef,                # bar can be given and be any value
                xyzzy => qr/^[\w\s-]+$/,     # must be alphanumeric
                                                # to prevent XSS, and non-empty
                                                # as a sanity check
        };
        # or
        $allowed = {
                email => { type => 'string', matches => qr/^[^@]+@[^@]+\.[^@]+$/ }, # String, basic email format check
                age => { type => 'integer', min => 0, max => 150 }, # Integer between 0 and 150
                bio => { type => 'string', optional => 1 }, # String, optional
                ip_address => { type => 'string', matches => qr/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/ }, #Basic IPv4 validation
        };
        my $paramsref = $info->params(allow => $allowed);
        if(defined($paramsref)) {
                my $ids = CGI::IDS->new();
                $ids->set_scan_keys(scan_keys => 1);
                if($ids->detect_attacks(request => $paramsref) > 0) {
                        die 'horribly';
                }
        }

If the request is an XML request (i.e. the content type of the POST is text/xml),
CGI::Info will put the request into the params element 'XML', thus:

        use CGI::Info;
        # ...
        my $info = CGI::Info->new();
        my $paramsref = $info->params();     # See BUGS below
        my $xml = $$paramsref{'XML'};
        # ... parse and process the XML request in $xml

Carp if logger is not set and we detect something serious.

Blocks some attacks,
such as SQL and XSS injections,
mustleak and directory traversals,
thus creating a primitive web application firewall (WAF).
Warning - this is an extra layer, not a replacement for your other security layers.

=head3 Validation Subroutine Support

The C<allow> parameter accepts subroutine references for dynamic validation,
enabling complex parameter checks beyond static regex patterns.
These callbacks:

=over 4

=item * Receive three arguments: the parameter key, value and the C<CGI::Info> instance

=item * Must return a true value to allow the parameter, false to reject

=item * Can access other parameters through the instance for contextual validation

=back

Basic usage:

    CGI::Info->new(
        allow => {
            # Simple value check
            even_number => sub { ($_[1] % 2) == 0 },

            # Context-aware validation
            child_age => sub {
                my ($key, $value, $info) = @_;
                $info->param('is_parent') ? $value <= 18 : 0
            }
        }
    );

Advanced features:

    # Combine with regex validation
    mixed_validation => {
        email => qr/@/,  # Regex check
        promo_code => \&validate_promo_code  # Subroutine check
    }

    # Throw custom exceptions
    dangerous_param => sub {
        die 'Hacking attempt!' if $_[1] =~ /DROP TABLE/;
        return 1;
    }
=cut
627
628sub params {
629
165
3629
        my $self = shift;
630
631
165
218
        my $params = Params::Get::get_params(undef, @_);
632
633
165
1331
        if((defined($self->{paramref})) && ((!defined($params->{'allow'})) || defined($self->{allow}) && ($params->{'allow'} eq $self->{allow}))) {
634
47
63
                return $self->{paramref};
635        }
636
637
118
116
        if(defined($params->{allow})) {
638
11
11
                $self->{allow} = $params->{allow};
639        }
640        # if(defined($params->{expect})) {
641                # if(ref($params->{expect}) eq 'ARRAY') {
642                        # $self->{expect} = $params->{expect};
643                        # $self->_warn('expect is deprecated, use allow instead');
644                # } else {
645                        # $self->_warn('expect must be a reference to an array');
646                # }
647        # }
648
118
110
        if(defined($params->{upload_dir})) {
649
4
5
                $self->{upload_dir} = $params->{upload_dir};
650        }
651
118
87
        if(defined($params->{'logger'})) {
652
2
3
                $self->set_logger($params->{'logger'});
653        }
654
118
159
        $self->_trace('Entering params');
655
656
118
1601
        my @pairs;
657
118
103
        my $content_type = $ENV{'CONTENT_TYPE'};
658
118
69
        my %FORM;
659
660
118
354
        if((!$ENV{'GATEWAY_INTERFACE'}) || (!$ENV{'REQUEST_METHOD'})) {
661
9
664
                require IO::Interactive;
662
9
1292
                IO::Interactive->import();
663
664
9
86
                if(@ARGV) {
665
9
9
                        @pairs = @ARGV;
666
9
11
                        if(defined($pairs[0])) {
667
9
21
                                if($pairs[0] eq '--robot') {
668
1
1
                                        $self->{is_robot} = 1;
669
1
2
                                        shift @pairs;
670                                } elsif($pairs[0] eq '--mobile') {
671
2
5
                                        $self->{is_mobile} = 1;
672
2
3
                                        shift @pairs;
673                                } elsif($pairs[0] eq '--search-engine') {
674
1
1
                                        $self->{is_search_engine} = 1;
675
1
1
                                        shift @pairs;
676                                } elsif($pairs[0] eq '--tablet') {
677
1
1
                                        $self->{is_tablet} = 1;
678
1
1
                                        shift @pairs;
679                                }
680                        }
681                } elsif($stdin_data) {
682
0
0
                        @pairs = split(/\n/, $stdin_data);
683                } elsif(IO::Interactive::is_interactive() && !$self->{args_read}) {
684                        # TODO:  Do I really need this anymore?
685
0
0
                        my $oldfh = select(STDOUT);
686
0
0
                        print "Entering debug mode\n",
687                                "Enter key=value pairs - end with quit\n";
688
0
0
                        select($oldfh);
689
690                        # Avoid prompting for the arguments more than once
691                        # if just 'quit' is entered
692
0
0
                        $self->{args_read} = 1;
693
694
0
0
                        while(<STDIN>) {
695
0
0
                                chop(my $line = $_);
696
0
0
                                $line =~ s/[\r\n]//g;
697
0
0
                                last if $line eq 'quit';
698
0
0
                                push(@pairs, $line);
699
0
0
                                $stdin_data .= "$line\n";
700                        }
701                }
702        } elsif(($ENV{'REQUEST_METHOD'} eq 'GET') || ($ENV{'REQUEST_METHOD'} eq 'HEAD')) {
703
80
102
                if(my $query = $ENV{'QUERY_STRING'}) {
704
75
79
                        if((defined($content_type)) && ($content_type =~ /multipart\/form-data/i)) {
705
1
1
                                $self->_warn('Multipart/form-data not supported for GET');
706
0
0
                                $self->{status} = 501;       # Not implemented
707
0
0
                                return;
708                        }
709
74
77
                        $query =~ s/\\u0026/\&/g;
710
74
91
                        @pairs = split(/&/, $query);
711                } else {
712
5
13
                        return;
713                }
714        } elsif($ENV{'REQUEST_METHOD'} eq 'POST') {
715
26
40
                my $content_length = $self->_get_env('CONTENT_LENGTH');
716
26
65
                if((!defined($content_length)) || ($content_length =~ /\D/)) {
717
2
2
                        $self->{status} = 411;
718
2
4
                        return;
719                }
720
24
54
                if(($self->{max_upload_size} >= 0) && ($content_length > $self->{max_upload_size})) {       # Set maximum posts
721                        # TODO: Design a way to tell the caller to send HTTP
722                        # status 413
723
2
2
                        $self->{status} = 413;
724
2
3
                        $self->_warn('Large upload prohibited');
725
2
4
                        return;
726                }
727
728
22
80
                if((!defined($content_type)) || ($content_type =~ /application\/x-www-form-urlencoded/)) {
729
4
4
                        my $buffer;
730
4
5
                        if($stdin_data) {
731
1
1
                                $buffer = $stdin_data;
732                        } else {
733
3
13
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
734
1
1
                                        $self->_warn('POST failed: something else may have read STDIN');
735                                }
736
3
4
                                $stdin_data = $buffer;
737                        }
738
4
5
                        @pairs = split(/&/, $buffer);
739
740                        # if($ENV{'QUERY_STRING'}) {
741                                # my @getpairs = split(/&/, $ENV{'QUERY_STRING'});
742                                # push(@pairs, @getpairs);
743                        # }
744                } elsif($content_type =~ /multipart\/form-data/i) {
745
15
19
                        if(!defined($self->{upload_dir})) {
746
1
2
                                $self->_warn({
747                                        warning => 'Attempt to upload a file when upload_dir has not been set'
748                                });
749
0
0
                                return;
750                        }
751
752                        # Validate 'upload_dir'
753                        # Ensure the upload directory is safe and accessible
754                        # - Check permissions
755                        # - Validate path to prevent directory traversal attacks
756                        # TODO: Consider using a temporary directory for uploads and moving them later
757
14
47
                        if(!File::Spec->file_name_is_absolute($self->{upload_dir})) {
758
3
9
                                $self->_warn({
759                                        warning => "upload_dir $self->{upload_dir} isn't a full pathname"
760                                });
761
2
23
                                $self->status(500);
762
2
2
                                delete $self->{upload_dir};
763
2
5
                                return;
764                        }
765
11
56
                        if(!-d $self->{upload_dir}) {
766
3
13
                                $self->_warn({
767                                        warning => "upload_dir $self->{upload_dir} isn't a directory"
768                                });
769
1
2
                                $self->status(500);
770
1
7
                                delete $self->{upload_dir};
771
1
2
                                return;
772                        }
773
8
40
                        if(!-w $self->{upload_dir}) {
774
2
3
                                delete $self->{paramref};
775
2
7
                                $self->_warn({
776                                        warning => "upload_dir $self->{upload_dir} isn't writeable"
777                                });
778
1
5
                                $self->status(500);
779
1
2
                                delete $self->{upload_dir};
780
1
3
                                return;
781                        }
782
6
11
                        my $tmpdir = $self->tmpdir();
783
6
35
                        if($self->{'upload_dir'} !~ /^\Q$tmpdir\E/) {
784                                $self->_warn({
785
0
0
                                        warning => 'upload_dir ' . $self->{'upload_dir'} . " isn't somewhere in the temporary area $tmpdir"
786                                });
787
0
0
                                $self->status(500);
788
0
0
                                delete $self->{upload_dir};
789
0
0
                                return;
790                        }
791
6
14
                        if($content_type =~ /boundary=(\S+)$/) {
792
6
18
                                @pairs = $self->_multipart_data({
793                                        length => $content_length,
794                                        boundary => $1
795                                });
796                        }
797                } elsif($content_type =~ /text\/xml/i) {
798
1
1
                        my $buffer;
799
1
3
                        if($stdin_data) {
800
0
0
                                $buffer = $stdin_data;
801                        } else {
802
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
803
0
0
                                        $self->_warn({
804                                                warning => 'XML failed: something else may have read STDIN'
805                                        });
806                                }
807
1
1
                                $stdin_data = $buffer;
808                        }
809
810
1
1
                        $FORM{XML} = $buffer;
811
812
1
2
                        $self->{paramref} = \%FORM;
813
814
1
3
                        return \%FORM;
815                } elsif($content_type =~ /application\/json/i) {
816
1
27
                        require JSON::MaybeXS && JSON::MaybeXS->import() unless JSON::MaybeXS->can('parse_json');
817                        # require JSON::MaybeXS;
818                        # JSON::MaybeXS->import();
819
820
1
1
                        my $buffer;
821
822
1
1
                        if($stdin_data) {
823
0
0
                                $buffer = $stdin_data;
824                        } else {
825
1
4
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
826
0
0
                                        $self->_warn({
827                                                warning => 'read failed: something else may have read STDIN'
828                                        });
829                                }
830
1
1
                                $stdin_data = $buffer;
831                        }
832                        # JSON::Parse::assert_valid_json($buffer);
833                        # my $paramref = JSON::Parse::parse_json($buffer);
834
1
5
                        my $paramref = decode_json($buffer);
835
1
1
1
1
                        foreach my $key(keys(%{$paramref})) {
836
2
3
                                push @pairs, "$key=" . $paramref->{$key};
837                        }
838                } else {
839
1
1
                        my $buffer;
840
1
2
                        if($stdin_data) {
841
0
0
                                $buffer = $stdin_data;
842                        } else {
843
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
844
0
0
                                        $self->_warn({
845                                                warning => 'read failed: something else may have read STDIN'
846                                        });
847                                }
848
1
1
                                $stdin_data = $buffer;
849                        }
850
851
1
4
                        $self->_warn({
852                                warning => "POST: Invalid or unsupported content type: $content_type: $buffer",
853                        });
854                }
855        } elsif($ENV{'REQUEST_METHOD'} eq 'OPTIONS') {
856
0
0
                $self->{status} = 405;
857
0
0
                return;
858        } elsif($ENV{'REQUEST_METHOD'} eq 'DELETE') {
859
1
2
                $self->{status} = 405;
860
1
2
                return;
861        } else {
862                # TODO: Design a way to tell the caller to send HTTP
863                # status 501
864
2
4
                $self->{status} = 501;
865
2
5
                $self->_warn({
866                        warning => 'Use POST, GET or HEAD'
867                });
868        }
869
870
93
102
        unless(scalar @pairs) {
871
1
2
                return;
872        }
873
874
92
1897
        require String::Clean::XSS;
875
92
47338
        String::Clean::XSS->import();
876        # require String::EscapeCage;
877        # String::EscapeCage->import();
878
879
92
79
        foreach my $arg (@pairs) {
880
192
220
                my($key, $value) = split(/=/, $arg, 2);
881
882
192
184
                next unless($key);
883
884
188
129
                $key =~ s/\0//g;        # Strip encoded NUL byte poison
885
188
126
                $key =~ s/%00//g;       # Strip NUL byte poison
886
188
1
115
3
                $key =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
887
188
140
                $key =~ tr/+/ /;
888
188
138
                if(defined($value)) {
889
188
107
                        $value =~ s/\0//g;      # Strip NUL byte poison
890
188
104
                        $value =~ s/%00//g;     # Strip encoded NUL byte poison
891
188
83
117
101
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
892
188
112
                        $value =~ tr/+/ /;
893                } else {
894
0
0
                        $value = '';
895                }
896
897
188
148
                $key = _sanitise_input($key);
898
899
188
13762
                if($self->{allow}) {
900                        # Is this a permitted argument?
901
78
74
                        if(!exists($self->{allow}->{$key})) {
902
17
23
                                $self->_info("Discard unallowed argument '$key'");
903
17
239
                                $self->status(422);
904
17
12
                                next;   # Skip to the next parameter
905                        }
906
907                        # Do we allow any value, or must it be validated?
908
61
57
                        if(defined(my $schema = $self->{allow}->{$key})) {        # Get the schema for this key
909
54
81
                                if(!ref($schema)) {
910                                        # Can only contain one value
911
3
4
                                        if($value ne $schema) {
912
2
4
                                                $self->_info("Block $key = $value");
913
2
23
                                                $self->status(422);
914
2
2
                                                next;   # Skip to the next parameter
915                                        }
916                                } elsif(ref($schema) eq 'Regexp') {
917
12
28
                                        if($value !~ $schema) {
918                                                # Simple regex
919
8
14
                                                $self->_info("Block $key = $value");
920
8
97
                                                $self->status(422);
921
8
12
                                                next;   # Skip to the next parameter
922                                        }
923                                } elsif(ref($schema) eq 'CODE') {
924
9
10
                                        unless($schema->($key, $value, $self)) {
925
2
9
                                                $self->_info("Block $key = $value");
926
2
25
                                                next;
927                                        }
928                                } else {
929                                        # Set of rules
930
30
16
                                        eval {
931                                                $value = Params::Validate::Strict::validate_strict({
932                                                        schema => { $key => $schema },
933                                                        args => { $key => $value },
934                                                        unknown_parameter_handler => 'die',
935
30
61
                                                        logger => $self->{'logger'}
936                                                });
937                                        };
938
30
9405
                                        if($@) {
939
6
31
                                                $self->_info("Block $key = $value: $@");
940
6
104
                                                $self->status(422);
941
6
7
                                                next;   # Skip to the next parameter
942                                        }
943
24
24
12
24
                                        if(scalar keys %{$value}) {
944
24
21
                                                $value = $value->{$key};
945                                        } else {
946
0
0
                                                $self->_info("Block $key = $value");
947
0
0
                                                $self->status(422);
948
0
0
                                                next;   # Skip to the next parameter
949                                        }
950                                }
951                        }
952                }
953
954                # if($self->{expect} && (List::Util::none { $_ eq $key } @{$self->{expect}})) {
955                        # next;
956                # }
957
152
212
                my $orig_value = $value;
958
152
107
                $value = _sanitise_input($value);
959
960
152
9097
                if((!defined($ENV{'REQUEST_METHOD'})) || ($ENV{'REQUEST_METHOD'} eq 'GET')) {
961                        # From http://www.symantec.com/connect/articles/detection-sql-injection-and-cross-site-scripting-attacks
962                        # Facebook FBCLID can have "--"
963                        # if(($value =~ /(\%27)|(\')|(\-\-)|(\%23)|(\#)/ix) ||
964
137
1184
                        if(($value =~ /(\%27)|(\')|(\%23)|(\#)/ix) ||
965                           ($value =~ /((\%3D)|(=))[^\n]*((\%27)|(\')|(\-\-)|(\%3B)|(;))/i) ||
966                           ($value =~ /\w*((\%27)|(\'))((\%6F)|o|(\%4F))((\%72)|r|(\%52))\s*(OR|AND|UNION|SELECT|--)/ix) ||
967                           ($value =~ /((\%27)|(\'))union/ix) ||
968                           ($value =~ /select[[a-z]\s\*]from/ix) ||
969                           ($value =~ /\sAND\s1=1/ix) ||
970                           ($value =~ /\sOR\s.+\sAND\s/) ||
971                           ($value =~ /\/\*\*\/ORDER\/\*\*\/BY\/\*\*/ix) ||
972                           ($value =~ /\/AND\/.+\(SELECT\//) || # United/**/States)/**/AND/**/(SELECT/**/6734/**/FROM/**/(SELECT(SLEEP(5)))lRNi)/**/AND/**/(8984=8984
973                           ($value =~ /exec(\s|\+)+(s|x)p\w+/ix)) {
974
11
17
                                $self->status(403);
975
11
12
                                if($ENV{'REMOTE_ADDR'}) {
976
1
2
                                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$value'");
977                                } else {
978
10
15
                                        $self->_warn("SQL injection attempt blocked for '$key=$value'");
979                                }
980
11
27
                                return;
981                        }
982
126
108
                        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
983
0
0
                                if(($agent =~ /SELECT.+AND.+/) || ($agent =~ /ORDER BY /) || ($agent =~ / OR NOT /) || ($agent =~ / AND \d+=\d+/) || ($agent =~ /THEN.+ELSE.+END/) || ($agent =~ /.+AND.+SELECT.+/) || ($agent =~ /\sAND\s.+\sAND\s/)) {
984
0
0
                                        $self->status(403);
985
0
0
                                        if($ENV{'REMOTE_ADDR'}) {
986
0
0
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
987                                        } else {
988
0
0
                                                $self->_warn("SQL injection attempt blocked for '$agent'");
989                                        }
990
0
0
                                        return;
991                                }
992                        }
993
126
481
                        if(($value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
994                           ($value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i) ||
995                           ($orig_value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
996                           ($orig_value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i)) {
997
5
9
                                $self->status(403);
998
5
10
                                $self->_warn("XSS injection attempt blocked for '$value'");
999
5
13
                                return;
1000                        }
1001
121
606
                        if($value =~ /mustleak\.com\//) {
1002
0
0
                                $self->status(403);
1003
0
0
                                $self->_warn("Blocked mustleak attack for '$key'");
1004
0
0
                                return;
1005                        }
1006
121
118
                        if($value =~ /\.\.\//) {
1007
3
8
                                $self->status(403);
1008
3
5
                                $self->_warn("Blocked directory traversal attack for '$key'");
1009
2
3
                                return;
1010                        }
1011                }
1012
133
104
                if(length($value) > 0) {
1013                        # Don't add if it's already there
1014
128
142
                        if($FORM{$key} && ($FORM{$key} ne $value)) {
1015
3
8
                                $FORM{$key} .= ",$value";
1016                        } else {
1017
125
149
                                $FORM{$key} = $value;
1018                        }
1019                }
1020        }
1021
1022
72
69
        unless(%FORM) {
1023
11
25
                return;
1024        }
1025
1026
61
80
        if($self->{'logger'}) {
1027
61
104
                while(my ($key,$value) = each %FORM) {
1028
115
773
                        $self->_debug("$key=$value");
1029                }
1030        }
1031
1032
61
735
        $self->{paramref} = \%FORM;
1033
1034
61
110
        return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
1035}
1036
1037 - 1060
=head2 param

Get a single parameter from the query string.
Takes an optional single string parameter which is the argument to return. If
that parameter is not given param() is a wrapper to params() with no arguments.

        use CGI::Info;
        # ...
        my $info = CGI::Info->new();
        my $bar = $info->param('foo');

If the requested parameter isn't in the allowed list, an error message will
be thrown:

        use CGI::Info;
        my $allowed = {
                foo => qr/\d+/
        };
        my $xyzzy = $info->params(allow => $allowed);
        my $bar = $info->param('bar');  # Gives an error message

Returns undef if the requested parameter was not given

=cut
1061
1062sub param {
1063
40
4327
        my ($self, $field) = @_;
1064
1065
40
42
        if(!defined($field)) {
1066
2
4
                return $self->params();
1067        }
1068        # Is this a permitted argument?
1069
38
59
        if($self->{allow} && !exists($self->{allow}->{$field})) {
1070
5
13
                $self->_warn({
1071                        warning => "param: $field isn't in the allow list"
1072                });
1073
1
2
                return;
1074        }
1075
1076        # Prevent deep recursion which can happen when a validation routine calls param()
1077
33
18
        my $allow;
1078
33
35
        if($self->{in_param} && $self->{allow}) {
1079
1
2
                $allow = delete $self->{allow};
1080        }
1081
33
24
        $self->{in_param} = 1;
1082
1083
33
43
        my $params = $self->params();
1084
1085
33
710
        $self->{in_param} = 0;
1086
33
25
        $self->{allow} = $allow if($allow);
1087
1088
33
45
        if($params) {
1089
27
39
                return Return::Set::set_return($params->{$field}, { type => 'string' });
1090        }
1091}
1092
1093sub _sanitise_input($) {
1094
340
205
        my $arg = shift;
1095
1096
340
751
        return if(!defined($arg));
1097
1098        # Remove hacking attempts and spaces
1099
340
249
        $arg =~ s/[\r\n]//g;
1100
340
232
        $arg =~ s/\s+$//;
1101
340
234
        $arg =~ s/^\s//;
1102
1103
340
181
        $arg =~ s/<!--.*-->//g;
1104        # Allow :
1105        # $arg =~ s/[;<>\*|`&\$!?#\(\)\[\]\{\}'"\\\r]//g;
1106
1107        # return $arg;
1108        # return String::EscapeCage->new(convert_XSS($arg))->escapecstring();
1109
340
281
        return convert_XSS($arg);
1110}
1111
1112sub _multipart_data {
1113
6
6
        my ($self, $args) = @_;
1114
1115
6
7
        $self->_trace('Entering _multipart_data');
1116
1117
6
69
        my $total_bytes = $$args{length};
1118
1119
6
13
        $self->_debug("_multipart_data: total_bytes = $total_bytes");
1120
1121
6
63
        if($total_bytes == 0) {
1122
0
0
                return;
1123        }
1124
1125
6
7
        unless($stdin_data) {
1126
6
17
                while(<STDIN>) {
1127
54
24
                        chop(my $line = $_);
1128
54
33
                        $line =~ s/[\r\n]//g;
1129
54
54
                        $stdin_data .= "$line\n";
1130                }
1131
6
8
                if(!$stdin_data) {
1132
0
0
                        return;
1133                }
1134        }
1135
1136
6
21
        my $boundary = $$args{boundary};
1137
1138
6
5
        my @pairs;
1139
6
5
        my $writing_file = 0;
1140
6
4
        my $key;
1141        my $value;
1142
6
4
        my $in_header = 0;
1143
6
4
        my $fout;
1144
1145
6
11
        foreach my $line(split(/\n/, $stdin_data)) {
1146
44
64
                if($line =~ /^--\Q$boundary\E--$/) {
1147
2
3
                        last;
1148                }
1149
42
60
                if($line =~ /^--\Q$boundary\E$/) {
1150
10
12
                        if($writing_file) {
1151
0
0
                                close $fout;
1152
0
0
                                $writing_file = 0;
1153                        } elsif(defined($key)) {
1154
4
4
                                push(@pairs, "$key=$value");
1155
4
3
                                $value = undef;
1156                        }
1157
10
10
                        $in_header = 1;
1158                } elsif($in_header) {
1159
20
32
                        if(length($line) == 0) {
1160
8
6
                                $in_header = 0;
1161                        } elsif($line =~ /^Content-Disposition: (.+)/i) {
1162
10
8
                                my $field = $1;
1163
10
18
                                if($field =~ /name="(.+?)"/) {
1164
10
7
                                        $key = $1;
1165                                }
1166
10
18
                                if($field =~ /filename="(.+)?"/) {
1167
6
3
                                        my $filename = $1;
1168
6
12
                                        unless(defined($filename)) {
1169
0
0
                                                $self->_warn('No upload filename given');
1170
0
0
                                        } elsif($filename =~ /[\\\/\|]/) {
1171
2
4
                                                $self->_warn("Disallowing invalid filename: $filename");
1172                                        } else {
1173
4
10
                                                $filename = $self->_create_file_name({
1174                                                        filename => $filename
1175                                                });
1176
1177                                                # Don't do this since it taints the string and I can't work out how to untaint it
1178                                                # my $full_path = Cwd::realpath(File::Spec->catfile($self->{upload_dir}, $filename));
1179                                                # $full_path =~ m/^(\/[\w\.]+)$/;
1180
4
23
                                                my $full_path = File::Spec->catfile($self->{upload_dir}, $filename);
1181
4
174
                                                unless(open($fout, '>', $full_path)) {
1182
0
0
                                                        $self->_warn("Can't open $full_path");
1183                                                }
1184
4
14
                                                $writing_file = 1;
1185
4
9
                                                push(@pairs, "$key=$filename");
1186                                        }
1187                                }
1188                        }
1189                        # TODO: handle Content-Type: text/plain, etc.
1190                } else {
1191
12
9
                        if($writing_file) {
1192
8
36
                                print $fout "$line\n";
1193                        } else {
1194
4
5
                                $value .= $line;
1195                        }
1196                }
1197        }
1198
1199
4
6
        if($writing_file) {
1200
4
75
                close $fout;
1201        }
1202
1203
4
7
        $self->_trace('Leaving _multipart_data');
1204
1205
4
67
        return @pairs;
1206}
1207
1208# Robust filename generation (preventing overwriting)
1209sub _create_file_name {
1210
4
3
        my ($self, $args) = @_;
1211
4
5
        my $filename = $$args{filename} . '_' . time;
1212
1213
4
4
        my $counter = 0;
1214
4
3
        my $rc;
1215
1216
4
2
        do {
1217
4
8
                $rc = $filename . ($counter ? "_$counter" : '');
1218
4
38
                $counter++;
1219        } until(! -e $rc);      # Check if file exists
1220
1221
4
6
        return $rc;
1222}
1223
1224# Untaint a filename. Regex from CGI::Untaint::Filenames
1225sub _untaint_filename {
1226
51
51
        my ($self, $args) = @_;
1227
1228
51
104
        if($$args{filename} =~ /(^[\w\+_\040\#\(\)\{\}\[\]\/\-\^,\.:;&%@\\~]+\$?$)/) {
1229
51
87
                return $1;
1230        }
1231        # return undef;
1232}
1233
1234 - 1242
=head2 is_mobile

Returns a boolean if the website is being viewed on a mobile
device such as a smartphone.
All tablets are mobile, but not all mobile devices are tablets.

Can be overriden by the IS_MOBILE environment setting

=cut
1243
1244sub is_mobile {
1245
43
1186
        my $self = shift;
1246
1247
43
80
        if(defined($self->{is_mobile})) {
1248
12
17
                return $self->{is_mobile};
1249        }
1250
1251
31
38
        if($ENV{'IS_MOBILE'}) {
1252
1
3
                return $ENV{'IS_MOBILE'}
1253        }
1254
1255        # Support Sec-CH-UA-Mobile
1256
30
41
        if(my $ch_ua_mobile = $ENV{'HTTP_SEC_CH_UA_MOBILE'}) {
1257
3
4
                if($ch_ua_mobile eq '?1') {
1258
1
1
                        $self->{is_mobile} = 1;
1259
1
3
                        return 1;
1260                }
1261        }
1262
1263
29
35
        if($ENV{'HTTP_X_WAP_PROFILE'}) {
1264                # E.g. Blackberry
1265                # TODO: Check the sanity of this variable
1266
1
2
                $self->{is_mobile} = 1;
1267
1
2
                return 1;
1268        }
1269
1270
28
35
        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1271
18
728
                if($agent =~ /.+(Android|iPhone).+/) {
1272
3
3
                        $self->{is_mobile} = 1;
1273
3
6
                        return 1;
1274                }
1275
1276                # From http://detectmobilebrowsers.com/
1277
15
396
                if($agent =~ m/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i || substr($ENV{'HTTP_USER_AGENT'}, 0, 4) =~ m/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i) {
1278
1
1
                        $self->{is_mobile} = 1;
1279
1
2
                        return 1;
1280                }
1281
1282                # Save loading and calling HTTP::BrowserDetect
1283
14
21
                my $remote = $ENV{'REMOTE_ADDR'};
1284
14
21
                if(defined($remote) && $self->{cache}) {
1285
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1286
0
0
                                return $self->{is_mobile} = ($type eq 'mobile');
1287                        }
1288                }
1289
1290
14
17
                unless($self->{browser_detect}) {
1291
8
8
5
1569
                        if(eval { require HTTP::BrowserDetect; }) {
1292
8
31992
                                HTTP::BrowserDetect->import();
1293
8
32
                                $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1294                        }
1295                }
1296
1297
14
619
                if($self->{browser_detect}) {
1298
14
24
                        my $device = $self->{browser_detect}->device();
1299                        # Without the ?1:0 it will set to the empty string not 0
1300
14
61
                        my $is_mobile = (defined($device) && ($device =~ /blackberry|webos|iphone|ipod|ipad|android/i)) ? 1 : 0;
1301
14
20
                        if($is_mobile && $self->{cache} && defined($remote)) {
1302
0
0
                                $self->{cache}->set("$remote/$agent", 'mobile', '1 day');
1303                        }
1304
14
32
                        return $self->{is_mobile} = $is_mobile;
1305                }
1306        }
1307
1308
10
17
        return 0;
1309}
1310
1311 - 1315
=head2 is_tablet

Returns a boolean if the website is being viewed on a tablet such as an iPad.

=cut
1316
1317sub is_tablet {
1318
6
25
        my $self = shift;
1319
1320
6
9
        if(defined($self->{is_tablet})) {
1321
1
2
                return $self->{is_tablet};
1322        }
1323
1324
5
143
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1325                # TODO: add others when I see some nice user_agents
1326
1
1
                $self->{is_tablet} = 1;
1327        } else {
1328
4
4
                $self->{is_tablet} = 0;
1329        }
1330
1331
5
9
        return $self->{is_tablet};
1332}
1333
1334 - 1360
=head2 as_string

Converts CGI parameters into a formatted string representation with optional raw mode (no escaping of special characters).
Useful for debugging or generating keys for a cache.

    my $string_representation = $info->as_string();
    my $raw_string = $info->as_string({ raw => 1 });

=head3 API SPECIFICATION

=head4 INPUT

  {
    raw => {
      'type' => 'boolean',
      'optional' => 1,
    }
  }

=head4 OUTPUT

  {
    type => 'string',
    optional => 1,
  }

=cut
1361
1362sub as_string
1363{
1364
40
8147
        my $self = shift;
1365
1366
40
54
        my $args = Params::Validate::Strict::validate_strict({
1367                args => Params::Get::get_params(undef, @_) || {},
1368                schema => {
1369                        raw => {
1370                                'type' => 'boolean',
1371                                'optional' => 1
1372                        }
1373                }
1374        });
1375
1376        # Retrieve object parameters
1377
40
2138
        my $params = $self->params() || return '';
1378
1379
30
208
        my $rc;
1380
1381
30
34
        if($args->{'raw'}) {
1382                # Raw mode: return key=value pairs without escaping
1383                $rc = join '; ', map {
1384
4
6
                        "$_=" . $params->{$_}
1385
2
2
2
4
                } sort keys %{$params};
1386        } else {
1387                # Escaped mode: escape special characters
1388                $rc = join '; ', map {
1389
42
31
                        my $value = $params->{$_};
1390
1391
42
36
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1392
42
56
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1393
42
64
                        "$_=$value"
1394
28
28
20
41
                } sort keys %{$params};
1395        }
1396
1397
30
64
        $self->_trace("as_string: returning '$rc'") if($rc);
1398
1399
30
400
        return $rc;
1400}
1401
1402 - 1407
=head2 protocol

Returns the connection protocol, presumably 'http' or 'https', or undef if
it can't be determined.

=cut
1408
1409sub protocol {
1410
22
672
        my $self = shift;
1411
1412
22
36
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1413
2
6
                return $1;
1414        }
1415
20
39
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1416
2
4
                return 'http';
1417        }
1418
1419
18
24
        if(my $port = $ENV{'SERVER_PORT'}) {
1420
13
573
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1421
13
29
                        if($name =~ /https?/) {
1422
11
26
                                return $name;
1423                        } elsif($name eq 'www') {
1424                                # e.g. NetBSD and OpenBSD
1425
0
0
                                return 'http';
1426                        }
1427                        # Return an error, maybe missing something
1428                } elsif($port == 80) {
1429                        # e.g. Solaris
1430
0
0
                        return 'http';
1431                } elsif($port == 443) {
1432
0
0
                        return 'https';
1433                }
1434        }
1435
1436
7
10
        if($ENV{'REMOTE_ADDR'}) {
1437
0
0
                $self->_warn("Can't determine the calling protocol");
1438        }
1439
7
20
        return;
1440}
1441
1442 - 1467
=head2 tmpdir

Returns the name of a directory that you can use to create temporary files
in.

The routine is preferable to L<File::Spec/tmpdir> since CGI programs are
often running on shared servers.  Having said that, tmpdir will fall back
to File::Spec->tmpdir() if it can't find somewhere better.

If the parameter 'default' is given, then use that directory as a
fall-back rather than the value in File::Spec->tmpdir().
No sanity tests are done, so if you give the default value of
'/non-existant', that will be returned.

Tmpdir allows a reference of the options to be passed.

        use CGI::Info;

        my $info = CGI::Info->new();
        my $dir = $info->tmpdir(default => '/var/tmp');
        $dir = $info->tmpdir({ default => '/var/tmp' });

        # or

        my $dir = CGI::Info->tmpdir();
=cut
1468
1469sub tmpdir {
1470
23
1604
        my $self = shift;
1471
1472
23
18
        my $name = 'tmp';
1473
23
33
        if($^O eq 'MSWin32') {
1474
0
0
                $name = 'temp';
1475        }
1476
1477
23
16
        my $dir;
1478
1479
23
30
        if(!ref($self)) {
1480
3
3
                $self = __PACKAGE__->new();
1481        }
1482
23
33
        my $params = Params::Get::get_params(undef, @_);
1483
1484
23
237
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1485
5
21
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1486
5
33
                if((-d $dir) && (-w $dir)) {
1487
2
3
                        return $self->_untaint_filename({ filename => $dir });
1488                }
1489
3
2
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1490
3
17
                if((-d $dir) && (-w $dir)) {
1491
3
7
                        return $self->_untaint_filename({ filename => $dir });
1492                }
1493        }
1494
18
40
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1495
1
7
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1496
1
4
                if((-d $dir) && (-w $dir)) {
1497
0
0
                        return $self->_untaint_filename({ filename => $dir });
1498                }
1499        }
1500
18
34
        if($params->{'default'} && ref($params->{'default'})) {
1501
0
0
                croak(ref($self), ': tmpdir must be given a scalar');
1502        }
1503
18
222
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1504}
1505
1506 - 1518
=head2 rootdir

Returns the document root.  This is preferable to looking at DOCUMENT_ROOT
in the environment because it will also work when we're not running as a CGI
script, which is useful for script debugging.

This can be run as a class or object method.

        use CGI::Info;

        print CGI::Info->rootdir();

=cut
1519
1520sub rootdir {
1521
14
950
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1522
1
2
                return $ENV{'C_DOCUMENT_ROOT'};
1523        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1524
2
3
                return $ENV{'DOCUMENT_ROOT'};
1525        }
1526
11
9
        my $script_name = $0;
1527
1528
11
25
        unless(File::Spec->file_name_is_absolute($script_name)) {
1529
11
71
                $script_name = File::Spec->rel2abs($script_name);
1530        }
1531
11
14
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1532
0
0
                $script_name =~ s/.cgi\-bin.*//;
1533        }
1534
11
49
        if(-f $script_name) {   # More kludge
1535
11
11
                if($^O eq 'MSWin32') {
1536
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1537
0
0
                                return $1;
1538                        }
1539                } else {
1540
11
30
                        if($script_name =~ /(.+)\/.+?$/) {
1541
11
22
                                return $1;
1542                        }
1543                }
1544        }
1545
0
0
        return $script_name;
1546}
1547
1548 - 1552
=head2 root_dir

Synonym of rootdir(), for compatibility with L<CHI>.

=cut
1553
1554sub root_dir
1555{
1556
4
444
        if($_[0] && ref($_[0])) {
1557
2
1
                my $self = shift;
1558
1559
2
5
                return $self->rootdir(@_);
1560        }
1561
2
3
        return __PACKAGE__->rootdir(@_);
1562}
1563
1564 - 1568
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1569
1570sub documentroot
1571{
1572
3
12
        if($_[0] && ref($_[0])) {
1573
1
0
                my $self = shift;
1574
1575
1
2
                return $self->rootdir(@_);
1576        }
1577
2
1
        return __PACKAGE__->rootdir(@_);
1578}
1579
1580 - 1584
=head2 logdir

Gets and sets the name of a directory that you can use to store logs in.

=cut
1585
1586sub logdir {
1587
5
1310
        my $self = shift;
1588
5
5
        my $dir = shift;
1589
1590
5
9
        if(!ref($self)) {
1591
1
2
                $self = __PACKAGE__->new();
1592        }
1593
1594
5
5
        if($dir) {
1595
2
20
                if(length($dir) && (-d $dir) && (-w $dir)) {
1596
1
15
                        return $self->{'logdir'} = $dir;
1597                }
1598
1
2
                $self->_warn("Invalid logdir: $dir");
1599
1
14
                Carp::croak("Invalid logdir: $dir");
1600        }
1601
1602
3
30
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1603
9
43
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1604
3
3
                        $dir = $rc;
1605
3
1
                        last;
1606                }
1607        }
1608
3
9
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1609
3
4
        $self->{logdir} ||= $dir;
1610
1611
3
9
        return $dir;
1612}
1613
1614 - 1629
=head2 is_robot

Is the visitor a real person or a robot?

        use CGI::Info;

        my $info = CGI::Info->new();
        unless($info->is_robot()) {
                # update site visitor statistics
        }

If the client is seen to be attempting an SQL injection,
set the HTTP status to 403,
and return 1.

=cut
1630
1631sub is_robot {
1632
21
331
        my $self = shift;
1633
1634
21
29
        if(defined($self->{is_robot})) {
1635
3
4
                return $self->{is_robot};
1636        }
1637
1638
18
17
        my $agent = $ENV{'HTTP_USER_AGENT'};
1639
18
16
        my $remote = $ENV{'REMOTE_ADDR'};
1640
1641
18
30
        unless($remote && $agent) {
1642                # Probably not running in CGI - assume real person
1643
8
11
                return 0;
1644        }
1645
1646        # See also params()
1647
10
61
        if(($agent =~ /SELECT.+AND.+/) || ($agent =~ /ORDER BY /) || ($agent =~ / OR NOT /) || ($agent =~ / AND \d+=\d+/) || ($agent =~ /THEN.+ELSE.+END/) || ($agent =~ /.+AND.+SELECT.+/) || ($agent =~ /\sAND\s.+\sAND\s/)) {
1648
1
2
                $self->status(403);
1649
1
1
                $self->{is_robot} = 1;
1650
1
2
                if($ENV{'REMOTE_ADDR'}) {
1651
1
3
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1652                } else {
1653
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1654                }
1655
1
2
                return 1;
1656        }
1657
9
265
        if($agent =~ /.+bot|axios\/1\.6\.7|bidswitchbot|bytespider|ClaudeBot|Clickagy.Intelligence.Bot|msnptc|CriteoBot|is_archiver|backstreet|fuzz faster|linkfluence\.com|spider|scoutjet|gingersoftware|heritrix|dodnetdotcom|yandex|nutch|ezooms|plukkie|nova\.6scan\.com|Twitterbot|adscanner|Go-http-client|python-requests|Mediatoolkitbot|NetcraftSurveyAgent|Expanse|serpstatbot|DreamHost SiteMonitor|techiaith.cymru|trendictionbot|ias_crawler|WPsec|Yak\/1\.0|ZoominfoBot/i) {
1658
3
4
                $self->{is_robot} = 1;
1659
3
6
                return 1;
1660        }
1661
1662        # TODO:
1663        # Download and use list from
1664        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1665
1666
6
8
        my $key = "$remote/$agent";
1667
1668
6
8
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1669                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1670
2
8
                my @crawler_lists = (
1671                        'http://fix-website-errors.com',
1672                        'http://keywords-monitoring-your-success.com',
1673                        'http://free-video-tool.com',
1674                        'http://magnet-to-torrent.com',
1675                        'http://torrent-to-magnet.com',
1676                        'http://dogsrun.net',
1677                        'http://###.responsive-test.net',
1678                        'http://uptime.com',
1679                        'http://uptimechecker.com',
1680                        'http://top1-seo-service.com',
1681                        'http://fast-wordpress-start.com',
1682                        'http://wordpress-crew.net',
1683                        'http://dbutton.net',
1684                        'http://justprofit.xyz',
1685                        'http://video--production.com',
1686                        'http://buttons-for-website.com',
1687                        'http://buttons-for-your-website.com',
1688                        'http://success-seo.com',
1689                        'http://videos-for-your-business.com',
1690                        'http://semaltmedia.com',
1691                        'http://dailyrank.net',
1692                        'http://uptimebot.net',
1693                        'http://sitevaluation.org',
1694                        'http://100dollars-seo.com',
1695                        'http://forum69.info',
1696                        'http://partner.semalt.com',
1697                        'http://best-seo-offer.com',
1698                        'http://best-seo-solution.com',
1699                        'http://semalt.semalt.com',
1700                        'http://semalt.com',
1701                        'http://7makemoneyonline.com',
1702                        'http://anticrawler.org',
1703                        'http://baixar-musicas-gratis.com',
1704                        'http://descargar-musica-gratis.net',
1705
1706                        # Mine
1707                        'http://www.seokicks.de/robot.html',
1708                );
1709
2
1
                $referrer =~ s/\\/_/g;
1710
2
3
8
17
                if(($referrer =~ /\)/) || (List::Util::any { $_ =~ /^$referrer/ } @crawler_lists)) {
1711
2
4
                        $self->_debug("is_robot: blocked trawler $referrer");
1712
1713
2
7
                        if($self->{cache}) {
1714
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1715                        }
1716
2
2
                        $self->{is_robot} = 1;
1717
2
7
                        return 1;
1718                }
1719        }
1720
1721
4
10
        if(defined($remote) && $self->{cache}) {
1722
0
0
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1723
0
0
                        return $self->{is_robot} = ($type eq 'robot');
1724                }
1725        }
1726
1727        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1728        # that is easily spoofed
1729
4
7
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1730                # Mark Facebook as a search engine, not a robot
1731
0
0
                if($self->{cache}) {
1732
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1733                }
1734
0
0
                return 0;
1735        }
1736
1737
4
5
        unless($self->{browser_detect}) {
1738
3
3
3
7
                if(eval { require HTTP::BrowserDetect; }) {
1739
3
5
                        HTTP::BrowserDetect->import();
1740
3
3
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1741                }
1742        }
1743
4
212
        if($self->{browser_detect}) {
1744
4
6
                my $is_robot = $self->{browser_detect}->robot();
1745
4
355
                if(defined($is_robot)) {
1746
2
7
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1747                }
1748
4
43
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1749
4
7
                $self->_debug("is_robot: $is_robot");
1750
1751
4
48
                if($is_robot) {
1752
2
3
                        if($self->{cache}) {
1753
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1754                        }
1755
2
8
                        $self->{is_robot} = $is_robot;
1756
2
4
                        return $is_robot;
1757                }
1758        }
1759
1760
2
2
        if($self->{cache}) {
1761
0
0
                $self->{cache}->set($key, 'unknown', '1 day');
1762        }
1763
2
3
        $self->{is_robot} = 0;
1764
2
4
        return 0;
1765}
1766
1767 - 1779
=head2 is_search_engine

Is the visitor a search engine?

    if(CGI::Info->new()->is_search_engine()) {
        # display generic information about yourself
    } else {
        # allow the user to pick and choose something to display
    }

Can be overriden by the IS_SEARCH_ENGINE environment setting

=cut
1780
1781sub is_search_engine
1782{
1783
28
564
        my $self = shift;
1784
1785
28
33
        if(defined($self->{is_search_engine})) {
1786
6
8
                return $self->{is_search_engine};
1787        }
1788
1789
22
28
        if($ENV{'IS_SEARCH_ENGINE'}) {
1790
1
2
                return $ENV{'IS_SEARCH_ENGINE'}
1791        }
1792
1793
21
22
        my $remote = $ENV{'REMOTE_ADDR'};
1794
21
17
        my $agent = $ENV{'HTTP_USER_AGENT'};
1795
1796
21
35
        unless($remote && $agent) {
1797                # Probably not running in CGI - assume not a search engine
1798
9
14
                return 0;
1799        }
1800
1801
12
8
        my $key;
1802
1803
12
13
        if($self->{cache}) {
1804
0
0
                $key = "$remote/$agent";
1805
0
0
                if(defined($remote) && $self->{cache}) {
1806
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1807
0
0
                                return $self->{is_search} = ($type eq 'search');
1808                        }
1809                }
1810        }
1811
1812        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1813        # that is easily spoofed
1814
12
31
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1815                # Mark Facebook as a search engine, not a robot
1816
0
0
                if($self->{cache}) {
1817
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1818                }
1819
0
0
                return 1;
1820        }
1821
1822
12
14
        unless($self->{browser_detect}) {
1823
8
8
6
414
                if(eval { require HTTP::BrowserDetect; }) {
1824
8
8138
                        HTTP::BrowserDetect->import();
1825
8
18
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1826                }
1827        }
1828
12
607
        if(my $browser = $self->{browser_detect}) {
1829
12
14
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1830
12
1745
                if(!$is_search) {
1831
6
30
                        if(($agent =~ /SeznamBot\//) ||
1832                           ($agent =~ /Google-InspectionTool\//) ||
1833                           ($agent =~ /Googlebot\//)) {
1834
1
2
                                $is_search = 1;
1835                        }
1836                }
1837
12
18
                if($is_search && $self->{cache}) {
1838
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1839                }
1840
12
30
                return $self->{is_search_engine} = $is_search;
1841        }
1842
1843        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1844
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1845
1846
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1847
1848
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1849           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1850
0
0
                if($self->{cache}) {
1851
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1852                }
1853
0
0
                $self->{is_search_engine} = 1;
1854
0
0
                return 1;
1855        }
1856
1857
0
0
        $self->{is_search_engine} = 0;
1858
0
0
        return 0;
1859}
1860
1861 - 1883
=head2 browser_type

Returns one of 'web', 'search', 'robot' and 'mobile'.

    # Code to display a different web page for a browser, search engine and
    # smartphone
    use Template;
    use CGI::Info;

    my $info = CGI::Info->new();
    my $dir = $info->rootdir() . '/templates/' . $info->browser_type();

    my $filename = ref($self);
    $filename =~ s/::/\//g;
    $filename = "$dir/$filename.tmpl";

    if((!-f $filename) || (!-r $filename)) {
        die "Can't open $filename";
    }
    my $template = Template->new();
    $template->process($filename, {}) || die $template->error();

=cut
1884
1885sub browser_type {
1886
21
18
        my $self = shift;
1887
1888
21
27
        if($self->is_mobile()) {
1889
8
16
                return 'mobile';
1890        }
1891
13
25
        if($self->is_search_engine()) {
1892
6
15
                return 'search';
1893        }
1894
7
14
        if($self->is_robot()) {
1895
3
8
                return 'robot';
1896        }
1897
4
8
        return 'web';
1898}
1899
1900 - 1915
=head2 get_cookie

Returns a cookie's value, or undef if no name is given, or the requested
cookie isn't in the jar.

Deprecated - use cookie() instead.

    use CGI::Info;

    my $i = CGI::Info->new();
    my $name = $i->get_cookie(cookie_name => 'name');
    print "Your name is $name\n";
    my $address = $i->get_cookie('address');
    print "Your address is $address\n";

=cut
1916
1917sub get_cookie {
1918
13
357
        my $self = shift;
1919
1920
13
16
        return $self->cookie(\@_);
1921}
1922
1923 - 1967
=head2 cookie

Returns a cookie's value, or undef if no name is given, or the requested
cookie isn't in the jar.
API is the same as "param",
it will replace the "get_cookie" method in the future.

    use CGI::Info;

    my $name = CGI::Info->new()->cookie('name');
    print "Your name is $name\n";


=head3 API SPECIFICATION

=head4 INPUT

  {
    cookie_name => {
      'type' => 'string',
      'min' => 1,
      'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/     # RFC6265
    }
  }

=head4 OUTPUT

Cookie not set: C<undef>

Cookie set:

  {
    type => 'string',
    optional => 1,
    matches => qr/   # RFC6265
      ^
      (?:
        "[\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*"   # quoted
      | [\x21\x23-\x2B\x2D-\x3A\x3C-\x5B\x5D-\x7E]*     # unquoted
      )
      $
    /x
  }

=cut
1968
1969sub cookie
1970{
1971
23
2376
        my $self = shift;
1972
23
30
        my $params = Params::Validate::Strict::validate_strict({
1973                args => Params::Get::get_params('cookie_name', @_),
1974                schema => {
1975                        cookie_name => {
1976                                'type' => 'string',
1977                                'min' => 1,
1978                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
1979                        }
1980                }
1981        });
1982
1983
21
1560
        my $field = $params->{'cookie_name'};
1984
1985        # Validate field argument
1986
21
23
        if(!defined($field)) {
1987
2
5
                $self->_error('what cookie do you want?');
1988
2
15
                Carp::croak('what cookie do you want?');
1989        }
1990
1991        # Load cookies if not already loaded
1992
19
17
        unless($self->{jar}) {
1993
9
9
                if(defined $ENV{'HTTP_COOKIE'}) {
1994
8
19
26
28
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
1995                }
1996        }
1997
1998        # Return the cookie value if it exists, otherwise return undef
1999
19
47
        return $self->{jar}{$field};
2000}
2001
2002 - 2008
=head2 status

Sets or returns the status of the object,
200 for OK,
otherwise an HTTP error code

=cut
2009
2010sub status
2011{
2012
91
3866
        my $self = shift;
2013
91
61
        my $status = shift;
2014
2015        # Set status if provided
2016
91
109
        return $self->{status} = $status if(defined($status));
2017
2018        # Determine status based on request method if status is not set
2019
32
48
        unless (defined $self->{status}) {
2020
13
14
                my $method = $ENV{'REQUEST_METHOD'};
2021
2022
13
25
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
2023
9
22
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
2024
2025
7
21
                return 200;
2026        }
2027
2028        # Return current status or 200 by default
2029
19
53
        return $self->{status} || 200;
2030}
2031
2032 - 2044
=head2 messages

Returns the messages that the object has generated as a ref to an array of hashes.

    my @messages;
    if(my $w = $info->messages()) {
        @messages = map { $_->{'message'} } @{$w};
    } else {
        @messages = ();
    }
    print STDERR join(';', @messages), "\n";

=cut
2045
2046sub messages
2047{
2048
7
2913
        my $self = shift;
2049
2050
7
25
        return $self->{'messages'};
2051}
2052
2053 - 2057
=head2  messages_as_string

Returns the messages of that the object has generated as a string.

=cut
2058
2059sub messages_as_string
2060{
2061
2
2
        my $self = shift;
2062
2063
2
4
        if(scalar($self->{'messages'})) {
2064
1
2
1
1
3
2
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2065
1
3
                return join('; ', @messages);
2066        }
2067
1
2
        return '';
2068}
2069
2070 - 2079
=head2 cache

Get/set the internal cache system.

Use this rather than pass the cache argument to C<new()> if you see these error messages,
"(in cleanup) Failed to get MD5_CTX pointer".
It's some obscure problem that I can't work out,
but calling this after C<new()> works.

=cut
2080
2081sub cache
2082{
2083
4
19
        my $self = shift;
2084
4
3
        my $cache = shift;
2085
2086
4
6
        if($cache) {
2087
0
0
                $self->{'cache'} = $cache;
2088        }
2089
4
4
        return $self->{'cache'};
2090}
2091
2092 - 2099
=head2 set_logger

Sets the class, array, code reference, or file that will be used for logging.

Sometimes you don't know what the logger is until you've instantiated the class.
This function fixes the catch-22 situation.

=cut
2100
2101sub set_logger
2102{
2103
6
31
        my $self = shift;
2104
6
11
        my $params = Params::Get::get_params('logger', @_);
2105
2106
6
81
        if(my $logger = $params->{'logger'}) {
2107
6
14
                if(Scalar::Util::blessed($logger)) {
2108
4
4
                        $self->{'logger'} = $logger;
2109                } else {
2110
2
5
                        $self->{'logger'} = Log::Abstraction->new($logger);
2111                }
2112        } else {
2113
0
0
                $self->{'logger'} = Log::Abstraction->new();
2114        }
2115
6
51
        return $self;
2116}
2117
2118# Log and remember a message
2119sub _log
2120{
2121
412
348
        my ($self, $level, @messages) = @_;
2122
2123
412
611
        if(ref($self) && scalar(@messages)) {
2124                # FIXME: add caller's function
2125                # if(($level eq 'warn') || ($level eq 'info')) {
2126
412
412
216
928
                        push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2127                # }
2128
2129
412
625
                if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2130
412
706
                        $self->{'logger'}->$level(join('', grep defined, @messages));
2131                }
2132        }
2133}
2134
2135sub _debug {
2136
132
77
        my $self = shift;
2137
132
112
        $self->_log('debug', @_);
2138}
2139
2140sub _info {
2141
35
25
        my $self = shift;
2142
35
32
        $self->_log('info', @_);
2143}
2144
2145sub _notice {
2146
0
0
        my $self = shift;
2147
0
0
        $self->_log('notice', @_);
2148}
2149
2150sub _trace {
2151
199
119
        my $self = shift;
2152
199
213
        $self->_log('trace', @_);
2153}
2154
2155# Emit a warning message somewhere
2156sub _warn {
2157
44
36
        my $self = shift;
2158
44
57
        my $params = Params::Get::get_params('warning', @_);
2159
2160
44
483
        $self->_log('warn', $params->{'warning'});
2161
29
2776
        if(!defined($self->{'logger'})) {
2162
0
0
                Carp::carp($params->{'warning'});
2163        }
2164}
2165
2166# Emit an error message somewhere
2167sub _error {
2168
2
7
        my $self = shift;
2169
2
3
        my $params = Params::Get::get_params('warning', @_);
2170
2171
2
22
        $self->_log('error', $params->{'warning'});
2172
2
546
        if(!defined($self->{'logger'})) {
2173
0
0
                Carp::croak($params->{'warning'});
2174        }
2175}
2176
2177# Ensure all environment variables are sanitized and validated before use.
2178# Use regular expressions to enforce strict input formats.
2179sub _get_env
2180{
2181
111
87
        my ($self, $var) = @_;
2182
2183
111
186
        return unless defined $ENV{$var};
2184
2185        # Strict sanitization: allow alphanumeric and limited special characters
2186
63
107
        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2187
63
77
                return $ENV{$var};
2188        }
2189
0
0
        $self->_warn("Invalid value in environment variable: $var");
2190
2191
0
0
        return undef;
2192}
2193
2194 - 2200
=head2 reset

Class method to reset the class.
You should do this in an FCGI environment before instantiating,
but nowhere else.

=cut
2201
2202sub reset {
2203
13
7912
        my $class = shift;
2204
2205
13
19
        unless($class eq __PACKAGE__) {
2206
1
9
                carp('Reset is a class method');
2207
0
0
                return;
2208        }
2209
2210
12
13
        $stdin_data = undef;
2211}
2212
2213sub AUTOLOAD
2214{
2215
241
51022
        our $AUTOLOAD;
2216
2217
241
287
        my $self = shift or return;
2218
2219        # Extract the method name from the AUTOLOAD variable
2220
241
760
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2221
2222        # Skip if called on destruction
2223
241
645
        return if($method eq 'DESTROY');
2224
2225
8
9
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2226
2227        # Allow the AUTOLOAD feature to be disabled
2228
8
15
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2229
2230        # Ensure the method is called on the correct package object or a subclass
2231
7
15
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2232
2233        # Validate method name - only allow safe parameter names
2234
7
16
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2235
2236        # Delegate to the param method
2237
7
11
        return $self->param($method);
2238}
2239
2240 - 2326
=head1 AUTHOR

Nigel Horne, C<< <njh at nigelhorne.com> >>

=head1 BUGS

is_tablet() only currently detects the iPad and Windows PCs. Android strings
don't differ between tablets and smartphones.

params() returns a ref which means that calling routines can change the hash
for other routines.
Take a local copy before making amendments to the table if you don't want unexpected
things to happen.

=head1 SEE ALSO

=over 4

=item * Test coverage report: L<https://nigelhorne.github.io/CGI-Info/coverage/>

=item * L<Object::Configure>

=item * L<HTTP::BrowserDetect>

=item * L<https://github.com/mitchellkrogza/apache-ultimate-bad-bot-blocker>

=back

=head1 REPOSITORY

L<https://github.com/nigelhorne/CGI-Info>

=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-cgi-info at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=CGI-Info>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

You can find documentation for this module with the perldoc command.

    perldoc CGI::Info

You can also look for information at:

=over 4

=item * MetaCPAN

L<https://metacpan.org/dist/CGI-Info>

=item * RT: CPAN's request tracker

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=CGI-Info>

=item * CPAN Testers' Matrix

L<http://matrix.cpantesters.org/?dist=CGI-Info>

=item * CPAN Testers Dependencies

L<http://deps.cpantesters.org/?module=CGI::Info>

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2010-2025 Nigel Horne.

Usage is subject to licence terms.

The licence terms of this software are as follows:

=over 4

=item * Personal single user, single computer use: GPL2

=item * All other users (including Commercial, Charity, Educational, Government)
  must apply in writing for a licence for use from Nigel Horne at the
  above e-mail.

=back

=cut
2327
23281;