File Coverage

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

linestmtbrancondsubtimecode
1package CGI::Info;
2
3# TODO: remove the expect argument
4# TODO: look into params::check or params::validate
5
6
41
41
41
1897017
34
934
use warnings;
7
41
41
41
61
31
341
use strict;
8
9
41
41
41
6199
17335
67
use boolean;
10
41
41
41
1117
30
866
use Carp;
11
41
41
41
8626
1869293
616
use Object::Configure 0.19;
12
41
41
41
111
27
491
use File::Spec;
13
41
41
41
62
189
304
use Log::Abstraction 0.10;
14
41
41
41
72
227
579
use Params::Get 0.13;
15
41
41
41
64
175
413
use Params::Validate::Strict 0.21;
16
41
41
41
8897
104322
871
use Net::CIDR;
17
41
41
41
83
24
482
use Return::Set;
18
41
41
41
59
43
474
use Scalar::Util;
19
41
41
41
62
39
6598
use Socket;     # For AF_INET
20
41
41
283
52
use 5.008;
21# use Cwd;
22# use JSON::Parse;
23
41
41
41
118
37
316
use List::Util ();      # Can go when expect goes
24# use Sub::Private;
25
41
41
41
7404
493773
571
use Sys::Path;
26
27
41
41
41
7680
235107
112
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
251
2068575
        my $class = shift;
152
153        # Handle hash or hashref arguments
154
251
427
        my $params = Params::Get::get_params(undef, @_) || {};
155
156
250
3040
        if(!defined($class)) {
157
1
1
1
2
                if((scalar keys %{$params}) > 0) {
158                        # Using CGI::Info:new(), not CGI::Info->new()
159
0
0
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
160                }
161
162                # FIXME: this only works when no arguments are given
163
1
1
                $class = __PACKAGE__;
164        } elsif(Scalar::Util::blessed($class)) {
165                # If $class is an object, clone it with new arguments
166
5
5
5
5
6
24
                return bless { %{$class}, %{$params} }, ref($class);
167        }
168
169        # Load the configuration from a config file, if provided
170
245
414
        $params = Object::Configure::configure($class, $params);
171
172        # Validate logger object has required methods
173
244
1167253
        if(defined $params->{'logger'}) {
174
244
7042
                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
244
834
        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
5
                if(my $logger = $params->{'logger'}) {
185
2
5
                        $logger->error("$class: expect has been deprecated, use allow instead");
186                }
187
2
1380
                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
242
242
204
699
                %{$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
25
993
        my $self = shift;
231
232
25
42
        unless($self->{script_name}) {
233
16
22
                $self->_find_paths();
234        }
235
25
72
        return $self->{script_name};
236}
237
238sub _find_paths {
239
26
25
        my $self = shift;
240
241
26
43
        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
26
375
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
246
247
26
610
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
248
249        # Determine script name
250
26
45
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
251
26
509
        $self->{script_name} = $self->_untaint_filename({
252                filename => File::Basename::basename($script_name)
253        });
254
255        # Determine script path
256
26
53
        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
15
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
260
6
5
                        $script_name = $self->_get_env('SCRIPT_NAME');
261
262                        # It's usually the case, e.g. /cgi-bin/foo.pl
263
6
10
                        $script_name =~ s{^/}{};
264
265
6
38
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
266                } else {
267
6
60
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
268                                # Called from a command line with a full path
269
1
2
                                $self->{script_path} = $script_name;
270                        } else {
271
5
23
                                require Cwd unless Cwd->can('abs_path');
272
273
5
14
                                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
44
                                $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
3
4
                $self->{script_path} = $0;
284        } else {
285
9
111
                $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
26
65
        });
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
29
4166
        my $self = shift;
315
316
29
46
        unless($self->{script_path}) {
317
7
8
                $self->_find_paths();
318        }
319
29
128
        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
17
28
        my $self = shift;
341
342        # Ensure $self is an object
343
17
29
        $self = __PACKAGE__->new() unless ref $self;
344
345        # Set script path if it is not already defined
346
17
24
        $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
17
45
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
351
352
17
163
        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
11
756
        my $self = shift;
378
379
11
22
        unless($self->{site}) {
380
4
7
                $self->_find_site_details();
381        }
382
383
11
85
        return $self->{site};
384}
385
386sub _find_site_details
387{
388
13
10
        my $self = shift;
389
390        # Log entry to the routine
391
13
24
        $self->_trace('Entering _find_site_details');
392
393
13
292
        return if $self->{site} && $self->{cgi_site};
394
395        # Determine cgi_site using environment variables or hostname
396
11
50
        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
397                # Import necessary module
398
5
231
                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
399
400
5
932
                $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
61
                $self->{cgi_site} =~ s/(.*)\.+$/$1/;  # Trim trailing dots
404
405
5
16
                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
406
1
3
                        $self->{cgi_site} =~ s/^http/$protocol/;
407                }
408        } else {
409                # Import necessary module
410
6
32
                require Sys::Hostname unless Sys::Hostname->can('hostname');
411
412
6
12
                $self->_debug('Falling back to using hostname');
413
6
96
                $self->{cgi_site} = Sys::Hostname::hostname();
414        }
415
416        # Set site details if not already defined
417
11
45
        $self->{site} ||= $self->{cgi_site};
418
11
24
        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
419        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
420
11
30
                unless $self->{cgi_site} =~ /^https?:\/\//;
421
422        # Warn if site details could not be determined
423
11
32
        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
424
425        # Log exit
426
11
15
        $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
10
199
        my $self = shift;
440
441
10
17
        if(!ref($self)) {
442
1
2
                $self = __PACKAGE__->new();
443        }
444
10
26
        return $self->{domain} if $self->{domain};
445
446
5
14
        $self->_find_site_details();
447
448
5
42
        if(my $site = $self->{site}) {
449
5
11
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
450        }
451
452
5
14
        return $self->{domain};
453}
454
455 - 459
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
460
461sub cgi_host_url {
462
8
32
        my $self = shift;
463
464
8
14
        unless($self->{cgi_site}) {
465
4
6
                $self->_find_site_details();
466        }
467
468
8
76
        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
700
3896
        my $self = shift;
630
631
700
678
        my $params = Params::Get::get_params(undef, @_);
632
633
700
5200
        if((defined($self->{paramref})) && ((!defined($params->{'allow'})) || defined($self->{allow}) && ($params->{'allow'} eq $self->{allow}))) {
634
47
56
                return $self->{paramref};
635        }
636
637
653
560
        if(defined($params->{allow})) {
638
11
9
                $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
653
482
        if(defined($params->{upload_dir})) {
649
4
6
                $self->{upload_dir} = $params->{upload_dir};
650        }
651
653
490
        if(defined($params->{'logger'})) {
652
2
4
                $self->set_logger($params->{'logger'});
653        }
654
653
718
        $self->_trace('Entering params');
655
656
653
12919
        my @pairs;
657
653
525
        my $content_type = $ENV{'CONTENT_TYPE'};
658
653
380
        my %FORM;
659
660
653
897
        if((!$ENV{'GATEWAY_INTERFACE'}) || (!$ENV{'REQUEST_METHOD'})) {
661                # require IO::Interactive;
662                # IO::Interactive->import();
663
664
544
521
                if(@ARGV) {
665
9
8
                        @pairs = @ARGV;
666
9
10
                        if(defined($pairs[0])) {
667
9
21
                                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
4
                                        $self->{is_mobile} = 1;
672
2
3
                                        shift @pairs;
673                                } elsif($pairs[0] eq '--search-engine') {
674
1
1
                                        $self->{is_search_engine} = 1;
675
1
2
                                        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
0
0
                } elsif(0) {
685                        # TODO:  Do I really need this anymore?
686                        my $oldfh = select(STDOUT);
687                        print "Entering debug mode\n",
688                                "Enter key=value pairs - end with quit\n";
689                        select($oldfh);
690
691                        # Avoid prompting for the arguments more than once
692                        # if just 'quit' is entered
693                        $self->{args_read} = 1;
694
695                        while(<STDIN>) {
696                                chop(my $line = $_);
697                                $line =~ s/[\r\n]//g;
698                                last if $line eq 'quit';
699                                push(@pairs, $line);
700                                $stdin_data .= "$line\n";
701                        }
702                }
703        } elsif(($ENV{'REQUEST_METHOD'} eq 'GET') || ($ENV{'REQUEST_METHOD'} eq 'HEAD')) {
704
80
100
                if(my $query = $ENV{'QUERY_STRING'}) {
705
75
81
                        if((defined($content_type)) && ($content_type =~ /multipart\/form-data/i)) {
706
1
1
                                if($ENV{'REMOTE_ADDR'}) {
707
0
0
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Multipart/form-data not supported for GET" });
708                                } else {
709
1
2
                                        $self->_warn('Multipart/form-data not supported for GET');
710                                }
711
0
0
                                $self->{status} = 501;       # Not implemented
712
0
0
                                return;
713                        }
714
74
85
                        $query =~ s/\\u0026/\&/g;
715
74
105
                        @pairs = split(/&/, $query);
716                } else {
717
5
12
                        return;
718                }
719        } elsif($ENV{'REQUEST_METHOD'} eq 'POST') {
720
26
34
                my $content_length = $self->_get_env('CONTENT_LENGTH');
721
26
58
                if((!defined($content_length)) || ($content_length =~ /\D/)) {
722
2
2
                        $self->{status} = 411;
723
2
6
                        return;
724                }
725
24
71
                if(($self->{max_upload_size} >= 0) && ($content_length > $self->{max_upload_size})) {       # Set maximum posts
726                        # TODO: Design a way to tell the caller to send HTTP
727                        # status 413
728
2
3
                        $self->{status} = 413;
729
2
3
                        $self->_warn('Large upload prohibited');
730
2
3
                        return;
731                }
732
733
22
76
                if((!defined($content_type)) || ($content_type =~ /application\/x-www-form-urlencoded/)) {
734
4
3
                        my $buffer;
735
4
5
                        if($stdin_data) {
736
1
1
                                $buffer = $stdin_data;
737                        } else {
738
3
7
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
739
1
2
                                        $self->_warn('POST failed: something else may have read STDIN');
740                                }
741
3
4
                                $stdin_data = $buffer;
742                        }
743
4
5
                        @pairs = split(/&/, $buffer);
744
745                        # if($ENV{'QUERY_STRING'}) {
746                                # my @getpairs = split(/&/, $ENV{'QUERY_STRING'});
747                                # push(@pairs, @getpairs);
748                        # }
749                } elsif($content_type =~ /multipart\/form-data/i) {
750
15
20
                        if(!defined($self->{upload_dir})) {
751
1
1
                                if($ENV{'REMOTE_ADDR'}) {
752                                        # This could be an attack
753
0
0
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Attempt to upload a file when upload_dir has not been set" });
754                                } else {
755
1
2
                                        $self->_warn({ warning => 'Attempt to upload a file when upload_dir has not been set' });
756                                }
757
0
0
                                return;
758                        }
759
760                        # Validate 'upload_dir'
761                        # Ensure the upload directory is safe and accessible
762                        # - Check permissions
763                        # - Validate path to prevent directory traversal attacks
764                        # TODO: Consider using a temporary directory for uploads and moving them later
765
14
55
                        if(!File::Spec->file_name_is_absolute($self->{upload_dir})) {
766
3
10
                                $self->_warn({
767                                        warning => "upload_dir $self->{upload_dir} isn't a full pathname"
768                                });
769
2
6
                                $self->status(500);
770
2
3
                                delete $self->{upload_dir};
771
2
3
                                return;
772                        }
773
11
75
                        if(!-d $self->{upload_dir}) {
774
3
8
                                $self->_warn({
775                                        warning => "upload_dir $self->{upload_dir} isn't a directory"
776                                });
777
1
3
                                $self->status(500);
778
1
2
                                delete $self->{upload_dir};
779
1
17
                                return;
780                        }
781
8
37
                        if(!-w $self->{upload_dir}) {
782
2
2
                                delete $self->{paramref};
783
2
8
                                $self->_warn({
784                                        warning => "upload_dir $self->{upload_dir} isn't writeable"
785                                });
786
1
4
                                $self->status(500);
787
1
1
                                delete $self->{upload_dir};
788
1
3
                                return;
789                        }
790
6
11
                        my $tmpdir = $self->tmpdir();
791
6
35
                        if($self->{'upload_dir'} !~ /^\Q$tmpdir\E/) {
792                                $self->_warn({
793
0
0
                                        warning => 'upload_dir ' . $self->{'upload_dir'} . " isn't somewhere in the temporary area $tmpdir"
794                                });
795
0
0
                                $self->status(500);
796
0
0
                                delete $self->{upload_dir};
797
0
0
                                return;
798                        }
799
6
13
                        if($content_type =~ /boundary=(\S+)$/) {
800
6
16
                                @pairs = $self->_multipart_data({
801                                        length => $content_length,
802                                        boundary => $1
803                                });
804                        }
805                } elsif($content_type =~ /text\/xml/i) {
806
1
2
                        my $buffer;
807
1
2
                        if($stdin_data) {
808
0
0
                                $buffer = $stdin_data;
809                        } else {
810
1
3
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
811
0
0
                                        $self->_warn({
812                                                warning => 'XML failed: something else may have read STDIN'
813                                        });
814                                }
815
1
0
                                $stdin_data = $buffer;
816                        }
817
818
1
2
                        $FORM{XML} = $buffer;
819
820
1
1
                        $self->{paramref} = \%FORM;
821
822
1
2
                        return \%FORM;
823                } elsif($content_type =~ /application\/json/i) {
824
1
21
                        require JSON::MaybeXS && JSON::MaybeXS->import() unless JSON::MaybeXS->can('parse_json');
825                        # require JSON::MaybeXS;
826                        # JSON::MaybeXS->import();
827
828
1
1
                        my $buffer;
829
830
1
1
                        if($stdin_data) {
831
0
0
                                $buffer = $stdin_data;
832                        } else {
833
1
3
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
834
0
0
                                        $self->_warn({
835                                                warning => 'read failed: something else may have read STDIN'
836                                        });
837                                }
838
1
1
                                $stdin_data = $buffer;
839                        }
840                        # JSON::Parse::assert_valid_json($buffer);
841                        # my $paramref = JSON::Parse::parse_json($buffer);
842
1
5
                        my $paramref = decode_json($buffer);
843
1
1
1
1
                        foreach my $key(keys(%{$paramref})) {
844
2
8
                                push @pairs, "$key=" . $paramref->{$key};
845                        }
846                } else {
847
1
1
                        my $buffer;
848
1
2
                        if($stdin_data) {
849
0
0
                                $buffer = $stdin_data;
850                        } else {
851
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
852
0
0
                                        $self->_warn({
853                                                warning => 'read failed: something else may have read STDIN'
854                                        });
855                                }
856
1
1
                                $stdin_data = $buffer;
857                        }
858
859
1
2
                        $self->_warn({
860                                warning => "POST: Invalid or unsupported content type: $content_type: $buffer",
861                        });
862                }
863        } elsif($ENV{'REQUEST_METHOD'} eq 'OPTIONS') {
864
0
0
                $self->{status} = 405;
865
0
0
                return;
866        } elsif($ENV{'REQUEST_METHOD'} eq 'DELETE') {
867
1
2
                $self->{status} = 405;
868
1
2
                return;
869        } else {
870                # TODO: Design a way to tell the caller to send HTTP
871                # status 501
872
2
2
                $self->{status} = 501;
873
2
5
                $self->_warn({
874                        warning => 'Use POST, GET or HEAD'
875                });
876        }
877
878
628
498
        unless(scalar @pairs) {
879
536
1376
                return;
880        }
881
882
92
1870
        require String::Clean::XSS;
883
92
47121
        String::Clean::XSS->import();
884        # require String::EscapeCage;
885        # String::EscapeCage->import();
886
887
92
69
        foreach my $arg (@pairs) {
888
192
224
                my($key, $value) = split(/=/, $arg, 2);
889
890
192
159
                next unless($key);
891
892
188
151
                $key =~ s/\0//g;        # Strip encoded NUL byte poison
893
188
111
                $key =~ s/%00//g;       # Strip NUL byte poison
894
188
1
103
3
                $key =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
895
188
146
                $key =~ tr/+/ /;
896
188
158
                if(defined($value)) {
897
188
131
                        $value =~ s/\0//g;      # Strip NUL byte poison
898
188
100
                        $value =~ s/%00//g;     # Strip encoded NUL byte poison
899
188
83
121
105
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
900
188
108
                        $value =~ tr/+/ /;
901                } else {
902
0
0
                        $value = '';
903                }
904
905
188
153
                $key = _sanitise_input($key);
906
907
188
13976
                if($self->{allow}) {
908                        # Is this a permitted argument?
909
78
68
                        if(!exists($self->{allow}->{$key})) {
910
17
22
                                $self->_info("Discard unallowed argument '$key'");
911
17
238
                                $self->status(422);
912
17
16
                                next;   # Skip to the next parameter
913                        }
914
915                        # Do we allow any value, or must it be validated?
916
61
62
                        if(defined(my $schema = $self->{allow}->{$key})) {        # Get the schema for this key
917
54
103
                                if(!ref($schema)) {
918                                        # Can only contain one value
919
3
6
                                        if($value ne $schema) {
920
2
3
                                                $self->_info("Block $key = $value");
921
2
22
                                                $self->status(422);
922
2
2
                                                next;   # Skip to the next parameter
923                                        }
924                                } elsif(ref($schema) eq 'Regexp') {
925
12
27
                                        if($value !~ $schema) {
926                                                # Simple regex
927
8
14
                                                $self->_info("Block $key = $value");
928
8
101
                                                $self->status(422);
929
8
8
                                                next;   # Skip to the next parameter
930                                        }
931                                } elsif(ref($schema) eq 'CODE') {
932
9
12
                                        unless($schema->($key, $value, $self)) {
933
2
7
                                                $self->_info("Block $key = $value");
934
2
45
                                                next;
935                                        }
936                                } else {
937                                        # Set of rules
938
30
17
                                        eval {
939                                                $value = Params::Validate::Strict::validate_strict({
940                                                        schema => { $key => $schema },
941                                                        args => { $key => $value },
942                                                        unknown_parameter_handler => 'die',
943
30
61
                                                        logger => $self->{'logger'}
944                                                });
945                                        };
946
30
9433
                                        if($@) {
947
6
15
                                                $self->_info("Block $key = $value: $@");
948
6
101
                                                $self->status(422);
949
6
8
                                                next;   # Skip to the next parameter
950                                        }
951
24
24
5
23
                                        if(scalar keys %{$value}) {
952
24
23
                                                $value = $value->{$key};
953                                        } else {
954
0
0
                                                $self->_info("Block $key = $value");
955
0
0
                                                $self->status(422);
956
0
0
                                                next;   # Skip to the next parameter
957                                        }
958                                }
959                        }
960                }
961
962                # if($self->{expect} && (List::Util::none { $_ eq $key } @{$self->{expect}})) {
963                        # next;
964                # }
965
152
225
                my $orig_value = $value;
966
152
109
                $value = _sanitise_input($value);
967
968
152
9333
                if((!defined($ENV{'REQUEST_METHOD'})) || ($ENV{'REQUEST_METHOD'} eq 'GET')) {
969                        # From http://www.symantec.com/connect/articles/detection-sql-injection-and-cross-site-scripting-attacks
970                        # Facebook FBCLID can have "--"
971                        # if(($value =~ /(\%27)|(\')|(\-\-)|(\%23)|(\#)/ix) ||
972
137
1721
                        if(($value =~ /(\%27)|(\')|(\%23)|(\#)/ix) ||
973                           ($value =~ /((\%3D)|(=))[^\n]*((\%27)|(\')|(\-\-)|(\%3B)|(;))/i) ||
974                           ($value =~ /\w*((\%27)|(\'))((\%6F)|o|(\%4F))((\%72)|r|(\%52))\s*(OR|AND|UNION|SELECT|--)/ix) ||
975                           ($value =~ /((\%27)|(\'))union/ix) ||
976                           ($value =~ /select[[a-z]\s\*]from/ix) ||
977                           ($value =~ /\sAND\s1=1/ix) ||
978                           ($value =~ /\sOR\s.+\sAND\s/) ||
979                           ($value =~ /\/\*\*\/ORDER\/\*\*\/BY\/\*\*/ix) ||
980                           ($value =~ /\/AND\/.+\(SELECT\//) || # United/**/States)/**/AND/**/(SELECT/**/6734/**/FROM/**/(SELECT(SLEEP(5)))lRNi)/**/AND/**/(8984=8984
981                           ($value =~ /exec(\s|\+)+(s|x)p\w+/ix)) {
982
11
15
                                $self->status(403);
983
11
12
                                if($ENV{'REMOTE_ADDR'}) {
984
1
3
                                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$value'");
985                                } else {
986
10
13
                                        $self->_warn("SQL injection attempt blocked for '$key=$value'");
987                                }
988
11
30
                                return;
989                        }
990
126
120
                        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
991
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/)) {
992
0
0
                                        $self->status(403);
993
0
0
                                        if($ENV{'REMOTE_ADDR'}) {
994
0
0
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
995                                        } else {
996
0
0
                                                $self->_warn("SQL injection attempt blocked for '$agent'");
997                                        }
998
0
0
                                        return;
999                                }
1000                        }
1001
126
466
                        if(($value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
1002                           ($value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i) ||
1003                           ($orig_value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
1004                           ($orig_value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i)) {
1005
5
10
                                $self->status(403);
1006
5
11
                                $self->_warn("XSS injection attempt blocked for '$value'");
1007
5
12
                                return;
1008                        }
1009
121
90
                        if($value =~ /mustleak\.com\//) {
1010
0
0
                                $self->status(403);
1011
0
0
                                $self->_warn("Blocked mustleak attack for '$key'");
1012
0
0
                                return;
1013                        }
1014
121
588
                        if($value =~ /\.\.\//) {
1015
3
5
                                $self->status(403);
1016
3
6
                                $self->_warn("Blocked directory traversal attack for '$key'");
1017
2
4
                                return;
1018                        }
1019                }
1020
133
118
                if(length($value) > 0) {
1021                        # Don't add if it's already there
1022
128
121
                        if($FORM{$key} && ($FORM{$key} ne $value)) {
1023
3
6
                                $FORM{$key} .= ",$value";
1024                        } else {
1025
125
155
                                $FORM{$key} = $value;
1026                        }
1027                }
1028        }
1029
1030
72
69
        unless(%FORM) {
1031
11
25
                return;
1032        }
1033
1034
61
68
        if($self->{'logger'}) {
1035
61
80
                while(my ($key,$value) = each %FORM) {
1036
115
767
                        $self->_debug("$key=$value");
1037                }
1038        }
1039
1040
61
797
        $self->{paramref} = \%FORM;
1041
1042
61
101
        return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
1043}
1044
1045 - 1077
=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
1078
1079sub param {
1080
40
4261
        my ($self, $field) = @_;
1081
1082
40
39
        if(!defined($field)) {
1083
2
4
                return $self->params();
1084        }
1085        # Is this a permitted argument?
1086
38
57
        if($self->{allow} && !exists($self->{allow}->{$field})) {
1087
5
12
                $self->_warn({
1088                        warning => "param: $field isn't in the allow list"
1089                });
1090
1
4
                return;
1091        }
1092
1093        # Prevent deep recursion which can happen when a validation routine calls param()
1094
33
15
        my $allow;
1095
33
36
        if($self->{in_param} && $self->{allow}) {
1096
1
1
                $allow = delete $self->{allow};
1097        }
1098
33
29
        $self->{in_param} = 1;
1099
1100
33
37
        my $params = $self->params();
1101
1102
33
750
        $self->{in_param} = 0;
1103
33
26
        $self->{allow} = $allow if($allow);
1104
1105
33
39
        if($params) {
1106
27
42
                return Return::Set::set_return($params->{$field}, { type => 'string' });
1107        }
1108}
1109
1110sub _sanitise_input($) {
1111
340
215
        my $arg = shift;
1112
1113
340
255
        return if(!defined($arg));
1114
1115        # Remove hacking attempts and spaces
1116
340
248
        $arg =~ s/[\r\n]//g;
1117
340
232
        $arg =~ s/\s+$//;
1118
340
253
        $arg =~ s/^\s//;
1119
1120
340
187
        $arg =~ s/<!--.*-->//g;
1121        # Allow :
1122        # $arg =~ s/[;<>\*|`&\$!?#\(\)\[\]\{\}'"\\\r]//g;
1123
1124        # return $arg;
1125        # return String::EscapeCage->new(convert_XSS($arg))->escapecstring();
1126
340
276
        return convert_XSS($arg);
1127}
1128
1129sub _multipart_data {
1130
6
5
        my ($self, $args) = @_;
1131
1132
6
8
        $self->_trace('Entering _multipart_data');
1133
1134
6
71
        my $total_bytes = $$args{length};
1135
1136
6
12
        $self->_debug("_multipart_data: total_bytes = $total_bytes");
1137
1138
6
65
        if($total_bytes == 0) {
1139
0
0
                return;
1140        }
1141
1142
6
17
        unless($stdin_data) {
1143
6
13
                while(<STDIN>) {
1144
54
34
                        chop(my $line = $_);
1145
54
28
                        $line =~ s/[\r\n]//g;
1146
54
50
                        $stdin_data .= "$line\n";
1147                }
1148
6
8
                if(!$stdin_data) {
1149
0
0
                        return;
1150                }
1151        }
1152
1153
6
12
        my $boundary = $$args{boundary};
1154
1155
6
4
        my @pairs;
1156
6
4
        my $writing_file = 0;
1157
6
5
        my $key;
1158        my $value;
1159
6
4
        my $in_header = 0;
1160
6
3
        my $fout;
1161
1162
6
14
        foreach my $line(split(/\n/, $stdin_data)) {
1163
44
59
                if($line =~ /^--\Q$boundary\E--$/) {
1164
2
2
                        last;
1165                }
1166
42
56
                if($line =~ /^--\Q$boundary\E$/) {
1167
10
12
                        if($writing_file) {
1168
0
0
                                close $fout;
1169
0
0
                                $writing_file = 0;
1170                        } elsif(defined($key)) {
1171
4
4
                                push(@pairs, "$key=$value");
1172
4
3
                                $value = undef;
1173                        }
1174
10
11
                        $in_header = 1;
1175                } elsif($in_header) {
1176
20
26
                        if(length($line) == 0) {
1177
8
7
                                $in_header = 0;
1178                        } elsif($line =~ /^Content-Disposition: (.+)/i) {
1179
10
6
                                my $field = $1;
1180
10
19
                                if($field =~ /name="(.+?)"/) {
1181
10
5
                                        $key = $1;
1182                                }
1183
10
22
                                if($field =~ /filename="(.+)?"/) {
1184
6
5
                                        my $filename = $1;
1185
6
14
                                        unless(defined($filename)) {
1186
0
0
                                                $self->_warn('No upload filename given');
1187
0
0
                                        } elsif($filename =~ /[\\\/\|]/) {
1188
2
2
                                                $self->_warn("Disallowing invalid filename: $filename");
1189                                        } else {
1190
4
8
                                                $filename = $self->_create_file_name({
1191                                                        filename => $filename
1192                                                });
1193
1194                                                # Don't do this since it taints the string and I can't work out how to untaint it
1195                                                # my $full_path = Cwd::realpath(File::Spec->catfile($self->{upload_dir}, $filename));
1196                                                # $full_path =~ m/^(\/[\w\.]+)$/;
1197
4
19
                                                my $full_path = File::Spec->catfile($self->{upload_dir}, $filename);
1198
4
184
                                                unless(open($fout, '>', $full_path)) {
1199
0
0
                                                        $self->_warn("Can't open $full_path");
1200                                                }
1201
4
4
                                                $writing_file = 1;
1202
4
25
                                                push(@pairs, "$key=$filename");
1203                                        }
1204                                }
1205                        }
1206                        # TODO: handle Content-Type: text/plain, etc.
1207                } else {
1208
12
9
                        if($writing_file) {
1209
8
26
                                print $fout "$line\n";
1210                        } else {
1211
4
3
                                $value .= $line;
1212                        }
1213                }
1214        }
1215
1216
4
7
        if($writing_file) {
1217
4
83
                close $fout;
1218        }
1219
1220
4
7
        $self->_trace('Leaving _multipart_data');
1221
1222
4
107
        return @pairs;
1223}
1224
1225# Robust filename generation (preventing overwriting)
1226sub _create_file_name {
1227
4
5
        my ($self, $args) = @_;
1228
4
5
        my $filename = $$args{filename} . '_' . time;
1229
1230
4
3
        my $counter = 0;
1231
4
2
        my $rc;
1232
1233
4
4
        do {
1234
4
6
                $rc = $filename . ($counter ? "_$counter" : '');
1235
4
38
                $counter++;
1236        } until(! -e $rc);      # Check if file exists
1237
1238
4
4
        return $rc;
1239}
1240
1241# Untaint a filename. Regex from CGI::Untaint::Filenames
1242sub _untaint_filename {
1243
57
59
        my ($self, $args) = @_;
1244
1245
57
164
        if($$args{filename} =~ /(^[\w\+_\040\#\(\)\{\}\[\]\/\-\^,\.:;&%@\\~]+\$?$)/) {
1246
57
142
                return $1;
1247        }
1248        # return undef;
1249}
1250
1251 - 1259
=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
1260
1261sub is_mobile {
1262
44
1165
        my $self = shift;
1263
1264
44
62
        if(defined($self->{is_mobile})) {
1265
12
20
                return $self->{is_mobile};
1266        }
1267
1268
32
43
        if($ENV{'IS_MOBILE'}) {
1269
1
2
                return $ENV{'IS_MOBILE'}
1270        }
1271
1272        # Support Sec-CH-UA-Mobile
1273
31
41
        if(my $ch_ua_mobile = $ENV{'HTTP_SEC_CH_UA_MOBILE'}) {
1274
3
3
                if($ch_ua_mobile eq '?1') {
1275
1
1
                        $self->{is_mobile} = 1;
1276
1
2
                        return 1;
1277                }
1278        }
1279
1280
30
35
        if($ENV{'HTTP_X_WAP_PROFILE'}) {
1281                # E.g. Blackberry
1282                # TODO: Check the sanity of this variable
1283
1
1
                $self->{is_mobile} = 1;
1284
1
3
                return 1;
1285        }
1286
1287
29
42
        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1288
18
952
                if($agent =~ /.+(Android|iPhone).+/) {
1289
3
4
                        $self->{is_mobile} = 1;
1290
3
7
                        return 1;
1291                }
1292
1293                # From http://detectmobilebrowsers.com/
1294
15
472
                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) {
1295
1
1
                        $self->{is_mobile} = 1;
1296
1
2
                        return 1;
1297                }
1298
1299                # Save loading and calling HTTP::BrowserDetect
1300
14
15
                my $remote = $ENV{'REMOTE_ADDR'};
1301
14
26
                if(defined($remote) && $self->{cache}) {
1302
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1303
0
0
                                return $self->{is_mobile} = ($type eq 'mobile');
1304                        }
1305                }
1306
1307
14
26
                unless($self->{browser_detect}) {
1308
8
8
7
1506
                        if(eval { require HTTP::BrowserDetect; }) {
1309
8
32207
                                HTTP::BrowserDetect->import();
1310
8
23
                                $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1311                        }
1312                }
1313
1314
14
591
                if($self->{browser_detect}) {
1315
14
23
                        my $device = $self->{browser_detect}->device();
1316                        # Without the ?1:0 it will set to the empty string not 0
1317
14
64
                        my $is_mobile = (defined($device) && ($device =~ /blackberry|webos|iphone|ipod|ipad|android/i)) ? 1 : 0;
1318
14
21
                        if($is_mobile && $self->{cache} && defined($remote)) {
1319
0
0
                                $self->{cache}->set("$remote/$agent", 'mobile', '1 day');
1320                        }
1321
14
30
                        return $self->{is_mobile} = $is_mobile;
1322                }
1323        }
1324
1325
11
16
        return 0;
1326}
1327
1328 - 1332
=head2 is_tablet

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

=cut
1333
1334sub is_tablet {
1335
6
22
        my $self = shift;
1336
1337
6
7
        if(defined($self->{is_tablet})) {
1338
1
1
                return $self->{is_tablet};
1339        }
1340
1341
5
147
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1342                # TODO: add others when I see some nice user_agents
1343
1
1
                $self->{is_tablet} = 1;
1344        } else {
1345
4
3
                $self->{is_tablet} = 0;
1346        }
1347
1348
5
8
        return $self->{is_tablet};
1349}
1350
1351 - 1377
=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
1378
1379sub as_string
1380{
1381
819
8602
        my $self = shift;
1382
1383
819
1028
        my $args = Params::Validate::Strict::validate_strict({
1384                args => Params::Get::get_params(undef, @_) || {},
1385                schema => {
1386                        raw => {
1387                                'type' => 'boolean',
1388                                'optional' => 1
1389                        }
1390                }
1391        });
1392
1393        # Retrieve object parameters
1394
575
39243
        my $params = $self->params() || return '';
1395
1396
30
246
        my $rc;
1397
1398
30
30
        if($args->{'raw'}) {
1399                # Raw mode: return key=value pairs without escaping
1400                $rc = join '; ', map {
1401
4
6
                        "$_=" . $params->{$_}
1402
2
2
2
3
                } sort keys %{$params};
1403        } else {
1404                # Escaped mode: escape special characters
1405                $rc = join '; ', map {
1406
42
26
                        my $value = $params->{$_};
1407
1408
42
41
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1409
42
60
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1410
42
61
                        "$_=$value"
1411
28
28
16
42
                } sort keys %{$params};
1412        }
1413
1414
30
33
        $rc ||= '';
1415
1416
30
39
        $self->_trace("as_string: returning '$rc'");
1417
1418
30
452
        return $rc;
1419}
1420
1421 - 1426
=head2 protocol

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

=cut
1427
1428sub protocol {
1429
25
614
        my $self = shift;
1430
1431
25
59
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1432
2
4
                return $1;
1433        }
1434
23
42
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1435
2
5
                return 'http';
1436        }
1437
1438
21
28
        if(my $port = $ENV{'SERVER_PORT'}) {
1439
13
1049
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1440
13
40
                        if($name =~ /https?/) {
1441
11
35
                                return $name;
1442                        } elsif($name eq 'www') {
1443                                # e.g. NetBSD and OpenBSD
1444
0
0
                                return 'http';
1445                        }
1446                        # Return an error, maybe missing something
1447                } elsif($port == 80) {
1448                        # e.g. Solaris
1449
0
0
                        return 'http';
1450                } elsif($port == 443) {
1451
0
0
                        return 'https';
1452                }
1453        }
1454
1455
10
21
        if($ENV{'REMOTE_ADDR'}) {
1456
0
0
                $self->_warn("Can't determine the calling protocol");
1457        }
1458
10
27
        return;
1459}
1460
1461 - 1486
=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
1487
1488sub tmpdir {
1489
1511
3383
        my $self = shift;
1490
1491
1511
1087
        my $name = 'tmp';
1492
1511
1947
        if($^O eq 'MSWin32') {
1493
0
0
                $name = 'temp';
1494        }
1495
1496
1511
862
        my $dir;
1497
1498
1511
1436
        if(!ref($self)) {
1499
3
6
                $self = __PACKAGE__->new();
1500        }
1501
1511
1724
        my $params = Params::Get::get_params(undef, @_);
1502
1503
1511
15072
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1504
5
21
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1505
5
41
                if((-d $dir) && (-w $dir)) {
1506
2
5
                        return $self->_untaint_filename({ filename => $dir });
1507                }
1508
3
4
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1509
3
20
                if((-d $dir) && (-w $dir)) {
1510
3
13
                        return $self->_untaint_filename({ filename => $dir });
1511                }
1512        }
1513
1506
1488
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1514
1
8
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1515
1
14
                if((-d $dir) && (-w $dir)) {
1516
0
0
                        return $self->_untaint_filename({ filename => $dir });
1517                }
1518        }
1519
1506
2539
        if($params->{'default'} && ref($params->{'default'})) {
1520
606
5712
                croak(ref($self), ': tmpdir must be given a scalar');
1521        }
1522
900
3731
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1523}
1524
1525 - 1537
=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
1538
1539sub rootdir {
1540
14
896
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1541
1
2
                return $ENV{'C_DOCUMENT_ROOT'};
1542        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1543
2
3
                return $ENV{'DOCUMENT_ROOT'};
1544        }
1545
11
9
        my $script_name = $0;
1546
1547
11
27
        unless(File::Spec->file_name_is_absolute($script_name)) {
1548
11
64
                $script_name = File::Spec->rel2abs($script_name);
1549        }
1550
11
13
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1551
0
0
                $script_name =~ s/.cgi\-bin.*//;
1552        }
1553
11
46
        if(-f $script_name) {   # More kludge
1554
11
18
                if($^O eq 'MSWin32') {
1555
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1556
0
0
                                return $1;
1557                        }
1558                } else {
1559
11
33
                        if($script_name =~ /(.+)\/.+?$/) {
1560
11
21
                                return $1;
1561                        }
1562                }
1563        }
1564
0
0
        return $script_name;
1565}
1566
1567 - 1571
=head2 root_dir

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

=cut
1572
1573sub root_dir
1574{
1575
4
509
        if($_[0] && ref($_[0])) {
1576
2
1
                my $self = shift;
1577
1578
2
2
                return $self->rootdir(@_);
1579        }
1580
2
3
        return __PACKAGE__->rootdir(@_);
1581}
1582
1583 - 1587
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1588
1589sub documentroot
1590{
1591
3
10
        if($_[0] && ref($_[0])) {
1592
1
1
                my $self = shift;
1593
1594
1
2
                return $self->rootdir(@_);
1595        }
1596
2
2
        return __PACKAGE__->rootdir(@_);
1597}
1598
1599 - 1611
=head2 logdir($dir)

Gets and sets the name of a directory where you can store logs.

=over 4

=item $dir

Path to the directory where logs will be stored.

=back

=cut
1612
1613sub logdir {
1614
5
1354
        my $self = shift;
1615
5
5
        my $dir = shift;
1616
1617
5
10
        if(!ref($self)) {
1618
1
3
                $self = __PACKAGE__->new();
1619        }
1620
1621
5
6
        if($dir) {
1622
2
36
                if(length($dir) && (-d $dir) && (-w $dir)) {
1623
1
18
                        return $self->{'logdir'} = $dir;
1624                }
1625
1
3
                $self->_warn("Invalid logdir: $dir");
1626
1
17
                Carp::croak("Invalid logdir: $dir");
1627        }
1628
1629
3
12
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1630
9
48
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1631
3
1
                        $dir = $rc;
1632
3
3
                        last;
1633                }
1634        }
1635
3
7
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1636
3
5
        $self->{logdir} ||= $dir;
1637
1638
3
7
        return $dir;
1639}
1640
1641 - 1656
=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
1657
1658sub is_robot {
1659
22
370
        my $self = shift;
1660
1661
22
34
        if(defined($self->{is_robot})) {
1662
3
5
                return $self->{is_robot};
1663        }
1664
1665
19
19
        my $agent = $ENV{'HTTP_USER_AGENT'};
1666
19
20
        my $remote = $ENV{'REMOTE_ADDR'};
1667
1668
19
47
        unless($remote && $agent) {
1669                # Probably not running in CGI - assume real person
1670
9
14
                return 0;
1671        }
1672
1673        # See also params()
1674
10
85
        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/)) {
1675
1
2
                $self->status(403);
1676
1
1
                $self->{is_robot} = 1;
1677
1
3
                if($ENV{'REMOTE_ADDR'}) {
1678
1
4
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1679                } else {
1680
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1681                }
1682
1
2
                return 1;
1683        }
1684
9
437
        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) {
1685
3
4
                $self->{is_robot} = 1;
1686
3
9
                return 1;
1687        }
1688
1689        # TODO:
1690        # Download and use list from
1691        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1692
1693
6
6
        my $key = "$remote/$agent";
1694
1695
6
12
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1696                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1697
2
7
                my @crawler_lists = (
1698                        'http://fix-website-errors.com',
1699                        'http://keywords-monitoring-your-success.com',
1700                        'http://free-video-tool.com',
1701                        'http://magnet-to-torrent.com',
1702                        'http://torrent-to-magnet.com',
1703                        'http://dogsrun.net',
1704                        'http://###.responsive-test.net',
1705                        'http://uptime.com',
1706                        'http://uptimechecker.com',
1707                        'http://top1-seo-service.com',
1708                        'http://fast-wordpress-start.com',
1709                        'http://wordpress-crew.net',
1710                        'http://dbutton.net',
1711                        'http://justprofit.xyz',
1712                        'http://video--production.com',
1713                        'http://buttons-for-website.com',
1714                        'http://buttons-for-your-website.com',
1715                        'http://success-seo.com',
1716                        'http://videos-for-your-business.com',
1717                        'http://semaltmedia.com',
1718                        'http://dailyrank.net',
1719                        'http://uptimebot.net',
1720                        'http://sitevaluation.org',
1721                        'http://100dollars-seo.com',
1722                        'http://forum69.info',
1723                        'http://partner.semalt.com',
1724                        'http://best-seo-offer.com',
1725                        'http://best-seo-solution.com',
1726                        'http://semalt.semalt.com',
1727                        'http://semalt.com',
1728                        'http://7makemoneyonline.com',
1729                        'http://anticrawler.org',
1730                        'http://baixar-musicas-gratis.com',
1731                        'http://descargar-musica-gratis.net',
1732
1733                        # Mine
1734                        'http://www.seokicks.de/robot.html',
1735                );
1736
2
2
                $referrer =~ s/\\/_/g;
1737
2
3
6
42
                if(($referrer =~ /\)/) || (List::Util::any { $_ =~ /^$referrer/ } @crawler_lists)) {
1738
2
6
                        $self->_debug("is_robot: blocked trawler $referrer");
1739
1740
2
7
                        if($self->{cache}) {
1741
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1742                        }
1743
2
2
                        $self->{is_robot} = 1;
1744
2
5
                        return 1;
1745                }
1746        }
1747
1748
4
11
        if(defined($remote) && $self->{cache}) {
1749
0
0
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1750
0
0
                        return $self->{is_robot} = ($type eq 'robot');
1751                }
1752        }
1753
1754        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1755        # that is easily spoofed
1756
4
13
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1757                # Mark Facebook as a search engine, not a robot
1758
0
0
                if($self->{cache}) {
1759
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1760                }
1761
0
0
                return 0;
1762        }
1763
1764
4
7
        unless($self->{browser_detect}) {
1765
3
3
3
12
                if(eval { require HTTP::BrowserDetect; }) {
1766
3
6
                        HTTP::BrowserDetect->import();
1767
3
5
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1768                }
1769        }
1770
4
250
        if($self->{browser_detect}) {
1771
4
5
                my $is_robot = $self->{browser_detect}->robot();
1772
4
351
                if(defined($is_robot)) {
1773
2
6
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1774                }
1775
4
61
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1776
4
14
                $self->_debug("is_robot: $is_robot");
1777
1778
4
57
                if($is_robot) {
1779
2
2
                        if($self->{cache}) {
1780
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1781                        }
1782
2
2
                        $self->{is_robot} = $is_robot;
1783
2
5
                        return $is_robot;
1784                }
1785        }
1786
1787
2
2
        if($self->{cache}) {
1788
0
0
                $self->{cache}->set($key, 'unknown', '1 day');
1789        }
1790
2
2
        $self->{is_robot} = 0;
1791
2
6
        return 0;
1792}
1793
1794 - 1806
=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
1807
1808sub is_search_engine
1809{
1810
29
536
        my $self = shift;
1811
1812
29
37
        if(defined($self->{is_search_engine})) {
1813
6
13
                return $self->{is_search_engine};
1814        }
1815
1816
23
29
        if($ENV{'IS_SEARCH_ENGINE'}) {
1817
1
2
                return $ENV{'IS_SEARCH_ENGINE'}
1818        }
1819
1820
22
22
        my $remote = $ENV{'REMOTE_ADDR'};
1821
22
18
        my $agent = $ENV{'HTTP_USER_AGENT'};
1822
1823
22
40
        unless($remote && $agent) {
1824                # Probably not running in CGI - assume not a search engine
1825
10
15
                return 0;
1826        }
1827
1828
12
6
        my $key;
1829
1830
12
18
        if($self->{cache}) {
1831
0
0
                $key = "$remote/$agent";
1832
0
0
                if(defined($remote) && $self->{cache}) {
1833
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1834
0
0
                                return $self->{is_search} = ($type eq 'search');
1835                        }
1836                }
1837        }
1838
1839        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1840        # that is easily spoofed
1841
12
31
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1842                # Mark Facebook as a search engine, not a robot
1843
0
0
                if($self->{cache}) {
1844
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1845                }
1846
0
0
                return 1;
1847        }
1848
1849
12
15
        unless($self->{browser_detect}) {
1850
8
8
2
414
                if(eval { require HTTP::BrowserDetect; }) {
1851
8
7936
                        HTTP::BrowserDetect->import();
1852
8
10
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1853                }
1854        }
1855
12
613
        if(my $browser = $self->{browser_detect}) {
1856
12
34
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1857
12
1822
                if(!$is_search) {
1858
6
21
                        if(($agent =~ /SeznamBot\//) ||
1859                           ($agent =~ /Google-InspectionTool\//) ||
1860                           ($agent =~ /Googlebot\//)) {
1861
1
1
                                $is_search = 1;
1862                        }
1863                }
1864
12
20
                if($is_search && $self->{cache}) {
1865
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1866                }
1867
12
32
                return $self->{is_search_engine} = $is_search;
1868        }
1869
1870        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1871
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1872
1873
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1874
1875
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1876           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1877
0
0
                if($self->{cache}) {
1878
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1879                }
1880
0
0
                $self->{is_search_engine} = 1;
1881
0
0
                return 1;
1882        }
1883
1884
0
0
        $self->{is_search_engine} = 0;
1885
0
0
        return 0;
1886}
1887
1888 - 1910
=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
1911
1912sub browser_type {
1913
22
18
        my $self = shift;
1914
1915
22
32
        if($self->is_mobile()) {
1916
8
41
                return 'mobile';
1917        }
1918
14
24
        if($self->is_search_engine()) {
1919
6
17
                return 'search';
1920        }
1921
8
20
        if($self->is_robot()) {
1922
3
8
                return 'robot';
1923        }
1924
5
13
        return 'web';
1925}
1926
1927 - 1942
=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
1943
1944sub get_cookie {
1945
13
364
        my $self = shift;
1946
1947
13
13
        return $self->cookie(\@_);
1948}
1949
1950 - 1994
=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
1995
1996sub cookie
1997{
1998
1498
3478
        my $self = shift;
1999
1498
1989
        my $params = Params::Validate::Strict::validate_strict({
2000                args => Params::Get::get_params('cookie_name', @_),
2001                schema => {
2002                        cookie_name => {
2003                                'type' => 'string',
2004                                'min' => 1,
2005                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
2006                        }
2007                }
2008        });
2009
2010
413
35052
        my $field = $params->{'cookie_name'};
2011
2012        # Validate field argument
2013
413
392
        if(!defined($field)) {
2014
93
128
                $self->_error('what cookie do you want?');
2015
93
937
                Carp::croak('what cookie do you want?');
2016
0
0
                return;
2017        }
2018
320
253
        if(ref($field)) {
2019
0
0
                $self->_error('Cookie name should be a string');
2020
0
0
                Carp::croak('Cookie name should be a string');
2021
0
0
                return;
2022        }
2023
2024        # Load cookies if not already loaded
2025
320
275
        unless($self->{jar}) {
2026
10
11
                if(defined $ENV{'HTTP_COOKIE'}) {
2027
8
19
13
24
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
2028                }
2029        }
2030
2031        # Return the cookie value if it exists, otherwise return undef
2032
320
678
        return $self->{jar}{$field};
2033}
2034
2035 - 2050
=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
2051
2052sub status
2053{
2054
91
4133
        my $self = shift;
2055
91
59
        my $status = shift;
2056
2057        # Set status if provided
2058
91
115
        return $self->{status} = $status if(defined($status));
2059
2060        # Determine status based on request method if status is not set
2061
32
50
        unless (defined $self->{status}) {
2062
13
18
                my $method = $ENV{'REQUEST_METHOD'};
2063
2064
13
45
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
2065
9
25
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
2066
2067
7
26
                return 200;
2068        }
2069
2070        # Return current status or 200 by default
2071
19
54
        return $self->{status} || 200;
2072}
2073
2074 - 2086
=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
2087
2088sub messages
2089{
2090
8
2939
        my $self = shift;
2091
2092
8
23
        return $self->{'messages'};
2093}
2094
2095 - 2099
=head2  messages_as_string

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

=cut
2100
2101sub messages_as_string
2102{
2103
3
3
        my $self = shift;
2104
2105
3
6
        if(scalar($self->{'messages'})) {
2106
1
2
1
1
2
2
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2107
1
3
                return join('; ', @messages);
2108        }
2109
2
4
        return '';
2110}
2111
2112 - 2131
=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
2132
2133sub cache
2134{
2135
4
30
        my $self = shift;
2136
4
5
        my $cache = shift;
2137
2138
4
6
        if($cache) {
2139
0
0
                croak(ref($self), ':cache($cache) is not an object') if(!Scalar::Util::blessed($cache));
2140
0
0
                $self->{'cache'} = $cache;
2141        }
2142
4
6
        return $self->{'cache'};
2143}
2144
2145 - 2152
=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
2153
2154sub set_logger
2155{
2156
6
34
        my $self = shift;
2157
6
13
        my $params = Params::Get::get_params('logger', @_);
2158
2159
6
85
        if(my $logger = $params->{'logger'}) {
2160
6
14
                if(Scalar::Util::blessed($logger)) {
2161
4
7
                        $self->{'logger'} = $logger;
2162                } else {
2163
2
4
                        $self->{'logger'} = Log::Abstraction->new($logger);
2164                }
2165        } else {
2166
0
0
                $self->{'logger'} = Log::Abstraction->new();
2167        }
2168
6
48
        return $self;
2169}
2170
2171# Log and remember a message
2172sub _log
2173{
2174
1050
1012
        my ($self, $level, @messages) = @_;
2175
2176
1050
920
        if(scalar(@messages)) {
2177                # FIXME: add caller's function
2178                # if(($level eq 'warn') || ($level eq 'info')) {
2179
1050
1050
550
2439
                        push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2180                # }
2181
2182
1050
1660
                if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2183
1050
2015
                        $self->{'logger'}->$level(join('', grep defined, @messages));
2184                }
2185        }
2186}
2187
2188sub _debug {
2189
135
72
        my $self = shift;
2190
135
139
        $self->_log('debug', @_);
2191}
2192
2193sub _info {
2194
35
18
        my $self = shift;
2195
35
33
        $self->_log('info', @_);
2196}
2197
2198sub _notice {
2199
0
0
        my $self = shift;
2200
0
0
        $self->_log('notice', @_);
2201}
2202
2203sub _trace {
2204
743
426
        my $self = shift;
2205
743
748
        $self->_log('trace', @_);
2206}
2207
2208# Emit a warning message somewhere
2209sub _warn {
2210
44
31
        my $self = shift;
2211
44
62
        my $params = Params::Get::get_params('warning', @_);
2212
2213
44
515
        $self->_log('warn', $params->{'warning'});
2214
29
2874
        if(!defined($self->{'logger'})) {
2215
0
0
                Carp::carp($params->{'warning'});
2216        }
2217}
2218
2219# Emit an error message somewhere
2220sub _error {
2221
93
57
        my $self = shift;
2222
93
88
        my $params = Params::Get::get_params('warning', @_);
2223
2224
93
729
        $self->_log('error', $params->{'warning'});
2225
93
20210
        if(!defined($self->{'logger'})) {
2226
0
0
                Carp::croak($params->{'warning'});
2227        }
2228}
2229
2230# Ensure all environment variables are sanitized and validated before use.
2231# Use regular expressions to enforce strict input formats.
2232sub _get_env
2233{
2234
120
108
        my ($self, $var) = @_;
2235
2236
120
276
        return unless defined $ENV{$var};
2237
2238        # Strict sanitization: allow alphanumeric and limited special characters
2239
63
130
        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2240
63
97
                return $ENV{$var};
2241        }
2242
0
0
        $self->_warn("Invalid value in environment variable: $var");
2243
2244
0
0
        return undef;
2245}
2246
2247 - 2253
=head2 reset

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

=cut
2254
2255sub reset {
2256
13
7874
        my $class = shift;
2257
2258
13
19
        unless($class eq __PACKAGE__) {
2259
1
9
                carp('Reset is a class method');
2260
0
0
                return;
2261        }
2262
2263
12
12
        $stdin_data = undef;
2264}
2265
2266sub AUTOLOAD
2267{
2268
243
52979
        our $AUTOLOAD;
2269
2270
243
336
        my $self = shift or return;
2271
2272
243
246
        return if(!defined($AUTOLOAD));
2273
2274        # Extract the method name from the AUTOLOAD variable
2275
242
836
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2276
2277        # Skip if called on destruction
2278
242
665
        return if($method eq 'DESTROY');
2279
2280
8
7
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2281
2282        # Allow the AUTOLOAD feature to be disabled
2283
8
23
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2284
2285        # Ensure the method is called on the correct package object or a subclass
2286
7
14
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2287
2288        # Validate method name - only allow safe parameter names
2289
7
15
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2290
2291        # Delegate to the param method
2292
7
10
        return $self->param($method);
2293}
2294
2295 - 2381
=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
2382
23831;