File Coverage

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

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
1555027
15
472
use warnings;
7
27
27
27
32
18
173
use strict;
8
9
27
27
27
2483
9592
36
use boolean;
10
27
27
27
651
20
484
use Carp;
11
27
27
27
3656
1035988
362
use Object::Configure 0.12;
12
27
27
27
77
13
302
use File::Spec;
13
27
27
27
37
133
189
use Log::Abstraction 0.10;
14
27
27
27
41
99
335
use Params::Get 0.13;
15
27
27
27
36
110
219
use Params::Validate::Strict 0.21;
16
27
27
27
4516
60214
609
use Net::CIDR;
17
27
27
27
67
13
234
use Return::Set;
18
27
27
27
33
15
228
use Scalar::Util;
19
27
27
27
34
19
4296
use Socket;     # For AF_INET
20
27
27
167
68
use 5.008;
21
27
27
27
4006
76214
59
use Log::Any qw($log);
22# use Cwd;
23# use JSON::Parse;
24
27
27
27
18641
27
162
use List::Util ();      # Can go when expect goes
25# use Sub::Private;
26
27
27
27
3287
279441
313
use Sys::Path;
27
28
27
27
27
3437
123445
58
use namespace::clean;
29
30sub _sanitise_input($);
31
32 - 40
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.07

=cut
41
42our $VERSION = '1.07';
43
44 - 145
=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
146
147our $stdin_data;        # Class variable storing STDIN in case the class
148                        # is instantiated more than once
149
150sub new
151{
152
237
1785128
        my $class = shift;
153
154        # Handle hash or hashref arguments
155
237
366
        my $params = Params::Get::get_params(undef, @_) || {};
156
157
236
2845
        if(!defined($class)) {
158
1
1
1
2
                if((scalar keys %{$params}) > 0) {
159                        # Using CGI::Info:new(), not CGI::Info->new()
160
0
0
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
161                }
162
163                # FIXME: this only works when no arguments are given
164
1
1
                $class = __PACKAGE__;
165        } elsif(Scalar::Util::blessed($class)) {
166                # If $class is an object, clone it with new arguments
167
5
5
5
4
7
17
                return bless { %{$class}, %{$params} }, ref($class);
168        }
169
170        # Load the configuration from a config file, if provided
171
231
366
        $params = Object::Configure::configure($class, $params);
172
173        # Validate logger object has required methods
174
230
692544
        if(defined $params->{'logger'}) {
175
230
1417
                unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
176
0
0
                        Carp::croak("Logger must be an object with info() and error() methods");
177                }
178        }
179
180
230
284
        if(defined($params->{'expect'})) {
181                # if(ref($params->{expect}) ne 'ARRAY') {
182                        # Carp::croak(__PACKAGE__, ': expect must be a reference to an array');
183                # }
184                # # warn __PACKAGE__, ': expect is deprecated, use allow instead';
185
2
3
                if(my $logger = $params->{'logger'}) {
186
2
4
                        $logger->error("$class: expect has been deprecated, use allow instead");
187                }
188
2
3455
                Carp::croak("$class: expect has been deprecated, use allow instead");
189        }
190
191        # Return the blessed object
192        return bless {
193                max_upload_size => 512 * 1024,
194                allow => undef,
195                upload_dir => undef,
196
228
228
181
612
                %{$params}      # Overwrite defaults with given arguments
197        }, $class;
198}
199
200 - 227
=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
228
229sub script_name
230{
231
22
765
        my $self = shift;
232
233
22
24
        unless($self->{script_name}) {
234
15
19
                $self->_find_paths();
235        }
236
22
45
        return $self->{script_name};
237}
238
239sub _find_paths {
240
23
15
        my $self = shift;
241
242
23
28
        if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
243
0
0
                Carp::croak('Illegal Operation: This method can only be called by a subclass or ourself');
244        }
245
246
23
185
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
247
248
23
393
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
249
250        # Determine script name
251
23
26
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
252
23
344
        $self->{script_name} = $self->_untaint_filename({
253                filename => File::Basename::basename($script_name)
254        });
255
256        # Determine script path
257
23
26
        if(my $script_path = $self->_get_env('SCRIPT_FILENAME')) {
258
2
2
                $self->{script_path} = $script_path;
259        } elsif($script_name = $self->_get_env('SCRIPT_NAME')) {
260
12
9
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
261
6
3
                        $script_name = $self->_get_env('SCRIPT_NAME');
262
263                        # It's usually the case, e.g. /cgi-bin/foo.pl
264
6
6
                        $script_name =~ s{^/}{};
265
266
6
24
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
267                } else {
268
6
42
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
269                                # Called from a command line with a full path
270
1
1
                                $self->{script_path} = $script_name;
271                        } else {
272
5
15
                                require Cwd unless Cwd->can('abs_path');
273
274
5
7
                                if($script_name =~ /^\/(.+)/) {
275                                        # It's usually the case, e.g. /cgi-bin/foo.pl
276
2
1
                                        $script_name = $1;
277                                }
278
279
5
34
                                $self->{script_path} = File::Spec->catfile(Cwd::abs_path(), $script_name);
280                        }
281                }
282        } elsif(File::Spec->file_name_is_absolute($0)) {
283                # Called from a command line with a full path
284
0
0
                $self->{script_path} = $0;
285        } else {
286
9
106
                $self->{script_path} = File::Spec->rel2abs($0);
287        }
288
289        # Untaint and finalize script path
290        $self->{script_path} = $self->_untaint_filename({
291                filename => $self->{script_path}
292
23
37
        });
293}
294
295 - 312
=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
313
314sub script_path {
315
26
3808
        my $self = shift;
316
317
26
28
        unless($self->{script_path}) {
318
6
9
                $self->_find_paths();
319        }
320
26
66
        return $self->{script_path};
321}
322
323 - 337
=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
338
339sub script_dir
340{
341
14
14
        my $self = shift;
342
343        # Ensure $self is an object
344
14
15
        $self = __PACKAGE__->new() unless ref $self;
345
346        # Set script path if it is not already defined
347
14
13
        $self->_find_paths() unless $self->{script_path};
348
349        # Extract directory from script path based on OS
350        # Don't use File::Spec->splitpath() since that can leave the trailing slash
351
14
24
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
352
353
14
81
        return $self->{script_path} =~ $dir_regex ? $1 : $self->{script_path};
354}
355
356 - 375
=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
376
377sub host_name {
378
10
659
        my $self = shift;
379
380
10
12
        unless($self->{site}) {
381
3
7
                $self->_find_site_details();
382        }
383
384
10
71
        return $self->{site};
385}
386
387sub _find_site_details
388{
389
10
7
        my $self = shift;
390
391        # Log entry to the routine
392
10
13
        $self->_trace('Entering _find_site_details');
393
394
10
143
        return if $self->{site} && $self->{cgi_site};
395
396        # Determine cgi_site using environment variables or hostname
397
8
24
        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
398                # Import necessary module
399
5
175
                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
400
401
5
781
                $self->{cgi_site} = URI::Heuristic::uf_uristr($host);
402                # Remove trailing dots from the name.  They are legal in URLs
403                # and some sites link using them to avoid spoofing (nice)
404
5
47
                $self->{cgi_site} =~ s/(.*)\.+$/$1/;  # Trim trailing dots
405
406
5
11
                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
407
1
2
                        $self->{cgi_site} =~ s/^http/$protocol/;
408                }
409        } else {
410                # Import necessary module
411
3
14
                require Sys::Hostname unless Sys::Hostname->can('hostname');
412
413
3
7
                $self->_debug('Falling back to using hostname');
414
3
28
                $self->{cgi_site} = Sys::Hostname::hostname();
415        }
416
417        # Set site details if not already defined
418
8
28
        $self->{site} ||= $self->{cgi_site};
419
8
13
        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
420        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
421
8
30
                unless $self->{cgi_site} =~ /^https?:\/\//;
422
423        # Warn if site details could not be determined
424
8
13
        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
425
426        # Log exit
427
8
9
        $self->_trace('Leaving _find_site_details');
428}
429
430 - 437
=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
438
439sub domain_name {
440
7
146
        my $self = shift;
441
442
7
7
        if(!ref($self)) {
443
1
2
                $self = __PACKAGE__->new();
444        }
445
7
14
        return $self->{domain} if $self->{domain};
446
447
4
4
        $self->_find_site_details();
448
449
4
19
        if(my $site = $self->{site}) {
450
4
6
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
451        }
452
453
4
9
        return $self->{domain};
454}
455
456 - 460
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
461
462sub cgi_host_url {
463
7
18
        my $self = shift;
464
465
7
8
        unless($self->{cgi_site}) {
466
3
11
                $self->_find_site_details();
467        }
468
469
7
37
        return $self->{cgi_site};
470}
471
472 - 627
=head2 params

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

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

        ./script.cgi --mobile name=Nigel

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

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

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

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

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


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

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

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

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

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

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

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

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

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

=head3 Validation Subroutine Support

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

=over 4

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

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

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

=back

Basic usage:

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

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

Advanced features:

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

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

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

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

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

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

Returns undef if the requested parameter was not given

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

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

=cut
1314
1315sub is_tablet {
1316
6
22
        my $self = shift;
1317
1318
6
8
        if(defined($self->{is_tablet})) {
1319
1
2
                return $self->{is_tablet};
1320        }
1321
1322
5
140
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1323                # TODO: add others when I see some nice user_agents
1324
1
1
                $self->{is_tablet} = 1;
1325        } else {
1326
4
4
                $self->{is_tablet} = 0;
1327        }
1328
1329
5
8
        return $self->{is_tablet};
1330}
1331
1332 - 1340
=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 });

=cut
1341
1342sub as_string
1343{
1344
40
7671
        my $self = shift;
1345
1346        # Retrieve object parameters
1347
40
46
        my $params = $self->params() || return '';
1348
30
213
        my $args = Params::Get::get_params(undef, @_);
1349
30
270
        my $rc;
1350
1351
30
37
        if($args->{'raw'}) {
1352                # Raw mode: return key=value pairs without escaping
1353                $rc = join '; ', map {
1354
4
10
                        "$_=" . $params->{$_}
1355
2
2
3
4
                } sort keys %{$params};
1356        } else {
1357                # Escaped mode: escape special characters
1358                $rc = join '; ', map {
1359
42
36
                        my $value = $params->{$_};
1360
1361
42
43
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1362
42
55
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1363
42
74
                        "$_=$value"
1364
28
28
21
45
                } sort keys %{$params};
1365        }
1366
1367
30
69
        $self->_trace("as_string: returning '$rc'") if($rc);
1368
1369
30
438
        return $rc;
1370}
1371
1372 - 1377
=head2 protocol

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

=cut
1378
1379sub protocol {
1380
22
597
        my $self = shift;
1381
1382
22
41
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1383
2
5
                return $1;
1384        }
1385
20
35
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1386
2
3
                return 'http';
1387        }
1388
1389
18
19
        if(my $port = $ENV{'SERVER_PORT'}) {
1390
13
446
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1391
13
23
                        if($name =~ /https?/) {
1392
11
20
                                return $name;
1393                        } elsif($name eq 'www') {
1394                                # e.g. NetBSD and OpenBSD
1395
0
0
                                return 'http';
1396                        }
1397                        # Return an error, maybe missing something
1398                } elsif($port == 80) {
1399                        # e.g. Solaris
1400
0
0
                        return 'http';
1401                } elsif($port == 443) {
1402
0
0
                        return 'https';
1403                }
1404        }
1405
1406
7
14
        if($ENV{'REMOTE_ADDR'}) {
1407
0
0
                $self->_warn("Can't determine the calling protocol");
1408        }
1409
7
15
        return;
1410}
1411
1412 - 1437
=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
1438
1439sub tmpdir {
1440
23
1915
        my $self = shift;
1441
1442
23
21
        my $name = 'tmp';
1443
23
48
        if($^O eq 'MSWin32') {
1444
0
0
                $name = 'temp';
1445        }
1446
1447
23
18
        my $dir;
1448
1449
23
37
        if(!ref($self)) {
1450
3
4
                $self = __PACKAGE__->new();
1451        }
1452
23
32
        my $params = Params::Get::get_params(undef, @_);
1453
1454
23
263
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1455
5
17
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1456
5
29
                if((-d $dir) && (-w $dir)) {
1457
2
4
                        return $self->_untaint_filename({ filename => $dir });
1458                }
1459
3
5
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1460
3
15
                if((-d $dir) && (-w $dir)) {
1461
3
19
                        return $self->_untaint_filename({ filename => $dir });
1462                }
1463        }
1464
18
39
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1465
1
9
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1466
1
5
                if((-d $dir) && (-w $dir)) {
1467
0
0
                        return $self->_untaint_filename({ filename => $dir });
1468                }
1469        }
1470
18
226
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1471}
1472
1473 - 1485
=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
1486
1487sub rootdir {
1488
14
744
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1489
1
2
                return $ENV{'C_DOCUMENT_ROOT'};
1490        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1491
2
2
                return $ENV{'DOCUMENT_ROOT'};
1492        }
1493
11
7
        my $script_name = $0;
1494
1495
11
21
        unless(File::Spec->file_name_is_absolute($script_name)) {
1496
11
57
                $script_name = File::Spec->rel2abs($script_name);
1497        }
1498
11
17
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1499
0
0
                $script_name =~ s/.cgi\-bin.*//;
1500        }
1501
11
25
        if(-f $script_name) {   # More kludge
1502
11
9
                if($^O eq 'MSWin32') {
1503
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1504
0
0
                                return $1;
1505                        }
1506                } else {
1507
11
24
                        if($script_name =~ /(.+)\/.+?$/) {
1508
11
16
                                return $1;
1509                        }
1510                }
1511        }
1512
0
0
        return $script_name;
1513}
1514
1515 - 1519
=head2 root_dir

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

=cut
1520
1521sub root_dir
1522{
1523
4
392
        if($_[0] && ref($_[0])) {
1524
2
2
                my $self = shift;
1525
1526
2
2
                return $self->rootdir(@_);
1527        }
1528
2
2
        return __PACKAGE__->rootdir(@_);
1529}
1530
1531 - 1535
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1536
1537sub documentroot
1538{
1539
3
8
        if($_[0] && ref($_[0])) {
1540
1
1
                my $self = shift;
1541
1542
1
2
                return $self->rootdir(@_);
1543        }
1544
2
2
        return __PACKAGE__->rootdir(@_);
1545}
1546
1547 - 1551
=head2 logdir

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

=cut
1552
1553sub logdir {
1554
5
1172
        my $self = shift;
1555
5
5
        my $dir = shift;
1556
1557
5
8
        if(!ref($self)) {
1558
1
2
                $self = __PACKAGE__->new();
1559        }
1560
1561
5
9
        if($dir) {
1562
2
14
                if(length($dir) && (-d $dir) && (-w $dir)) {
1563
1
14
                        return $self->{'logdir'} = $dir;
1564                }
1565
1
3
                $self->_warn("Invalid logdir: $dir");
1566
1
7
                Carp::croak("Invalid logdir: $dir");
1567        }
1568
1569
3
11
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1570
9
31
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1571
3
2
                        $dir = $rc;
1572
3
3
                        last;
1573                }
1574        }
1575
3
6
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1576
3
7
        $self->{logdir} ||= $dir;
1577
1578
3
7
        return $dir;
1579}
1580
1581 - 1596
=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
1597
1598sub is_robot {
1599
21
293
        my $self = shift;
1600
1601
21
26
        if(defined($self->{is_robot})) {
1602
3
4
                return $self->{is_robot};
1603        }
1604
1605
18
16
        my $agent = $ENV{'HTTP_USER_AGENT'};
1606
18
12
        my $remote = $ENV{'REMOTE_ADDR'};
1607
1608
18
28
        unless($remote && $agent) {
1609                # Probably not running in CGI - assume real person
1610
8
9
                return 0;
1611        }
1612
1613        # See also params()
1614
10
57
        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/)) {
1615
1
2
                $self->status(403);
1616
1
1
                $self->{is_robot} = 1;
1617
1
2
                if($ENV{'REMOTE_ADDR'}) {
1618
1
3
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1619                } else {
1620
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1621                }
1622
1
2
                return 1;
1623        }
1624
9
259
        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) {
1625
3
3
                $self->{is_robot} = 1;
1626
3
5
                return 1;
1627        }
1628
1629        # TODO:
1630        # Download and use list from
1631        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1632
1633
6
5
        my $key = "$remote/$agent";
1634
1635
6
10
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1636                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1637
2
6
                my @crawler_lists = (
1638                        'http://fix-website-errors.com',
1639                        'http://keywords-monitoring-your-success.com',
1640                        'http://free-video-tool.com',
1641                        'http://magnet-to-torrent.com',
1642                        'http://torrent-to-magnet.com',
1643                        'http://dogsrun.net',
1644                        'http://###.responsive-test.net',
1645                        'http://uptime.com',
1646                        'http://uptimechecker.com',
1647                        'http://top1-seo-service.com',
1648                        'http://fast-wordpress-start.com',
1649                        'http://wordpress-crew.net',
1650                        'http://dbutton.net',
1651                        'http://justprofit.xyz',
1652                        'http://video--production.com',
1653                        'http://buttons-for-website.com',
1654                        'http://buttons-for-your-website.com',
1655                        'http://success-seo.com',
1656                        'http://videos-for-your-business.com',
1657                        'http://semaltmedia.com',
1658                        'http://dailyrank.net',
1659                        'http://uptimebot.net',
1660                        'http://sitevaluation.org',
1661                        'http://100dollars-seo.com',
1662                        'http://forum69.info',
1663                        'http://partner.semalt.com',
1664                        'http://best-seo-offer.com',
1665                        'http://best-seo-solution.com',
1666                        'http://semalt.semalt.com',
1667                        'http://semalt.com',
1668                        'http://7makemoneyonline.com',
1669                        'http://anticrawler.org',
1670                        'http://baixar-musicas-gratis.com',
1671                        'http://descargar-musica-gratis.net',
1672
1673                        # Mine
1674                        'http://www.seokicks.de/robot.html',
1675                );
1676
2
2
                $referrer =~ s/\\/_/g;
1677
2
3
5
11
                if(($referrer =~ /\)/) || (List::Util::any { $_ =~ /^$referrer/ } @crawler_lists)) {
1678
2
4
                        $self->_debug("is_robot: blocked trawler $referrer");
1679
1680
2
6
                        if($self->{cache}) {
1681
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1682                        }
1683
2
3
                        $self->{is_robot} = 1;
1684
2
5
                        return 1;
1685                }
1686        }
1687
1688
4
10
        if(defined($remote) && $self->{cache}) {
1689
0
0
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1690
0
0
                        return $self->{is_robot} = ($type eq 'robot');
1691                }
1692        }
1693
1694        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1695        # that is easily spoofed
1696
4
9
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1697                # Mark Facebook as a search engine, not a robot
1698
0
0
                if($self->{cache}) {
1699
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1700                }
1701
0
0
                return 0;
1702        }
1703
1704
4
8
        unless($self->{browser_detect}) {
1705
3
3
3
8
                if(eval { require HTTP::BrowserDetect; }) {
1706
3
4
                        HTTP::BrowserDetect->import();
1707
3
4
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1708                }
1709        }
1710
4
233
        if($self->{browser_detect}) {
1711
4
4
                my $is_robot = $self->{browser_detect}->robot();
1712
4
397
                if(defined($is_robot)) {
1713
2
5
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1714                }
1715
4
40
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1716
4
12
                $self->_debug("is_robot: $is_robot");
1717
1718
4
51
                if($is_robot) {
1719
2
2
                        if($self->{cache}) {
1720
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1721                        }
1722
2
2
                        $self->{is_robot} = $is_robot;
1723
2
4
                        return $is_robot;
1724                }
1725        }
1726
1727
2
2
        if($self->{cache}) {
1728
0
0
                $self->{cache}->set($key, 'unknown', '1 day');
1729        }
1730
2
1
        $self->{is_robot} = 0;
1731
2
11
        return 0;
1732}
1733
1734 - 1746
=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
1747
1748sub is_search_engine
1749{
1750
28
535
        my $self = shift;
1751
1752
28
34
        if(defined($self->{is_search_engine})) {
1753
6
11
                return $self->{is_search_engine};
1754        }
1755
1756
22
27
        if($ENV{'IS_SEARCH_ENGINE'}) {
1757
1
2
                return $ENV{'IS_SEARCH_ENGINE'}
1758        }
1759
1760
21
18
        my $remote = $ENV{'REMOTE_ADDR'};
1761
21
20
        my $agent = $ENV{'HTTP_USER_AGENT'};
1762
1763
21
31
        unless($remote && $agent) {
1764                # Probably not running in CGI - assume not a search engine
1765
9
11
                return 0;
1766        }
1767
1768
12
7
        my $key;
1769
1770
12
15
        if($self->{cache}) {
1771
0
0
                $key = "$remote/$agent";
1772
0
0
                if(defined($remote) && $self->{cache}) {
1773
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1774
0
0
                                return $self->{is_search} = ($type eq 'search');
1775                        }
1776                }
1777        }
1778
1779        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1780        # that is easily spoofed
1781
12
27
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1782                # Mark Facebook as a search engine, not a robot
1783
0
0
                if($self->{cache}) {
1784
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1785                }
1786
0
0
                return 1;
1787        }
1788
1789
12
12
        unless($self->{browser_detect}) {
1790
8
8
9
331
                if(eval { require HTTP::BrowserDetect; }) {
1791
8
6942
                        HTTP::BrowserDetect->import();
1792
8
12
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1793                }
1794        }
1795
12
586
        if(my $browser = $self->{browser_detect}) {
1796
12
15
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1797
12
1679
                if(!$is_search) {
1798
6
18
                        if(($agent =~ /SeznamBot\//) ||
1799                           ($agent =~ /Google-InspectionTool\//) ||
1800                           ($agent =~ /Googlebot\//)) {
1801
1
1
                                $is_search = 1;
1802                        }
1803                }
1804
12
18
                if($is_search && $self->{cache}) {
1805
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1806                }
1807
12
474
                return $self->{is_search_engine} = $is_search;
1808        }
1809
1810        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1811
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1812
1813
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1814
1815
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1816           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1817
0
0
                if($self->{cache}) {
1818
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1819                }
1820
0
0
                $self->{is_search_engine} = 1;
1821
0
0
                return 1;
1822        }
1823
1824
0
0
        $self->{is_search_engine} = 0;
1825
0
0
        return 0;
1826}
1827
1828 - 1850
=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
1851
1852sub browser_type {
1853
21
17
        my $self = shift;
1854
1855
21
24
        if($self->is_mobile()) {
1856
8
14
                return 'mobile';
1857        }
1858
13
13
        if($self->is_search_engine()) {
1859
6
13
                return 'search';
1860        }
1861
7
8
        if($self->is_robot()) {
1862
3
6
                return 'robot';
1863        }
1864
4
6
        return 'web';
1865}
1866
1867 - 1882
=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
1883
1884sub get_cookie {
1885
13
320
        my $self = shift;
1886
1887
13
14
        return $self->cookie(\@_);
1888}
1889
1890 - 1934
=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
1935
1936sub cookie
1937{
1938
23
2496
        my $self = shift;
1939
23
26
        my $params = Params::Validate::Strict::validate_strict({
1940                args => Params::Get::get_params('cookie_name', @_),
1941                schema => {
1942                        cookie_name => {
1943                                'type' => 'string',
1944                                'min' => 1,
1945                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
1946                        }
1947                }
1948        });
1949
1950
21
1568
        my $field = $params->{'cookie_name'};
1951
1952        # Validate field argument
1953
21
17
        if(!defined($field)) {
1954
2
4
                $self->_error('what cookie do you want?');
1955
2
8
                Carp::croak('what cookie do you want?');
1956        }
1957
1958        # Load cookies if not already loaded
1959
19
23
        unless($self->{jar}) {
1960
9
13
                if(defined $ENV{'HTTP_COOKIE'}) {
1961
8
19
17
33
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
1962                }
1963        }
1964
1965        # Return the cookie value if it exists, otherwise return undef
1966
19
47
        return $self->{jar}{$field};
1967}
1968
1969 - 1975
=head2 status

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

=cut
1976
1977sub status
1978{
1979
91
3897
        my $self = shift;
1980
91
59
        my $status = shift;
1981
1982        # Set status if provided
1983
91
115
        return $self->{status} = $status if(defined($status));
1984
1985        # Determine status based on request method if status is not set
1986
32
45
        unless (defined $self->{status}) {
1987
13
17
                my $method = $ENV{'REQUEST_METHOD'};
1988
1989
13
43
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
1990
9
25
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
1991
1992
7
21
                return 200;
1993        }
1994
1995        # Return current status or 200 by default
1996
19
61
        return $self->{status} || 200;
1997}
1998
1999 - 2011
=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
2012
2013sub messages
2014{
2015
7
2651
        my $self = shift;
2016
2017
7
17
        return $self->{'messages'};
2018}
2019
2020 - 2024
=head2  messages_as_string

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

=cut
2025
2026sub messages_as_string
2027{
2028
2
2
        my $self = shift;
2029
2030
2
4
        if(scalar($self->{'messages'})) {
2031
1
2
1
0
2
2
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2032
1
3
                return join('; ', @messages);
2033        }
2034
1
2
        return '';
2035}
2036
2037 - 2046
=head2 cache

Get/set the internal cache system.

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

=cut
2047
2048sub cache
2049{
2050
4
24
        my $self = shift;
2051
4
1
        my $cache = shift;
2052
2053
4
5
        if($cache) {
2054
0
0
                $self->{'cache'} = $cache;
2055        }
2056
4
4
        return $self->{'cache'};
2057}
2058
2059 - 2066
=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
2067
2068sub set_logger
2069{
2070
6
29
        my $self = shift;
2071
6
9
        my $params = Params::Get::get_params('logger', @_);
2072
2073
6
71
        if(my $logger = $params->{'logger'}) {
2074
6
9
                if(Scalar::Util::blessed($logger)) {
2075
4
6
                        $self->{'logger'} = $logger;
2076                } else {
2077
2
2
                        $self->{'logger'} = Log::Abstraction->new($logger);
2078                }
2079        } else {
2080
0
0
                $self->{'logger'} = Log::Abstraction->new();
2081        }
2082
6
41
        return $self;
2083}
2084
2085# Log and remember a message
2086sub _log
2087{
2088
412
373
        my ($self, $level, @messages) = @_;
2089
2090        # FIXME: add caller's function
2091        # if(($level eq 'warn') || ($level eq 'info')) {
2092
412
412
222
776
                push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2093        # }
2094
2095
412
684
        if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2096
412
695
                $self->{'logger'}->$level(join('', grep defined, @messages));
2097        }
2098}
2099
2100sub _debug {
2101
132
78
        my $self = shift;
2102
132
107
        $self->_log('debug', @_);
2103}
2104
2105sub _info {
2106
35
18
        my $self = shift;
2107
35
27
        $self->_log('info', @_);
2108}
2109
2110sub _notice {
2111
0
0
        my $self = shift;
2112
0
0
        $self->_log('notice', @_);
2113}
2114
2115sub _trace {
2116
199
123
        my $self = shift;
2117
199
226
        $self->_log('trace', @_);
2118}
2119
2120# Emit a warning message somewhere
2121sub _warn {
2122
44
27
        my $self = shift;
2123
44
50
        my $params = Params::Get::get_params('warning', @_);
2124
2125
44
505
        $self->_log('warn', $params->{'warning'});
2126
29
20033
        if(!defined($self->{'logger'})) {
2127
0
0
                Carp::carp($params->{'warning'});
2128        }
2129}
2130
2131# Emit an error message somewhere
2132sub _error {
2133
2
2
        my $self = shift;
2134
2
4
        my $params = Params::Get::get_params('warning', @_);
2135
2136
2
17
        $self->_log('error', $params->{'warning'});
2137
2
3044
        if(!defined($self->{'logger'})) {
2138
0
0
                Carp::croak($params->{'warning'});
2139        }
2140}
2141
2142# Ensure all environment variables are sanitized and validated before use.
2143# Use regular expressions to enforce strict input formats.
2144sub _get_env
2145{
2146
111
86
        my ($self, $var) = @_;
2147
2148
111
178
        return unless defined $ENV{$var};
2149
2150        # Strict sanitization: allow alphanumeric and limited special characters
2151
63
113
        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2152
63
77
                return $ENV{$var};
2153        }
2154
0
0
        $self->_warn("Invalid value in environment variable: $var");
2155
2156
0
0
        return undef;
2157}
2158
2159 - 2165
=head2 reset

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

=cut
2166
2167sub reset {
2168
13
8852
        my $class = shift;
2169
2170
13
22
        unless($class eq __PACKAGE__) {
2171
1
7
                carp('Reset is a class method');
2172
0
0
                return;
2173        }
2174
2175
12
18
        $stdin_data = undef;
2176}
2177
2178sub AUTOLOAD
2179{
2180
241
50129
        our $AUTOLOAD;
2181
2182
241
315
        my $self = shift or return;
2183
2184        # Extract the method name from the AUTOLOAD variable
2185
241
706
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2186
2187        # Skip if called on destruction
2188
241
717
        return if($method eq 'DESTROY');
2189
2190
8
14
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2191
2192        # Allow the AUTOLOAD feature to be disabled
2193
8
17
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2194
2195        # Ensure the method is called on the correct package object or a subclass
2196
7
18
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2197
2198        # Validate method name - only allow safe parameter names
2199
7
17
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2200
2201        # Delegate to the param method
2202
7
11
        return $self->param($method);
2203}
2204
2205 - 2291
=head1 AUTHOR

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

=head1 BUGS

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

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

=head1 SEE ALSO

=over 4

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

=item * L<Object::Configure>

=item * L<HTTP::BrowserDetect>

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

=back

=head1 REPOSITORY

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

=head1 SUPPORT

This module is provided as-is without any warranty.

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

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

    perldoc CGI::Info

You can also look for information at:

=over 4

=item * MetaCPAN

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

=item * RT: CPAN's request tracker

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

=item * CPAN Testers' Matrix

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

=item * CPAN Testers Dependencies

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

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2010-2025 Nigel Horne.

Usage is subject to licence terms.

The licence terms of this software are as follows:

=over 4

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

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

=back

=cut
2292
22931;