File Coverage

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

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
1920315
25
608
use warnings;
7
27
27
27
46
22
225
use strict;
8
9
27
27
27
3561
12227
47
use boolean;
10
27
27
27
807
26
610
use Carp;
11
27
27
27
5349
1434921
427
use Object::Configure 0.19;
12
27
27
27
99
23
380
use File::Spec;
13
27
27
27
45
148
235
use Log::Abstraction 0.10;
14
27
27
27
53
130
416
use Params::Get 0.13;
15
27
27
27
48
144
304
use Params::Validate::Strict 0.21;
16
27
27
27
5803
68829
658
use Net::CIDR;
17
27
27
27
62
22
324
use Return::Set;
18
27
27
27
48
14
306
use Scalar::Util;
19
27
27
27
39
21
5219
use Socket;     # For AF_INET
20
27
27
214
73
use 5.008;
21# use Cwd;
22# use JSON::Parse;
23
27
27
27
81
35
213
use List::Util ();      # Can go when expect goes
24# use Sub::Private;
25
27
27
27
4789
337896
444
use Sys::Path;
26
27
27
27
27
4659
153820
75
use namespace::clean;
28
29sub _sanitise_input($);
30
31 - 39
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.11

=cut
40
41our $VERSION = '1.11';
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
2135501
        my $class = shift;
152
153        # Handle hash or hashref arguments
154
237
456
        my $params = Params::Get::get_params(undef, @_) || {};
155
156
236
2866
        if(!defined($class)) {
157
1
1
1
3
                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
8
6
26
                return bless { %{$class}, %{$params} }, ref($class);
167        }
168
169        # Load the configuration from a config file, if provided
170
231
433
        $params = Object::Configure::configure($class, $params);
171
172        # Validate logger object has required methods
173
230
873337
        if(defined $params->{'logger'}) {
174
230
1883
                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
369
        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
7
                if(my $logger = $params->{'logger'}) {
185
2
6
                        $logger->error("$class: expect has been deprecated, use allow instead");
186                }
187
2
1389
                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
194
775
                %{$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
990
        my $self = shift;
231
232
22
47
        unless($self->{script_name}) {
233
15
21
                $self->_find_paths();
234        }
235
22
65
        return $self->{script_name};
236}
237
238sub _find_paths {
239
23
16
        my $self = shift;
240
241
23
39
        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
217
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
246
247
23
499
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
248
249        # Determine script name
250
23
38
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
251
23
416
        $self->{script_name} = $self->_untaint_filename({
252                filename => File::Basename::basename($script_name)
253        });
254
255        # Determine script path
256
23
35
        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
11
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
260
6
8
                        $script_name = $self->_get_env('SCRIPT_NAME');
261
262                        # It's usually the case, e.g. /cgi-bin/foo.pl
263
6
8
                        $script_name =~ s{^/}{};
264
265
6
24
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
266                } else {
267
6
47
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
268                                # Called from a command line with a full path
269
1
1
                                $self->{script_path} = $script_name;
270                        } else {
271
5
28
                                require Cwd unless Cwd->can('abs_path');
272
273
5
20
                                if($script_name =~ /^\/(.+)/) {
274                                        # It's usually the case, e.g. /cgi-bin/foo.pl
275
2
2
                                        $script_name = $1;
276                                }
277
278
5
42
                                $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
187
                $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
49
        });
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
4352
        my $self = shift;
315
316
26
31
        unless($self->{script_path}) {
317
6
10
                $self->_find_paths();
318        }
319
26
92
        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
18
        my $self = shift;
341
342        # Ensure $self is an object
343
14
17
        $self = __PACKAGE__->new() unless ref $self;
344
345        # Set script path if it is not already defined
346
14
19
        $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
32
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
351
352
14
105
        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
796
        my $self = shift;
378
379
10
34
        unless($self->{site}) {
380
3
6
                $self->_find_site_details();
381        }
382
383
10
70
        return $self->{site};
384}
385
386sub _find_site_details
387{
388
10
8
        my $self = shift;
389
390        # Log entry to the routine
391
10
14
        $self->_trace('Entering _find_site_details');
392
393
10
135
        return if $self->{site} && $self->{cgi_site};
394
395        # Determine cgi_site using environment variables or hostname
396
8
27
        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
397                # Import necessary module
398
5
226
                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
399
400
5
929
                $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
48
                $self->{cgi_site} =~ s/(.*)\.+$/$1/;  # Trim trailing dots
404
405
5
10
                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
406
1
6
                        $self->{cgi_site} =~ s/^http/$protocol/;
407                }
408        } else {
409                # Import necessary module
410
3
20
                require Sys::Hostname unless Sys::Hostname->can('hostname');
411
412
3
8
                $self->_debug('Falling back to using hostname');
413
3
36
                $self->{cgi_site} = Sys::Hostname::hostname();
414        }
415
416        # Set site details if not already defined
417
8
32
        $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
24
                unless $self->{cgi_site} =~ /^https?:\/\//;
421
422        # Warn if site details could not be determined
423
8
19
        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
424
425        # Log exit
426
8
6
        $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
152
        my $self = shift;
440
441
7
9
        if(!ref($self)) {
442
1
2
                $self = __PACKAGE__->new();
443        }
444
7
12
        return $self->{domain} if $self->{domain};
445
446
4
6
        $self->_find_site_details();
447
448
4
23
        if(my $site = $self->{site}) {
449
4
8
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
450        }
451
452
4
13
        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
16
        my $self = shift;
463
464
7
8
        unless($self->{cgi_site}) {
465
3
3
                $self->_find_site_details();
466        }
467
468
7
75
        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 before 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
3922
        my $self = shift;
630
631
165
200
        my $params = Params::Get::get_params(undef, @_);
632
633
165
1461
        if((defined($self->{paramref})) && ((!defined($params->{'allow'})) || defined($self->{allow}) && ($params->{'allow'} eq $self->{allow}))) {
634
47
66
                return $self->{paramref};
635        }
636
637
118
133
        if(defined($params->{allow})) {
638
11
15
                $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
139
        if(defined($params->{upload_dir})) {
649
4
6
                $self->{upload_dir} = $params->{upload_dir};
650        }
651
118
114
        if(defined($params->{'logger'})) {
652
2
5
                $self->set_logger($params->{'logger'});
653        }
654
118
169
        $self->_trace('Entering params');
655
656
118
1638
        my @pairs;
657
118
106
        my $content_type = $ENV{'CONTENT_TYPE'};
658
118
89
        my %FORM;
659
660
118
384
        if((!$ENV{'GATEWAY_INTERFACE'}) || (!$ENV{'REQUEST_METHOD'})) {
661
9
636
                require IO::Interactive;
662
9
1402
                IO::Interactive->import();
663
664
9
87
                if(@ARGV) {
665
9
10
                        @pairs = @ARGV;
666
9
15
                        if(defined($pairs[0])) {
667
9
24
                                if($pairs[0] eq '--robot') {
668
1
2
                                        $self->{is_robot} = 1;
669
1
1
                                        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
2
                                        $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
98
                if(my $query = $ENV{'QUERY_STRING'}) {
704
75
94
                        if((defined($content_type)) && ($content_type =~ /multipart\/form-data/i)) {
705
1
2
                                $self->_warn('Multipart/form-data not supported for GET');
706
0
0
                                $self->{status} = 501;       # Not implemented
707
0
0
                                return;
708                        }
709
74
75
                        $query =~ s/\\u0026/\&/g;
710
74
107
                        @pairs = split(/&/, $query);
711                } else {
712
5
15
                        return;
713                }
714        } elsif($ENV{'REQUEST_METHOD'} eq 'POST') {
715
26
44
                my $content_length = $self->_get_env('CONTENT_LENGTH');
716
26
69
                if((!defined($content_length)) || ($content_length =~ /\D/)) {
717
2
2
                        $self->{status} = 411;
718
2
4
                        return;
719                }
720
24
74
                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
3
                        $self->{status} = 413;
724
2
3
                        $self->_warn('Large upload prohibited');
725
2
4
                        return;
726                }
727
728
22
99
                if((!defined($content_type)) || ($content_type =~ /application\/x-www-form-urlencoded/)) {
729
4
4
                        my $buffer;
730
4
7
                        if($stdin_data) {
731
1
1
                                $buffer = $stdin_data;
732                        } else {
733
3
13
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
734
1
2
                                        $self->_warn('POST failed: something else may have read STDIN');
735                                }
736
3
3
                                $stdin_data = $buffer;
737                        }
738
4
7
                        @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
22
                        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
68
                        if(!File::Spec->file_name_is_absolute($self->{upload_dir})) {
758
3
8
                                $self->_warn({
759                                        warning => "upload_dir $self->{upload_dir} isn't a full pathname"
760                                });
761
2
4
                                $self->status(500);
762
2
2
                                delete $self->{upload_dir};
763
2
3
                                return;
764                        }
765
11
69
                        if(!-d $self->{upload_dir}) {
766
3
18
                                $self->_warn({
767                                        warning => "upload_dir $self->{upload_dir} isn't a directory"
768                                });
769
1
3
                                $self->status(500);
770
1
1
                                delete $self->{upload_dir};
771
1
2
                                return;
772                        }
773
8
38
                        if(!-w $self->{upload_dir}) {
774
2
4
                                delete $self->{paramref};
775
2
7
                                $self->_warn({
776                                        warning => "upload_dir $self->{upload_dir} isn't writeable"
777                                });
778
1
4
                                $self->status(500);
779
1
0
                                delete $self->{upload_dir};
780
1
2
                                return;
781                        }
782
6
17
                        my $tmpdir = $self->tmpdir();
783
6
44
                        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
19
                        if($content_type =~ /boundary=(\S+)$/) {
792
6
21
                                @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
1
                        if($stdin_data) {
800
0
0
                                $buffer = $stdin_data;
801                        } else {
802
1
4
                                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
2
                        return \%FORM;
815                } elsif($content_type =~ /application\/json/i) {
816
1
30
                        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
2
                        if($stdin_data) {
823
0
0
                                $buffer = $stdin_data;
824                        } else {
825
1
3
                                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
7
                        my $paramref = decode_json($buffer);
835
1
1
1
2
                        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
3
                                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
3
                        $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
1
                $self->{status} = 405;
860
1
3
                return;
861        } else {
862                # TODO: Design a way to tell the caller to send HTTP
863                # status 501
864
2
3
                $self->{status} = 501;
865
2
6
                $self->_warn({
866                        warning => 'Use POST, GET or HEAD'
867                });
868        }
869
870
93
94
        unless(scalar @pairs) {
871
1
2
                return;
872        }
873
874
92
1989
        require String::Clean::XSS;
875
92
47554
        String::Clean::XSS->import();
876        # require String::EscapeCage;
877        # String::EscapeCage->import();
878
879
92
86
        foreach my $arg (@pairs) {
880
192
244
                my($key, $value) = split(/=/, $arg, 2);
881
882
192
168
                next unless($key);
883
884
188
137
                $key =~ s/\0//g;        # Strip encoded NUL byte poison
885
188
160
                $key =~ s/%00//g;       # Strip NUL byte poison
886
188
1
115
4
                $key =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
887
188
152
                $key =~ tr/+/ /;
888
188
668
                if(defined($value)) {
889
188
126
                        $value =~ s/\0//g;      # Strip NUL byte poison
890
188
124
                        $value =~ s/%00//g;     # Strip encoded NUL byte poison
891
188
83
127
105
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
892
188
114
                        $value =~ tr/+/ /;
893                } else {
894
0
0
                        $value = '';
895                }
896
897
188
177
                $key = _sanitise_input($key);
898
899
188
14265
                if($self->{allow}) {
900                        # Is this a permitted argument?
901
78
82
                        if(!exists($self->{allow}->{$key})) {
902
17
25
                                $self->_info("Discard unallowed argument '$key'");
903
17
238
                                $self->status(422);
904
17
15
                                next;   # Skip to the next parameter
905                        }
906
907                        # Do we allow any value, or must it be validated?
908
61
64
                        if(defined(my $schema = $self->{allow}->{$key})) {        # Get the schema for this key
909
54
76
                                if(!ref($schema)) {
910                                        # Can only contain one value
911
3
5
                                        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
31
                                        if($value !~ $schema) {
918                                                # Simple regex
919
8
15
                                                $self->_info("Block $key = $value");
920
8
103
                                                $self->status(422);
921
8
10
                                                next;   # Skip to the next parameter
922                                        }
923                                } elsif(ref($schema) eq 'CODE') {
924
9
12
                                        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
20
                                        eval {
931                                                $value = Params::Validate::Strict::validate_strict({
932                                                        schema => { $key => $schema },
933                                                        args => { $key => $value },
934                                                        unknown_parameter_handler => 'die',
935
30
66
                                                        logger => $self->{'logger'}
936                                                });
937                                        };
938
30
9816
                                        if($@) {
939
6
19
                                                $self->_info("Block $key = $value: $@");
940
6
111
                                                $self->status(422);
941
6
9
                                                next;   # Skip to the next parameter
942                                        }
943
24
24
13
24
                                        if(scalar keys %{$value}) {
944
24
16
                                                $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
220
                my $orig_value = $value;
958
152
133
                $value = _sanitise_input($value);
959
960
152
9436
                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
1251
                        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
18
                                $self->status(403);
975
11
14
                                if($ENV{'REMOTE_ADDR'}) {
976
1
3
                                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$value'");
977                                } else {
978
10
16
                                        $self->_warn("SQL injection attempt blocked for '$key=$value'");
979                                }
980
11
31
                                return;
981                        }
982
126
619
                        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
509
                        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
10
                                $self->status(403);
998
5
11
                                $self->_warn("XSS injection attempt blocked for '$value'");
999
5
13
                                return;
1000                        }
1001
121
97
                        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
127
                        if($value =~ /\.\.\//) {
1007
3
8
                                $self->status(403);
1008
3
6
                                $self->_warn("Blocked directory traversal attack for '$key'");
1009
2
3
                                return;
1010                        }
1011                }
1012
133
121
                if(length($value) > 0) {
1013                        # Don't add if it's already there
1014
128
166
                        if($FORM{$key} && ($FORM{$key} ne $value)) {
1015
3
11
                                $FORM{$key} .= ",$value";
1016                        } else {
1017
125
162
                                $FORM{$key} = $value;
1018                        }
1019                }
1020        }
1021
1022
72
96
        unless(%FORM) {
1023
11
30
                return;
1024        }
1025
1026
61
80
        if($self->{'logger'}) {
1027
61
98
                while(my ($key,$value) = each %FORM) {
1028
115
790
                        $self->_debug("$key=$value");
1029                }
1030        }
1031
1032
61
762
        $self->{paramref} = \%FORM;
1033
1034
61
133
        return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
1035}
1036
1037 - 1069
=head2 param($field)

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

=over 4

=item $field

Optional field to be retrieved.
If omitted, all the parameters are returned.

=back

=cut
1070
1071sub param {
1072
40
4407
        my ($self, $field) = @_;
1073
1074
40
41
        if(!defined($field)) {
1075
2
4
                return $self->params();
1076        }
1077        # Is this a permitted argument?
1078
38
91
        if($self->{allow} && !exists($self->{allow}->{$field})) {
1079
5
15
                $self->_warn({
1080                        warning => "param: $field isn't in the allow list"
1081                });
1082
1
2
                return;
1083        }
1084
1085        # Prevent deep recursion which can happen when a validation routine calls param()
1086
33
20
        my $allow;
1087
33
43
        if($self->{in_param} && $self->{allow}) {
1088
1
1
                $allow = delete $self->{allow};
1089        }
1090
33
34
        $self->{in_param} = 1;
1091
1092
33
46
        my $params = $self->params();
1093
1094
33
709
        $self->{in_param} = 0;
1095
33
31
        $self->{allow} = $allow if($allow);
1096
1097
33
51
        if($params) {
1098
27
43
                return Return::Set::set_return($params->{$field}, { type => 'string' });
1099        }
1100}
1101
1102sub _sanitise_input($) {
1103
340
186
        my $arg = shift;
1104
1105
340
273
        return if(!defined($arg));
1106
1107        # Remove hacking attempts and spaces
1108
340
256
        $arg =~ s/[\r\n]//g;
1109
340
288
        $arg =~ s/\s+$//;
1110
340
252
        $arg =~ s/^\s//;
1111
1112
340
164
        $arg =~ s/<!--.*-->//g;
1113        # Allow :
1114        # $arg =~ s/[;<>\*|`&\$!?#\(\)\[\]\{\}'"\\\r]//g;
1115
1116        # return $arg;
1117        # return String::EscapeCage->new(convert_XSS($arg))->escapecstring();
1118
340
340
        return convert_XSS($arg);
1119}
1120
1121sub _multipart_data {
1122
6
7
        my ($self, $args) = @_;
1123
1124
6
10
        $self->_trace('Entering _multipart_data');
1125
1126
6
76
        my $total_bytes = $$args{length};
1127
1128
6
12
        $self->_debug("_multipart_data: total_bytes = $total_bytes");
1129
1130
6
67
        if($total_bytes == 0) {
1131
0
0
                return;
1132        }
1133
1134
6
35
        unless($stdin_data) {
1135
6
19
                while(<STDIN>) {
1136
54
31
                        chop(my $line = $_);
1137
54
34
                        $line =~ s/[\r\n]//g;
1138
54
54
                        $stdin_data .= "$line\n";
1139                }
1140
6
6
                if(!$stdin_data) {
1141
0
0
                        return;
1142                }
1143        }
1144
1145
6
7
        my $boundary = $$args{boundary};
1146
1147
6
5
        my @pairs;
1148
6
5
        my $writing_file = 0;
1149
6
5
        my $key;
1150        my $value;
1151
6
3
        my $in_header = 0;
1152
6
5
        my $fout;
1153
1154
6
14
        foreach my $line(split(/\n/, $stdin_data)) {
1155
44
65
                if($line =~ /^--\Q$boundary\E--$/) {
1156
2
2
                        last;
1157                }
1158
42
56
                if($line =~ /^--\Q$boundary\E$/) {
1159
10
15
                        if($writing_file) {
1160
0
0
                                close $fout;
1161
0
0
                                $writing_file = 0;
1162                        } elsif(defined($key)) {
1163
4
4
                                push(@pairs, "$key=$value");
1164
4
2
                                $value = undef;
1165                        }
1166
10
15
                        $in_header = 1;
1167                } elsif($in_header) {
1168
20
30
                        if(length($line) == 0) {
1169
8
8
                                $in_header = 0;
1170                        } elsif($line =~ /^Content-Disposition: (.+)/i) {
1171
10
8
                                my $field = $1;
1172
10
20
                                if($field =~ /name="(.+?)"/) {
1173
10
7
                                        $key = $1;
1174                                }
1175
10
21
                                if($field =~ /filename="(.+)?"/) {
1176
6
5
                                        my $filename = $1;
1177
6
13
                                        unless(defined($filename)) {
1178
0
0
                                                $self->_warn('No upload filename given');
1179
0
0
                                        } elsif($filename =~ /[\\\/\|]/) {
1180
2
5
                                                $self->_warn("Disallowing invalid filename: $filename");
1181                                        } else {
1182
4
11
                                                $filename = $self->_create_file_name({
1183                                                        filename => $filename
1184                                                });
1185
1186                                                # Don't do this since it taints the string and I can't work out how to untaint it
1187                                                # my $full_path = Cwd::realpath(File::Spec->catfile($self->{upload_dir}, $filename));
1188                                                # $full_path =~ m/^(\/[\w\.]+)$/;
1189
4
22
                                                my $full_path = File::Spec->catfile($self->{upload_dir}, $filename);
1190
4
223
                                                unless(open($fout, '>', $full_path)) {
1191
0
0
                                                        $self->_warn("Can't open $full_path");
1192                                                }
1193
4
19
                                                $writing_file = 1;
1194
4
10
                                                push(@pairs, "$key=$filename");
1195                                        }
1196                                }
1197                        }
1198                        # TODO: handle Content-Type: text/plain, etc.
1199                } else {
1200
12
9
                        if($writing_file) {
1201
8
28
                                print $fout "$line\n";
1202                        } else {
1203
4
4
                                $value .= $line;
1204                        }
1205                }
1206        }
1207
1208
4
8
        if($writing_file) {
1209
4
102
                close $fout;
1210        }
1211
1212
4
8
        $self->_trace('Leaving _multipart_data');
1213
1214
4
68
        return @pairs;
1215}
1216
1217# Robust filename generation (preventing overwriting)
1218sub _create_file_name {
1219
4
4
        my ($self, $args) = @_;
1220
4
6
        my $filename = $$args{filename} . '_' . time;
1221
1222
4
3
        my $counter = 0;
1223
4
2
        my $rc;
1224
1225
4
7
        do {
1226
4
6
                $rc = $filename . ($counter ? "_$counter" : '');
1227
4
43
                $counter++;
1228        } until(! -e $rc);      # Check if file exists
1229
1230
4
6
        return $rc;
1231}
1232
1233# Untaint a filename. Regex from CGI::Untaint::Filenames
1234sub _untaint_filename {
1235
51
50
        my ($self, $args) = @_;
1236
1237
51
116
        if($$args{filename} =~ /(^[\w\+_\040\#\(\)\{\}\[\]\/\-\^,\.:;&%@\\~]+\$?$)/) {
1238
51
84
                return $1;
1239        }
1240        # return undef;
1241}
1242
1243 - 1251
=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
1252
1253sub is_mobile {
1254
43
1263
        my $self = shift;
1255
1256
43
71
        if(defined($self->{is_mobile})) {
1257
12
18
                return $self->{is_mobile};
1258        }
1259
1260
31
47
        if($ENV{'IS_MOBILE'}) {
1261
1
3
                return $ENV{'IS_MOBILE'}
1262        }
1263
1264        # Support Sec-CH-UA-Mobile
1265
30
48
        if(my $ch_ua_mobile = $ENV{'HTTP_SEC_CH_UA_MOBILE'}) {
1266
3
4
                if($ch_ua_mobile eq '?1') {
1267
1
1
                        $self->{is_mobile} = 1;
1268
1
2
                        return 1;
1269                }
1270        }
1271
1272
29
41
        if($ENV{'HTTP_X_WAP_PROFILE'}) {
1273                # E.g. Blackberry
1274                # TODO: Check the sanity of this variable
1275
1
1
                $self->{is_mobile} = 1;
1276
1
4
                return 1;
1277        }
1278
1279
28
42
        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1280
18
950
                if($agent =~ /.+(Android|iPhone).+/) {
1281
3
3
                        $self->{is_mobile} = 1;
1282
3
6
                        return 1;
1283                }
1284
1285                # From http://detectmobilebrowsers.com/
1286
15
502
                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) {
1287
1
2
                        $self->{is_mobile} = 1;
1288
1
2
                        return 1;
1289                }
1290
1291                # Save loading and calling HTTP::BrowserDetect
1292
14
22
                my $remote = $ENV{'REMOTE_ADDR'};
1293
14
27
                if(defined($remote) && $self->{cache}) {
1294
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1295
0
0
                                return $self->{is_mobile} = ($type eq 'mobile');
1296                        }
1297                }
1298
1299
14
43
                unless($self->{browser_detect}) {
1300
8
8
9
1738
                        if(eval { require HTTP::BrowserDetect; }) {
1301
8
32525
                                HTTP::BrowserDetect->import();
1302
8
15
                                $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1303                        }
1304                }
1305
1306
14
708
                if($self->{browser_detect}) {
1307
14
27
                        my $device = $self->{browser_detect}->device();
1308                        # Without the ?1:0 it will set to the empty string not 0
1309
14
76
                        my $is_mobile = (defined($device) && ($device =~ /blackberry|webos|iphone|ipod|ipad|android/i)) ? 1 : 0;
1310
14
22
                        if($is_mobile && $self->{cache} && defined($remote)) {
1311
0
0
                                $self->{cache}->set("$remote/$agent", 'mobile', '1 day');
1312                        }
1313
14
40
                        return $self->{is_mobile} = $is_mobile;
1314                }
1315        }
1316
1317
10
18
        return 0;
1318}
1319
1320 - 1324
=head2 is_tablet

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

=cut
1325
1326sub is_tablet {
1327
6
28
        my $self = shift;
1328
1329
6
11
        if(defined($self->{is_tablet})) {
1330
1
2
                return $self->{is_tablet};
1331        }
1332
1333
5
152
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1334                # TODO: add others when I see some nice user_agents
1335
1
1
                $self->{is_tablet} = 1;
1336        } else {
1337
4
5
                $self->{is_tablet} = 0;
1338        }
1339
1340
5
11
        return $self->{is_tablet};
1341}
1342
1343 - 1369
=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
1370
1371sub as_string
1372{
1373
40
8276
        my $self = shift;
1374
1375
40
67
        my $args = Params::Validate::Strict::validate_strict({
1376                args => Params::Get::get_params(undef, @_) || {},
1377                schema => {
1378                        raw => {
1379                                'type' => 'boolean',
1380                                'optional' => 1
1381                        }
1382                }
1383        });
1384
1385        # Retrieve object parameters
1386
40
2177
        my $params = $self->params() || return '';
1387
1388
30
246
        my $rc;
1389
1390
30
33
        if($args->{'raw'}) {
1391                # Raw mode: return key=value pairs without escaping
1392                $rc = join '; ', map {
1393
4
6
                        "$_=" . $params->{$_}
1394
2
2
2
4
                } sort keys %{$params};
1395        } else {
1396                # Escaped mode: escape special characters
1397                $rc = join '; ', map {
1398
42
36
                        my $value = $params->{$_};
1399
1400
42
39
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1401
42
62
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1402
42
60
                        "$_=$value"
1403
28
28
20
44
                } sort keys %{$params};
1404        }
1405
1406
30
60
        $self->_trace("as_string: returning '$rc'") if($rc);
1407
1408
30
417
        return $rc;
1409}
1410
1411 - 1416
=head2 protocol

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

=cut
1417
1418sub protocol {
1419
22
702
        my $self = shift;
1420
1421
22
53
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1422
2
7
                return $1;
1423        }
1424
20
49
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1425
2
7
                return 'http';
1426        }
1427
1428
18
28
        if(my $port = $ENV{'SERVER_PORT'}) {
1429
13
713
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1430
13
32
                        if($name =~ /https?/) {
1431
11
34
                                return $name;
1432                        } elsif($name eq 'www') {
1433                                # e.g. NetBSD and OpenBSD
1434
0
0
                                return 'http';
1435                        }
1436                        # Return an error, maybe missing something
1437                } elsif($port == 80) {
1438                        # e.g. Solaris
1439
0
0
                        return 'http';
1440                } elsif($port == 443) {
1441
0
0
                        return 'https';
1442                }
1443        }
1444
1445
7
13
        if($ENV{'REMOTE_ADDR'}) {
1446
0
0
                $self->_warn("Can't determine the calling protocol");
1447        }
1448
7
23
        return;
1449}
1450
1451 - 1476
=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
1477
1478sub tmpdir {
1479
23
1815
        my $self = shift;
1480
1481
23
24
        my $name = 'tmp';
1482
23
41
        if($^O eq 'MSWin32') {
1483
0
0
                $name = 'temp';
1484        }
1485
1486
23
17
        my $dir;
1487
1488
23
24
        if(!ref($self)) {
1489
3
5
                $self = __PACKAGE__->new();
1490        }
1491
23
40
        my $params = Params::Get::get_params(undef, @_);
1492
1493
23
331
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1494
5
18
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1495
5
38
                if((-d $dir) && (-w $dir)) {
1496
2
4
                        return $self->_untaint_filename({ filename => $dir });
1497                }
1498
3
4
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1499
3
19
                if((-d $dir) && (-w $dir)) {
1500
3
11
                        return $self->_untaint_filename({ filename => $dir });
1501                }
1502        }
1503
18
52
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1504
1
9
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1505
1
5
                if((-d $dir) && (-w $dir)) {
1506
0
0
                        return $self->_untaint_filename({ filename => $dir });
1507                }
1508        }
1509
18
30
        if($params->{'default'} && ref($params->{'default'})) {
1510
0
0
                croak(ref($self), ': tmpdir must be given a scalar');
1511        }
1512
18
264
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1513}
1514
1515 - 1527
=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
1528
1529sub rootdir {
1530
14
982
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1531
1
2
                return $ENV{'C_DOCUMENT_ROOT'};
1532        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1533
2
4
                return $ENV{'DOCUMENT_ROOT'};
1534        }
1535
11
12
        my $script_name = $0;
1536
1537
11
33
        unless(File::Spec->file_name_is_absolute($script_name)) {
1538
11
76
                $script_name = File::Spec->rel2abs($script_name);
1539        }
1540
11
13
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1541
0
0
                $script_name =~ s/.cgi\-bin.*//;
1542        }
1543
11
47
        if(-f $script_name) {   # More kludge
1544
11
13
                if($^O eq 'MSWin32') {
1545
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1546
0
0
                                return $1;
1547                        }
1548                } else {
1549
11
47
                        if($script_name =~ /(.+)\/.+?$/) {
1550
11
22
                                return $1;
1551                        }
1552                }
1553        }
1554
0
0
        return $script_name;
1555}
1556
1557 - 1561
=head2 root_dir

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

=cut
1562
1563sub root_dir
1564{
1565
4
428
        if($_[0] && ref($_[0])) {
1566
2
3
                my $self = shift;
1567
1568
2
3
                return $self->rootdir(@_);
1569        }
1570
2
3
        return __PACKAGE__->rootdir(@_);
1571}
1572
1573 - 1577
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1578
1579sub documentroot
1580{
1581
3
12
        if($_[0] && ref($_[0])) {
1582
1
1
                my $self = shift;
1583
1584
1
2
                return $self->rootdir(@_);
1585        }
1586
2
2
        return __PACKAGE__->rootdir(@_);
1587}
1588
1589 - 1601
=head2 logdir($dir)

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

=over 4

=item $dir

Path to the directory where logs will be stored

=back

=cut
1602
1603sub logdir {
1604
5
1414
        my $self = shift;
1605
5
6
        my $dir = shift;
1606
1607
5
7
        if(!ref($self)) {
1608
1
4
                $self = __PACKAGE__->new();
1609        }
1610
1611
5
7
        if($dir) {
1612
2
27
                if(length($dir) && (-d $dir) && (-w $dir)) {
1613
1
5
                        return $self->{'logdir'} = $dir;
1614                }
1615
1
3
                $self->_warn("Invalid logdir: $dir");
1616
1
19
                Carp::croak("Invalid logdir: $dir");
1617        }
1618
1619
3
15
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1620
9
44
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1621
3
3
                        $dir = $rc;
1622
3
4
                        last;
1623                }
1624        }
1625
3
9
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1626
3
7
        $self->{logdir} ||= $dir;
1627
1628
3
8
        return $dir;
1629}
1630
1631 - 1646
=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
1647
1648sub is_robot {
1649
21
374
        my $self = shift;
1650
1651
21
27
        if(defined($self->{is_robot})) {
1652
3
4
                return $self->{is_robot};
1653        }
1654
1655
18
18
        my $agent = $ENV{'HTTP_USER_AGENT'};
1656
18
19
        my $remote = $ENV{'REMOTE_ADDR'};
1657
1658
18
35
        unless($remote && $agent) {
1659                # Probably not running in CGI - assume real person
1660
8
18
                return 0;
1661        }
1662
1663        # See also params()
1664
10
70
        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/)) {
1665
1
3
                $self->status(403);
1666
1
1
                $self->{is_robot} = 1;
1667
1
2
                if($ENV{'REMOTE_ADDR'}) {
1668
1
5
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1669                } else {
1670
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1671                }
1672
1
2
                return 1;
1673        }
1674
9
263
        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) {
1675
3
3
                $self->{is_robot} = 1;
1676
3
7
                return 1;
1677        }
1678
1679        # TODO:
1680        # Download and use list from
1681        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1682
1683
6
9
        my $key = "$remote/$agent";
1684
1685
6
11
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1686                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1687
2
6
                my @crawler_lists = (
1688                        'http://fix-website-errors.com',
1689                        'http://keywords-monitoring-your-success.com',
1690                        'http://free-video-tool.com',
1691                        'http://magnet-to-torrent.com',
1692                        'http://torrent-to-magnet.com',
1693                        'http://dogsrun.net',
1694                        'http://###.responsive-test.net',
1695                        'http://uptime.com',
1696                        'http://uptimechecker.com',
1697                        'http://top1-seo-service.com',
1698                        'http://fast-wordpress-start.com',
1699                        'http://wordpress-crew.net',
1700                        'http://dbutton.net',
1701                        'http://justprofit.xyz',
1702                        'http://video--production.com',
1703                        'http://buttons-for-website.com',
1704                        'http://buttons-for-your-website.com',
1705                        'http://success-seo.com',
1706                        'http://videos-for-your-business.com',
1707                        'http://semaltmedia.com',
1708                        'http://dailyrank.net',
1709                        'http://uptimebot.net',
1710                        'http://sitevaluation.org',
1711                        'http://100dollars-seo.com',
1712                        'http://forum69.info',
1713                        'http://partner.semalt.com',
1714                        'http://best-seo-offer.com',
1715                        'http://best-seo-solution.com',
1716                        'http://semalt.semalt.com',
1717                        'http://semalt.com',
1718                        'http://7makemoneyonline.com',
1719                        'http://anticrawler.org',
1720                        'http://baixar-musicas-gratis.com',
1721                        'http://descargar-musica-gratis.net',
1722
1723                        # Mine
1724                        'http://www.seokicks.de/robot.html',
1725                );
1726
2
2
                $referrer =~ s/\\/_/g;
1727
2
3
7
12
                if(($referrer =~ /\)/) || (List::Util::any { $_ =~ /^$referrer/ } @crawler_lists)) {
1728
2
4
                        $self->_debug("is_robot: blocked trawler $referrer");
1729
1730
2
6
                        if($self->{cache}) {
1731
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1732                        }
1733
2
3
                        $self->{is_robot} = 1;
1734
2
5
                        return 1;
1735                }
1736        }
1737
1738
4
10
        if(defined($remote) && $self->{cache}) {
1739
0
0
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1740
0
0
                        return $self->{is_robot} = ($type eq 'robot');
1741                }
1742        }
1743
1744        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1745        # that is easily spoofed
1746
4
13
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1747                # Mark Facebook as a search engine, not a robot
1748
0
0
                if($self->{cache}) {
1749
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1750                }
1751
0
0
                return 0;
1752        }
1753
1754
4
5
        unless($self->{browser_detect}) {
1755
3
3
2
8
                if(eval { require HTTP::BrowserDetect; }) {
1756
3
5
                        HTTP::BrowserDetect->import();
1757
3
4
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1758                }
1759        }
1760
4
237
        if($self->{browser_detect}) {
1761
4
9
                my $is_robot = $self->{browser_detect}->robot();
1762
4
382
                if(defined($is_robot)) {
1763
2
8
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1764                }
1765
4
44
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1766
4
15
                $self->_debug("is_robot: $is_robot");
1767
1768
4
53
                if($is_robot) {
1769
2
6
                        if($self->{cache}) {
1770
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1771                        }
1772
2
2
                        $self->{is_robot} = $is_robot;
1773
2
5
                        return $is_robot;
1774                }
1775        }
1776
1777
2
3
        if($self->{cache}) {
1778
0
0
                $self->{cache}->set($key, 'unknown', '1 day');
1779        }
1780
2
3
        $self->{is_robot} = 0;
1781
2
4
        return 0;
1782}
1783
1784 - 1796
=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
1797
1798sub is_search_engine
1799{
1800
28
596
        my $self = shift;
1801
1802
28
40
        if(defined($self->{is_search_engine})) {
1803
6
9
                return $self->{is_search_engine};
1804        }
1805
1806
22
36
        if($ENV{'IS_SEARCH_ENGINE'}) {
1807
1
4
                return $ENV{'IS_SEARCH_ENGINE'}
1808        }
1809
1810
21
26
        my $remote = $ENV{'REMOTE_ADDR'};
1811
21
24
        my $agent = $ENV{'HTTP_USER_AGENT'};
1812
1813
21
43
        unless($remote && $agent) {
1814                # Probably not running in CGI - assume not a search engine
1815
9
15
                return 0;
1816        }
1817
1818
12
12
        my $key;
1819
1820
12
15
        if($self->{cache}) {
1821
0
0
                $key = "$remote/$agent";
1822
0
0
                if(defined($remote) && $self->{cache}) {
1823
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1824
0
0
                                return $self->{is_search} = ($type eq 'search');
1825                        }
1826                }
1827        }
1828
1829        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1830        # that is easily spoofed
1831
12
37
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1832                # Mark Facebook as a search engine, not a robot
1833
0
0
                if($self->{cache}) {
1834
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1835                }
1836
0
0
                return 1;
1837        }
1838
1839
12
28
        unless($self->{browser_detect}) {
1840
8
8
8
430
                if(eval { require HTTP::BrowserDetect; }) {
1841
8
8033
                        HTTP::BrowserDetect->import();
1842
8
15
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1843                }
1844        }
1845
12
899
        if(my $browser = $self->{browser_detect}) {
1846
12
20
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1847
12
1933
                if(!$is_search) {
1848
6
27
                        if(($agent =~ /SeznamBot\//) ||
1849                           ($agent =~ /Google-InspectionTool\//) ||
1850                           ($agent =~ /Googlebot\//)) {
1851
1
0
                                $is_search = 1;
1852                        }
1853                }
1854
12
22
                if($is_search && $self->{cache}) {
1855
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1856                }
1857
12
36
                return $self->{is_search_engine} = $is_search;
1858        }
1859
1860        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1861
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1862
1863
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1864
1865
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1866           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1867
0
0
                if($self->{cache}) {
1868
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1869                }
1870
0
0
                $self->{is_search_engine} = 1;
1871
0
0
                return 1;
1872        }
1873
1874
0
0
        $self->{is_search_engine} = 0;
1875
0
0
        return 0;
1876}
1877
1878 - 1900
=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
1901
1902sub browser_type {
1903
21
23
        my $self = shift;
1904
1905
21
36
        if($self->is_mobile()) {
1906
8
23
                return 'mobile';
1907        }
1908
13
24
        if($self->is_search_engine()) {
1909
6
16
                return 'search';
1910        }
1911
7
14
        if($self->is_robot()) {
1912
3
9
                return 'robot';
1913        }
1914
4
14
        return 'web';
1915}
1916
1917 - 1932
=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
1933
1934sub get_cookie {
1935
13
409
        my $self = shift;
1936
1937
13
15
        return $self->cookie(\@_);
1938}
1939
1940 - 1984
=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
1985
1986sub cookie
1987{
1988
23
2369
        my $self = shift;
1989
23
29
        my $params = Params::Validate::Strict::validate_strict({
1990                args => Params::Get::get_params('cookie_name', @_),
1991                schema => {
1992                        cookie_name => {
1993                                'type' => 'string',
1994                                'min' => 1,
1995                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
1996                        }
1997                }
1998        });
1999
2000
21
1557
        my $field = $params->{'cookie_name'};
2001
2002        # Validate field argument
2003
21
19
        if(!defined($field)) {
2004
2
6
                $self->_error('what cookie do you want?');
2005
2
18
                Carp::croak('what cookie do you want?');
2006        }
2007
19
16
        if(ref($field)) {
2008
0
0
                $self->_error('Cookie name should be a string');
2009
0
0
                Carp::croak('Cookie name should be a string');
2010        }
2011
2012        # Load cookies if not already loaded
2013
19
20
        unless($self->{jar}) {
2014
9
14
                if(defined $ENV{'HTTP_COOKIE'}) {
2015
8
19
16
26
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
2016                }
2017        }
2018
2019        # Return the cookie value if it exists, otherwise return undef
2020
19
46
        return $self->{jar}{$field};
2021}
2022
2023 - 2038
=head2 status($status)

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

=over 4

=item $status

Optional integer value to be set or retrieved.
If omitted, the value is retrieved.

=back

=cut
2039
2040sub status
2041{
2042
91
4024
        my $self = shift;
2043
91
74
        my $status = shift;
2044
2045        # Set status if provided
2046
91
121
        return $self->{status} = $status if(defined($status));
2047
2048        # Determine status based on request method if status is not set
2049
32
44
        unless (defined $self->{status}) {
2050
13
11
                my $method = $ENV{'REQUEST_METHOD'};
2051
2052
13
30
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
2053
9
29
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
2054
2055
7
22
                return 200;
2056        }
2057
2058        # Return current status or 200 by default
2059
19
57
        return $self->{status} || 200;
2060}
2061
2062 - 2074
=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
2075
2076sub messages
2077{
2078
7
3110
        my $self = shift;
2079
2080
7
21
        return $self->{'messages'};
2081}
2082
2083 - 2087
=head2  messages_as_string

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

=cut
2088
2089sub messages_as_string
2090{
2091
2
2
        my $self = shift;
2092
2093
2
4
        if(scalar($self->{'messages'})) {
2094
1
2
1
1
3
2
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2095
1
4
                return join('; ', @messages);
2096        }
2097
1
2
        return '';
2098}
2099
2100 - 2119
=head2 cache($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.

=over 4

=item $cache

Optional cache object.
When not given,
returns the current cache object.

=back

=cut
2120
2121sub cache
2122{
2123
4
32
        my $self = shift;
2124
4
4
        my $cache = shift;
2125
2126
4
8
        if($cache) {
2127
0
0
                croak(ref($self), ':cache($cache) is not an object') if(!Scalar::Util::blessed($cache));
2128
0
0
                $self->{'cache'} = $cache;
2129        }
2130
4
5
        return $self->{'cache'};
2131}
2132
2133 - 2140
=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
2141
2142sub set_logger
2143{
2144
6
39
        my $self = shift;
2145
6
10
        my $params = Params::Get::get_params('logger', @_);
2146
2147
6
87
        if(my $logger = $params->{'logger'}) {
2148
6
14
                if(Scalar::Util::blessed($logger)) {
2149
4
5
                        $self->{'logger'} = $logger;
2150                } else {
2151
2
4
                        $self->{'logger'} = Log::Abstraction->new($logger);
2152                }
2153        } else {
2154
0
0
                $self->{'logger'} = Log::Abstraction->new();
2155        }
2156
6
46
        return $self;
2157}
2158
2159# Log and remember a message
2160sub _log
2161{
2162
412
395
        my ($self, $level, @messages) = @_;
2163
2164
412
373
        if(scalar(@messages)) {
2165                # FIXME: add caller's function
2166                # if(($level eq 'warn') || ($level eq 'info')) {
2167
412
412
208
928
                        push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2168                # }
2169
2170
412
666
                if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2171
412
786
                        $self->{'logger'}->$level(join('', grep defined, @messages));
2172                }
2173        }
2174}
2175
2176sub _debug {
2177
132
96
        my $self = shift;
2178
132
132
        $self->_log('debug', @_);
2179}
2180
2181sub _info {
2182
35
22
        my $self = shift;
2183
35
31
        $self->_log('info', @_);
2184}
2185
2186sub _notice {
2187
0
0
        my $self = shift;
2188
0
0
        $self->_log('notice', @_);
2189}
2190
2191sub _trace {
2192
199
133
        my $self = shift;
2193
199
261
        $self->_log('trace', @_);
2194}
2195
2196# Emit a warning message somewhere
2197sub _warn {
2198
44
35
        my $self = shift;
2199
44
74
        my $params = Params::Get::get_params('warning', @_);
2200
2201
44
521
        $self->_log('warn', $params->{'warning'});
2202
29
2844
        if(!defined($self->{'logger'})) {
2203
0
0
                Carp::carp($params->{'warning'});
2204        }
2205}
2206
2207# Emit an error message somewhere
2208sub _error {
2209
2
1
        my $self = shift;
2210
2
8
        my $params = Params::Get::get_params('warning', @_);
2211
2212
2
25
        $self->_log('error', $params->{'warning'});
2213
2
556
        if(!defined($self->{'logger'})) {
2214
0
0
                Carp::croak($params->{'warning'});
2215        }
2216}
2217
2218# Ensure all environment variables are sanitized and validated before use.
2219# Use regular expressions to enforce strict input formats.
2220sub _get_env
2221{
2222
111
95
        my ($self, $var) = @_;
2223
2224
111
217
        return unless defined $ENV{$var};
2225
2226        # Strict sanitization: allow alphanumeric and limited special characters
2227
63
121
        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2228
63
79
                return $ENV{$var};
2229        }
2230
0
0
        $self->_warn("Invalid value in environment variable: $var");
2231
2232
0
0
        return undef;
2233}
2234
2235 - 2241
=head2 reset

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

=cut
2242
2243sub reset {
2244
13
8761
        my $class = shift;
2245
2246
13
25
        unless($class eq __PACKAGE__) {
2247
1
13
                carp('Reset is a class method');
2248
0
0
                return;
2249        }
2250
2251
12
12
        $stdin_data = undef;
2252}
2253
2254sub AUTOLOAD
2255{
2256
241
53553
        our $AUTOLOAD;
2257
2258
241
322
        my $self = shift or return;
2259
2260
241
305
        return if(!defined($AUTOLOAD));
2261
2262        # Extract the method name from the AUTOLOAD variable
2263
241
813
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2264
2265        # Skip if called on destruction
2266
241
834
        return if($method eq 'DESTROY');
2267
2268
8
11
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2269
2270        # Allow the AUTOLOAD feature to be disabled
2271
8
19
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2272
2273        # Ensure the method is called on the correct package object or a subclass
2274
7
31
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2275
2276        # Validate method name - only allow safe parameter names
2277
7
16
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2278
2279        # Delegate to the param method
2280
7
13
        return $self->param($method);
2281}
2282
2283 - 2369
=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 * L<Test Dashboard|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-2026 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
2370
23711;