File Coverage

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

linestmtbrancondsubtimecode
1package CGI::Info;
2
3# TODO: remove the expect argument
4# TODO: look into params::check or params::validate
5
6
27
27
27
1915792
22
608
use warnings;
7
27
27
27
43
19
234
use strict;
8
9
27
27
27
4228
11734
43
use boolean;
10
27
27
27
771
31
568
use Carp;
11
27
27
27
5360
1207960
426
use Object::Configure 0.12;
12
27
27
27
82
18
330
use File::Spec;
13
27
27
27
61
151
254
use Log::Abstraction 0.10;
14
27
27
27
46
123
383
use Params::Get 0.13;
15
27
27
27
35
113
275
use Params::Validate::Strict 0.11;
16
27
27
27
6011
68673
682
use Net::CIDR;
17
27
27
27
76
20
334
use Return::Set;
18
27
27
27
41
14
264
use Scalar::Util;
19
27
27
27
31
19
4379
use Socket;     # For AF_INET
20
27
27
208
54
use 5.008;
21
27
27
27
5376
94382
62
use Log::Any qw($log);
22# use Cwd;
23# use JSON::Parse;
24
27
27
27
23930
21
192
use List::Util ();      # Can go when expect goes
25# use Sub::Private;
26
27
27
27
4892
335325
375
use Sys::Path;
27
28
27
27
27
5093
155922
77
use namespace::clean;
29
30sub _sanitise_input($);
31
32 - 40
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.07

=cut
41
42our $VERSION = '1.07';
43
44 - 145
=head1 SYNOPSIS

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

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

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

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

    use CGI::Info;

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

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

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

=head1 SUBROUTINES/METHODS

=head2 new

Creates a CGI::Info object.

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

It takes other optional parameters:

=over 4

=item * C<auto_load>

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

=item * C<config_dirs>

Where to look for C<config_file>

=item * C<config_file>

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

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

  export CGI::Info::max_upload_size=65536

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

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

=item * C<syslog>

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

=item * C<cache>

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

=item * C<max_upload_size>

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

=back

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

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

=cut
214
215sub script_name
216{
217
22
814
        my $self = shift;
218
219
22
30
        unless($self->{script_name}) {
220
15
19
                $self->_find_paths();
221        }
222
22
50
        return $self->{script_name};
223}
224
225sub _find_paths {
226
23
16
        my $self = shift;
227
228
23
34
        if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
229
0
0
                Carp::croak('Illegal Operation: This method can only be called by a subclass or ourself');
230        }
231
232
23
207
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
233
234
23
470
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
235
236        # Determine script name
237
23
29
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
238
23
374
        $self->{script_name} = $self->_untaint_filename({
239                filename => File::Basename::basename($script_name)
240        });
241
242        # Determine script path
243
23
28
        if(my $script_path = $self->_get_env('SCRIPT_FILENAME')) {
244
2
2
                $self->{script_path} = $script_path;
245        } elsif($script_name = $self->_get_env('SCRIPT_NAME')) {
246
12
11
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
247
6
6
                        $script_name = $self->_get_env('SCRIPT_NAME');
248
249                        # It's usually the case, e.g. /cgi-bin/foo.pl
250
6
5
                        $script_name =~ s{^/}{};
251
252
6
27
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
253                } else {
254
6
35
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
255                                # Called from a command line with a full path
256
1
2
                                $self->{script_path} = $script_name;
257                        } else {
258
5
24
                                require Cwd unless Cwd->can('abs_path');
259
260
5
9
                                if($script_name =~ /^\/(.+)/) {
261                                        # It's usually the case, e.g. /cgi-bin/foo.pl
262
2
2
                                        $script_name = $1;
263                                }
264
265
5
37
                                $self->{script_path} = File::Spec->catfile(Cwd::abs_path(), $script_name);
266                        }
267                }
268        } elsif(File::Spec->file_name_is_absolute($0)) {
269                # Called from a command line with a full path
270
0
0
                $self->{script_path} = $0;
271        } else {
272
9
104
                $self->{script_path} = File::Spec->rel2abs($0);
273        }
274
275        # Untaint and finalize script path
276        $self->{script_path} = $self->_untaint_filename({
277                filename => $self->{script_path}
278
23
39
        });
279}
280
281 - 298
=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
299
300sub script_path {
301
26
3957
        my $self = shift;
302
303
26
32
        unless($self->{script_path}) {
304
6
6
                $self->_find_paths();
305        }
306
26
92
        return $self->{script_path};
307}
308
309 - 323
=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
324
325sub script_dir
326{
327
14
11
        my $self = shift;
328
329        # Ensure $self is an object
330
14
17
        $self = __PACKAGE__->new() unless ref $self;
331
332        # Set script path if it is not already defined
333
14
30
        $self->_find_paths() unless $self->{script_path};
334
335        # Extract directory from script path based on OS
336        # Don't use File::Spec->splitpath() since that can leave the trailing slash
337
14
25
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
338
339
14
105
        return $self->{script_path} =~ $dir_regex ? $1 : $self->{script_path};
340}
341
342 - 361
=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
362
363sub host_name {
364
10
747
        my $self = shift;
365
366
10
15
        unless($self->{site}) {
367
3
7
                $self->_find_site_details();
368        }
369
370
10
63
        return $self->{site};
371}
372
373sub _find_site_details
374{
375
10
9
        my $self = shift;
376
377        # Log entry to the routine
378
10
15
        $self->_trace('Entering _find_site_details');
379
380
10
142
        return if $self->{site} && $self->{cgi_site};
381
382        # Determine cgi_site using environment variables or hostname
383
8
31
        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
384                # Import necessary module
385
5
209
                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
386
387
5
924
                $self->{cgi_site} = URI::Heuristic::uf_uristr($host);
388                # Remove trailing dots from the name.  They are legal in URLs
389                # and some sites link using them to avoid spoofing (nice)
390
5
51
                $self->{cgi_site} =~ s/(.*)\.+$/$1/;  # Trim trailing dots
391
392
5
13
                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
393
1
2
                        $self->{cgi_site} =~ s/^http/$protocol/;
394                }
395        } else {
396                # Import necessary module
397
3
15
                require Sys::Hostname unless Sys::Hostname->can('hostname');
398
399
3
7
                $self->_debug('Falling back to using hostname');
400
3
31
                $self->{cgi_site} = Sys::Hostname::hostname();
401        }
402
403        # Set site details if not already defined
404
8
31
        $self->{site} ||= $self->{cgi_site};
405
8
17
        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
406        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
407
8
20
                unless $self->{cgi_site} =~ /^https?:\/\//;
408
409        # Warn if site details could not be determined
410
8
20
        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
411
412        # Log exit
413
8
9
        $self->_trace('Leaving _find_site_details');
414}
415
416 - 423
=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
424
425sub domain_name {
426
7
154
        my $self = shift;
427
428
7
9
        if(!ref($self)) {
429
1
2
                $self = __PACKAGE__->new();
430        }
431
7
13
        return $self->{domain} if $self->{domain};
432
433
4
8
        $self->_find_site_details();
434
435
4
28
        if(my $site = $self->{site}) {
436
4
8
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
437        }
438
439
4
9
        return $self->{domain};
440}
441
442 - 446
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
447
448sub cgi_host_url {
449
7
24
        my $self = shift;
450
451
7
8
        unless($self->{cgi_site}) {
452
3
4
                $self->_find_site_details();
453        }
454
455
7
50
        return $self->{cgi_site};
456}
457
458 - 613
=head2 params

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

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

        ./script.cgi --mobile name=Nigel

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

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

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

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

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


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

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

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

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

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

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

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

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

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

=head3 Validation Subroutine Support

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

=over 4

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

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

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

=back

Basic usage:

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

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

Advanced features:

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

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

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

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

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

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

Returns undef if the requested parameter was not given

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

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

=cut
1291
1292sub is_tablet {
1293
6
24
        my $self = shift;
1294
1295
6
7
        if(defined($self->{is_tablet})) {
1296
1
2
                return $self->{is_tablet};
1297        }
1298
1299
5
159
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1300                # TODO: add others when I see some nice user_agents
1301
1
1
                $self->{is_tablet} = 1;
1302        } else {
1303
4
3
                $self->{is_tablet} = 0;
1304        }
1305
1306
5
11
        return $self->{is_tablet};
1307}
1308
1309 - 1317
=head2 as_string

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

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

=cut
1318
1319sub as_string
1320{
1321
40
7696
        my $self = shift;
1322
1323        # Retrieve object parameters
1324
40
46
        my $params = $self->params() || return '';
1325
30
169
        my $args = Params::Get::get_params(undef, @_);
1326
30
195
        my $rc;
1327
1328
30
33
        if($args->{'raw'}) {
1329                # Raw mode: return key=value pairs without escaping
1330                $rc = join '; ', map {
1331
4
7
                        "$_=" . $params->{$_}
1332
2
2
2
4
                } sort keys %{$params};
1333        } else {
1334                # Escaped mode: escape special characters
1335                $rc = join '; ', map {
1336
42
37
                        my $value = $params->{$_};
1337
1338
42
37
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1339
42
59
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1340
42
73
                        "$_=$value"
1341
28
28
18
40
                } sort keys %{$params};
1342        }
1343
1344
30
71
        $self->_trace("as_string: returning '$rc'") if($rc);
1345
1346
30
420
        return $rc;
1347}
1348
1349 - 1354
=head2 protocol

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

=cut
1355
1356sub protocol {
1357
22
627
        my $self = shift;
1358
1359
22
51
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1360
2
6
                return $1;
1361        }
1362
20
32
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1363
2
7
                return 'http';
1364        }
1365
1366
18
27
        if(my $port = $ENV{'SERVER_PORT'}) {
1367
13
655
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1368
13
33
                        if($name =~ /https?/) {
1369
11
28
                                return $name;
1370                        } elsif($name eq 'www') {
1371                                # e.g. NetBSD and OpenBSD
1372
0
0
                                return 'http';
1373                        }
1374                        # Return an error, maybe missing something
1375                } elsif($port == 80) {
1376                        # e.g. Solaris
1377
0
0
                        return 'http';
1378                } elsif($port == 443) {
1379
0
0
                        return 'https';
1380                }
1381        }
1382
1383
7
16
        if($ENV{'REMOTE_ADDR'}) {
1384
0
0
                $self->_warn("Can't determine the calling protocol");
1385        }
1386
7
18
        return;
1387}
1388
1389 - 1414
=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
1415
1416sub tmpdir {
1417
23
1600
        my $self = shift;
1418
1419
23
22
        my $name = 'tmp';
1420
23
34
        if($^O eq 'MSWin32') {
1421
0
0
                $name = 'temp';
1422        }
1423
1424
23
10
        my $dir;
1425
1426
23
25
        if(!ref($self)) {
1427
3
3
                $self = __PACKAGE__->new();
1428        }
1429
23
39
        my $params = Params::Get::get_params(undef, @_);
1430
1431
23
738
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1432
5
16
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1433
5
32
                if((-d $dir) && (-w $dir)) {
1434
2
3
                        return $self->_untaint_filename({ filename => $dir });
1435                }
1436
3
2
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1437
3
24
                if((-d $dir) && (-w $dir)) {
1438
3
8
                        return $self->_untaint_filename({ filename => $dir });
1439                }
1440        }
1441
18
35
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1442
1
7
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1443
1
3
                if((-d $dir) && (-w $dir)) {
1444
0
0
                        return $self->_untaint_filename({ filename => $dir });
1445                }
1446        }
1447
18
216
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1448}
1449
1450 - 1462
=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
1463
1464sub rootdir {
1465
14
955
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1466
1
2
                return $ENV{'C_DOCUMENT_ROOT'};
1467        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1468
2
4
                return $ENV{'DOCUMENT_ROOT'};
1469        }
1470
11
11
        my $script_name = $0;
1471
1472
11
25
        unless(File::Spec->file_name_is_absolute($script_name)) {
1473
11
69
                $script_name = File::Spec->rel2abs($script_name);
1474        }
1475
11
13
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1476
0
0
                $script_name =~ s/.cgi\-bin.*//;
1477        }
1478
11
50
        if(-f $script_name) {   # More kludge
1479
11
14
                if($^O eq 'MSWin32') {
1480
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1481
0
0
                                return $1;
1482                        }
1483                } else {
1484
11
33
                        if($script_name =~ /(.+)\/.+?$/) {
1485
11
19
                                return $1;
1486                        }
1487                }
1488        }
1489
0
0
        return $script_name;
1490}
1491
1492 - 1496
=head2 root_dir

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

=cut
1497
1498sub root_dir
1499{
1500
4
469
        if($_[0] && ref($_[0])) {
1501
2
2
                my $self = shift;
1502
1503
2
4
                return $self->rootdir(@_);
1504        }
1505
2
3
        return __PACKAGE__->rootdir(@_);
1506}
1507
1508 - 1512
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1513
1514sub documentroot
1515{
1516
3
10
        if($_[0] && ref($_[0])) {
1517
1
1
                my $self = shift;
1518
1519
1
1
                return $self->rootdir(@_);
1520        }
1521
2
2
        return __PACKAGE__->rootdir(@_);
1522}
1523
1524 - 1528
=head2 logdir

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

=cut
1529
1530sub logdir {
1531
5
1337
        my $self = shift;
1532
5
6
        my $dir = shift;
1533
1534
5
8
        if(!ref($self)) {
1535
1
2
                $self = __PACKAGE__->new();
1536        }
1537
1538
5
7
        if($dir) {
1539
2
24
                if(length($dir) && (-d $dir) && (-w $dir)) {
1540
1
4
                        return $self->{'logdir'} = $dir;
1541                }
1542
1
3
                $self->_warn("Invalid logdir: $dir");
1543
1
16
                Carp::croak("Invalid logdir: $dir");
1544        }
1545
1546
3
13
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1547
9
38
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1548
3
3
                        $dir = $rc;
1549
3
2
                        last;
1550                }
1551        }
1552
3
9
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1553
3
5
        $self->{logdir} ||= $dir;
1554
1555
3
8
        return $dir;
1556}
1557
1558 - 1573
=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
1574
1575sub is_robot {
1576
21
371
        my $self = shift;
1577
1578
21
25
        if(defined($self->{is_robot})) {
1579
3
3
                return $self->{is_robot};
1580        }
1581
1582
18
27
        my $agent = $ENV{'HTTP_USER_AGENT'};
1583
18
14
        my $remote = $ENV{'REMOTE_ADDR'};
1584
1585
18
26
        unless($remote && $agent) {
1586                # Probably not running in CGI - assume real person
1587
8
11
                return 0;
1588        }
1589
1590        # See also params()
1591
10
74
        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/)) {
1592
1
2
                $self->status(403);
1593
1
1
                $self->{is_robot} = 1;
1594
1
2
                if($ENV{'REMOTE_ADDR'}) {
1595
1
3
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1596                } else {
1597
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1598                }
1599
1
1
                return 1;
1600        }
1601
9
249
        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) {
1602
3
3
                $self->{is_robot} = 1;
1603
3
6
                return 1;
1604        }
1605
1606        # TODO:
1607        # Download and use list from
1608        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1609
1610
6
6
        my $key = "$remote/$agent";
1611
1612
6
10
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1613                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1614
2
5
                my @crawler_lists = (
1615                        'http://fix-website-errors.com',
1616                        'http://keywords-monitoring-your-success.com',
1617                        'http://free-video-tool.com',
1618                        'http://magnet-to-torrent.com',
1619                        'http://torrent-to-magnet.com',
1620                        'http://dogsrun.net',
1621                        'http://###.responsive-test.net',
1622                        'http://uptime.com',
1623                        'http://uptimechecker.com',
1624                        'http://top1-seo-service.com',
1625                        'http://fast-wordpress-start.com',
1626                        'http://wordpress-crew.net',
1627                        'http://dbutton.net',
1628                        'http://justprofit.xyz',
1629                        'http://video--production.com',
1630                        'http://buttons-for-website.com',
1631                        'http://buttons-for-your-website.com',
1632                        'http://success-seo.com',
1633                        'http://videos-for-your-business.com',
1634                        'http://semaltmedia.com',
1635                        'http://dailyrank.net',
1636                        'http://uptimebot.net',
1637                        'http://sitevaluation.org',
1638                        'http://100dollars-seo.com',
1639                        'http://forum69.info',
1640                        'http://partner.semalt.com',
1641                        'http://best-seo-offer.com',
1642                        'http://best-seo-solution.com',
1643                        'http://semalt.semalt.com',
1644                        'http://semalt.com',
1645                        'http://7makemoneyonline.com',
1646                        'http://anticrawler.org',
1647                        'http://baixar-musicas-gratis.com',
1648                        'http://descargar-musica-gratis.net',
1649
1650                        # Mine
1651                        'http://www.seokicks.de/robot.html',
1652                );
1653
2
2
                $referrer =~ s/\\/_/g;
1654
2
3
6
12
                if(($referrer =~ /\)/) || (List::Util::any { $_ =~ /^$referrer/ } @crawler_lists)) {
1655
2
3
                        $self->_debug("is_robot: blocked trawler $referrer");
1656
1657
2
6
                        if($self->{cache}) {
1658
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1659                        }
1660
2
1
                        $self->{is_robot} = 1;
1661
2
7
                        return 1;
1662                }
1663        }
1664
1665
4
9
        if(defined($remote) && $self->{cache}) {
1666
0
0
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1667
0
0
                        return $self->{is_robot} = ($type eq 'robot');
1668                }
1669        }
1670
1671        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1672        # that is easily spoofed
1673
4
12
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1674                # Mark Facebook as a search engine, not a robot
1675
0
0
                if($self->{cache}) {
1676
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1677                }
1678
0
0
                return 0;
1679        }
1680
1681
4
4
        unless($self->{browser_detect}) {
1682
3
3
1
10
                if(eval { require HTTP::BrowserDetect; }) {
1683
3
6
                        HTTP::BrowserDetect->import();
1684
3
6
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1685                }
1686        }
1687
4
288
        if($self->{browser_detect}) {
1688
4
4
                my $is_robot = $self->{browser_detect}->robot();
1689
4
371
                if(defined($is_robot)) {
1690
2
5
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1691                }
1692
4
49
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1693
4
7
                $self->_debug("is_robot: $is_robot");
1694
1695
4
51
                if($is_robot) {
1696
2
2
                        if($self->{cache}) {
1697
0
0
                                $self->{cache}->set($key, 'robot', '1 day');
1698                        }
1699
2
4
                        $self->{is_robot} = $is_robot;
1700
2
4
                        return $is_robot;
1701                }
1702        }
1703
1704
2
3
        if($self->{cache}) {
1705
0
0
                $self->{cache}->set($key, 'unknown', '1 day');
1706        }
1707
2
3
        $self->{is_robot} = 0;
1708
2
9
        return 0;
1709}
1710
1711 - 1723
=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
1724
1725sub is_search_engine
1726{
1727
28
550
        my $self = shift;
1728
1729
28
35
        if(defined($self->{is_search_engine})) {
1730
6
7
                return $self->{is_search_engine};
1731        }
1732
1733
22
27
        if($ENV{'IS_SEARCH_ENGINE'}) {
1734
1
3
                return $ENV{'IS_SEARCH_ENGINE'}
1735        }
1736
1737
21
18
        my $remote = $ENV{'REMOTE_ADDR'};
1738
21
20
        my $agent = $ENV{'HTTP_USER_AGENT'};
1739
1740
21
32
        unless($remote && $agent) {
1741                # Probably not running in CGI - assume not a search engine
1742
9
12
                return 0;
1743        }
1744
1745
12
8
        my $key;
1746
1747
12
10
        if($self->{cache}) {
1748
0
0
                $key = "$remote/$agent";
1749
0
0
                if(defined($remote) && $self->{cache}) {
1750
0
0
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1751
0
0
                                return $self->{is_search} = ($type eq 'search');
1752                        }
1753                }
1754        }
1755
1756        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1757        # that is easily spoofed
1758
12
39
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1759                # Mark Facebook as a search engine, not a robot
1760
0
0
                if($self->{cache}) {
1761
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1762                }
1763
0
0
                return 1;
1764        }
1765
1766
12
14
        unless($self->{browser_detect}) {
1767
8
8
5
425
                if(eval { require HTTP::BrowserDetect; }) {
1768
8
7821
                        HTTP::BrowserDetect->import();
1769
8
12
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1770                }
1771        }
1772
12
602
        if(my $browser = $self->{browser_detect}) {
1773
12
13
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1774
12
1828
                if(!$is_search) {
1775
6
17
                        if(($agent =~ /SeznamBot\//) ||
1776                           ($agent =~ /Google-InspectionTool\//) ||
1777                           ($agent =~ /Googlebot\//)) {
1778
1
1
                                $is_search = 1;
1779                        }
1780                }
1781
12
20
                if($is_search && $self->{cache}) {
1782
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1783                }
1784
12
29
                return $self->{is_search_engine} = $is_search;
1785        }
1786
1787        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1788
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1789
1790
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1791
1792
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1793           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1794
0
0
                if($self->{cache}) {
1795
0
0
                        $self->{cache}->set($key, 'search', '1 day');
1796                }
1797
0
0
                $self->{is_search_engine} = 1;
1798
0
0
                return 1;
1799        }
1800
1801
0
0
        $self->{is_search_engine} = 0;
1802
0
0
        return 0;
1803}
1804
1805 - 1827
=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
1828
1829sub browser_type {
1830
21
21
        my $self = shift;
1831
1832
21
26
        if($self->is_mobile()) {
1833
8
24
                return 'mobile';
1834        }
1835
13
19
        if($self->is_search_engine()) {
1836
6
15
                return 'search';
1837        }
1838
7
11
        if($self->is_robot()) {
1839
3
7
                return 'robot';
1840        }
1841
4
8
        return 'web';
1842}
1843
1844 - 1859
=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
1860
1861sub get_cookie {
1862
13
336
        my $self = shift;
1863
13
16
        my $params = Params::Get::get_params('cookie_name', @_);
1864
1865        # Validate field argument
1866
12
130
        if(!defined($params->{'cookie_name'})) {
1867
2
4
                $self->_warn('cookie_name argument not given');
1868
2
4
                return;
1869        }
1870
1871        # Load cookies if not already loaded
1872
10
11
        unless($self->{jar}) {
1873
4
4
                if(defined $ENV{'HTTP_COOKIE'}) {
1874
3
11
6
14
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
1875                }
1876        }
1877
1878        # Return the cookie value if it exists, otherwise return undef
1879
10
24
        return $self->{jar}->{$params->{'cookie_name'}};
1880}
1881
1882 - 1894
=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";

=cut
1895
1896sub cookie {
1897
9
972
        my ($self, $field) = @_;
1898
1899        # Validate field argument
1900
9
10
        if(!defined($field)) {
1901
1
2
                $self->_warn('what cookie do you want?');
1902
1
2
                return;
1903        }
1904
1905        # Load cookies if not already loaded
1906
8
8
        unless($self->{jar}) {
1907
4
4
                if(defined $ENV{'HTTP_COOKIE'}) {
1908
4
6
6
9
                        $self->{jar} = { map { split(/=/, $_, 2) } split(/; /, $ENV{'HTTP_COOKIE'}) };
1909                }
1910        }
1911
1912        # Return the cookie value if it exists, otherwise return undef
1913
8
18
        return $self->{jar}{$field};
1914}
1915
1916 - 1922
=head2 status

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

=cut
1923
1924sub status
1925{
1926
91
3754
        my $self = shift;
1927
91
62
        my $status = shift;
1928
1929        # Set status if provided
1930
91
113
        return $self->{status} = $status if(defined($status));
1931
1932        # Determine status based on request method if status is not set
1933
32
44
        unless (defined $self->{status}) {
1934
13
15
                my $method = $ENV{'REQUEST_METHOD'};
1935
1936
13
29
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
1937
9
22
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
1938
1939
7
22
                return 200;
1940        }
1941
1942        # Return current status or 200 by default
1943
19
50
        return $self->{status} || 200;
1944}
1945
1946 - 1958
=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
1959
1960sub messages
1961{
1962
7
2857
        my $self = shift;
1963
1964
7
19
        return $self->{'messages'};
1965}
1966
1967 - 1971
=head2  messages_as_string

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

=cut
1972
1973sub messages_as_string
1974{
1975
2
2
        my $self = shift;
1976
1977
2
4
        if(scalar($self->{'messages'})) {
1978
1
2
1
1
2
2
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
1979
1
4
                return join('; ', @messages);
1980        }
1981
1
2
        return '';
1982}
1983
1984 - 1993
=head2 cache

Get/set the internal cache system.

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

=cut
1994
1995sub cache
1996{
1997
4
22
        my $self = shift;
1998
4
2
        my $cache = shift;
1999
2000
4
6
        if($cache) {
2001
0
0
                $self->{'cache'} = $cache;
2002        }
2003
4
5
        return $self->{'cache'};
2004}
2005
2006 - 2013
=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
2014
2015sub set_logger
2016{
2017
6
25
        my $self = shift;
2018
6
12
        my $params = Params::Get::get_params('logger', @_);
2019
2020
6
73
        if(my $logger = $params->{'logger'}) {
2021
6
10
                if(Scalar::Util::blessed($logger)) {
2022
4
4
                        $self->{'logger'} = $logger;
2023                } else {
2024
2
4
                        $self->{'logger'} = Log::Abstraction->new($logger);
2025                }
2026        } else {
2027
0
0
                $self->{'logger'} = Log::Abstraction->new();
2028        }
2029
6
42
        return $self;
2030}
2031
2032# Log and remember a message
2033sub _log
2034{
2035
413
408
        my ($self, $level, @messages) = @_;
2036
2037        # FIXME: add caller's function
2038        # if(($level eq 'warn') || ($level eq 'info')) {
2039
413
413
219
891
                push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2040        # }
2041
2042
413
729
        if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2043
413
751
                $self->{'logger'}->$level(join('', grep defined, @messages));
2044        }
2045}
2046
2047sub _debug {
2048
132
90
        my $self = shift;
2049
132
106
        $self->_log('debug', @_);
2050}
2051
2052sub _info {
2053
35
26
        my $self = shift;
2054
35
31
        $self->_log('info', @_);
2055}
2056
2057sub _notice {
2058
0
0
        my $self = shift;
2059
0
0
        $self->_log('notice', @_);
2060}
2061
2062sub _trace {
2063
199
115
        my $self = shift;
2064
199
235
        $self->_log('trace', @_);
2065}
2066
2067# Emit a warning message somewhere
2068sub _warn {
2069
47
40
        my $self = shift;
2070
47
70
        my $params = Params::Get::get_params('warning', @_);
2071
2072
47
527
        $self->_log('warn', $params->{'warning'});
2073
32
2927
        if(!defined($self->{'logger'})) {
2074
0
0
                Carp::carp($params->{'warning'});
2075        }
2076}
2077
2078# Ensure all environment variables are sanitized and validated before use.
2079# Use regular expressions to enforce strict input formats.
2080sub _get_env
2081{
2082
111
79
        my ($self, $var) = @_;
2083
2084
111
197
        return unless defined $ENV{$var};
2085
2086        # Strict sanitization: allow alphanumeric and limited special characters
2087
63
106
        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2088
63
79
                return $ENV{$var};
2089        }
2090
0
0
        $self->_warn("Invalid value in environment variable: $var");
2091
2092
0
0
        return undef;
2093}
2094
2095 - 2101
=head2 reset

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

=cut
2102
2103sub reset {
2104
13
7667
        my $class = shift;
2105
2106
13
17
        unless($class eq __PACKAGE__) {
2107
1
10
                carp('Reset is a class method');
2108
0
0
                return;
2109        }
2110
2111
12
12
        $stdin_data = undef;
2112}
2113
2114sub AUTOLOAD
2115{
2116
241
50223
        our $AUTOLOAD;
2117
2118
241
297
        my $self = shift or return;
2119
2120        # Extract the method name from the AUTOLOAD variable
2121
241
745
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2122
2123        # Skip if called on destruction
2124
241
603
        return if($method eq 'DESTROY');
2125
2126
8
14
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2127
2128        # Allow the AUTOLOAD feature to be disabled
2129
8
513
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2130
2131        # Ensure the method is called on the correct package object or a subclass
2132
7
15
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2133
2134        # Validate method name - only allow safe parameter names
2135
7
18
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2136
2137        # Delegate to the param method
2138
7
12
        return $self->param($method);
2139}
2140
2141 - 2227
=head1 AUTHOR

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

=head1 BUGS

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

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

=head1 SEE ALSO

=over 4

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

=item * L<Object::Configure>

=item * L<HTTP::BrowserDetect>

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

=back

=head1 REPOSITORY

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

=head1 SUPPORT

This module is provided as-is without any warranty.

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

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

    perldoc CGI::Info

You can also look for information at:

=over 4

=item * MetaCPAN

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

=item * RT: CPAN's request tracker

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

=item * CPAN Testers' Matrix

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

=item * CPAN Testers Dependencies

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

=back

=head1 LICENCE AND COPYRIGHT

Copyright 2010-2025 Nigel Horne.

Usage is subject to licence terms.

The licence terms of this software are as follows:

=over 4

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

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

=back

=cut
2228
22291;