File Coverage

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

linestmtbrancondsubtimecode
1package CGI::Info;
2
3
49
49
49
1857061
37
856
use warnings;
4
49
49
49
54
32
376
use strict;
5
49
49
49
3139
136222
146
use autodie qw(:all);
6
7
49
49
210283
125
use 5.010;      # Minimum version for features used here
8
9# Core modules
10
49
49
49
5517
17394
68
use boolean;
11
49
49
49
1098
30
714
use Carp;
12
49
49
49
61
48
213
use List::Util ();      # any() used in is_robot() referrer check
13
49
49
49
4582
41437
823
use Readonly;
14
49
49
49
79
30
491
use Scalar::Util;
15
49
49
49
8124
68854
7503
use Socket;     # AF_INET constant
16
17# CPAN modules
18
49
49
49
9612
1676778
656
use Object::Configure 0.19;
19
49
49
49
121
64
562
use File::Spec;
20
49
49
49
70
236
290
use Log::Abstraction 0.10;
21
49
49
49
8245
99686
1149
use Net::CIDR;
22
49
49
49
138
302
548
use Params::Get 0.13;
23
49
49
49
66
214
438
use Params::Validate::Strict 0.21;
24
49
49
49
62
27
377
use Return::Set;
25
49
49
49
6445
448171
517
use Sys::Path;
26
49
49
49
6252
222329
105
use Sub::Protected;
27
28
49
49
49
12229
210897
132
use namespace::clean;
29
30# ---------------------------------------------------------------------------
31# Module-level constants -- avoids magic numbers scattered through the code
32# ---------------------------------------------------------------------------
33Readonly my $MAX_UPLOAD_SIZE_DEFAULT => 512 * 1024;  # 512 KB default upload cap
34Readonly my $CACHE_TTL_ROBOT         => '1 day';     # TTL for robot-detection cache entries
35Readonly my $CACHE_TTL_SEARCH        => '1 day';     # TTL for search-engine cache entries
36
37sub _sanitise_input;
38
39 - 47
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.13

=cut
48
49our $VERSION = '1.13';
50
51 - 197
=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 in bytes you can upload.
Use C<-1> for no limit.
The default is 512 KB (524288 bytes).

=back

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

=head3 API SPECIFICATION

=head4 INPUT

  {
    allow          => { type => 'hashref',  optional => 1 },
    auto_load      => { type => 'boolean',  optional => 1 },
    cache          => { type => 'object',   optional => 1 },
    carp_on_warn   => { type => 'boolean',  optional => 1 },
    config_dirs    => { type => 'arrayref', optional => 1 },
    config_file    => { type => 'string',   optional => 1 },
    logger         => { type => 'object',   optional => 1 },
    max_upload_size=> { type => 'integer',  optional => 1, min => -1 },
    upload_dir     => { type => 'string',   optional => 1 },
  }

=head4 OUTPUT

  { type => 'object', isa => 'CGI::Info' }

=head3 MESSAGES

=over 4

=item C<< use ->new() not ::new() to instantiate >>

B<Level>: fatal (croak).
B<Cause>: called as C<CGI::Info::new()> (double-colon) instead of C<< CGI::Info->new() >>.
B<Action>: change the call-site to use the arrow notation.

=item C<< Logger must be an object with info() and error() methods >>

B<Level>: fatal (croak).
B<Cause>: the C<logger> argument is not a blessed object, or does not
implement C<info()>, C<warn()>, and C<error()> methods.
B<Action>: pass a compliant logger such as a L<Log::Abstraction>-based object.

=item C<< expect has been deprecated, use allow instead >>

B<Level>: fatal (croak).
B<Cause>: the removed C<expect> parameter was passed to C<new()>.
B<Action>: replace C<expect =E<gt> [...]> with C<allow =E<gt> { key =E<gt> qr/.../ }>.

=back

=cut
198
199our $stdin_data;        # Class variable storing STDIN in case the class
200                        # is instantiated more than once
201
202sub new
203{
204
741
1897931
        my $class = shift;
205
206        # Handle hash or hashref arguments
207
741
962
        my $params = Params::Get::get_params(undef, @_);
208
209
740
6115
        if (defined($class)) {
210
737
1111
                my $is_valid = Scalar::Util::blessed($class) || (eval { $class->isa(__PACKAGE__) });
211
737
816
                unless ($is_valid) {
212                        # Called as CGI::Info::new(...) or similar wrong function call
213
0
0
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
214                }
215        } else {
216                # If class is undef, but there are arguments/params passed
217
3
1
6
2
                if (defined($params) && keys %{$params}) {
218
1
6
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
219                }
220                # Called as CGI::Info::new() with 0 arguments (undef $class)
221
2
2
                $class = __PACKAGE__;
222        }
223
224
739
789
        if(Scalar::Util::blessed($class)) {
225                # If $class is an object, clone it with new arguments
226
8
31
                $params ||= {};
227
228                # Validate any new logger passed to the clone
229
8
12
                if(defined $params->{'logger'}) {
230
0
0
                        unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
231
0
0
                                Carp::croak('Logger must be an object with info() and error() methods');
232                        }
233                }
234
235                # expect is deprecated even when cloning
236
8
19
                if(defined($params->{'expect'})) {
237
0
0
                        my $logger = $params->{'logger'} // $class->{'logger'};
238
0
0
                        $logger->error(ref($class) . ': expect has been deprecated, use allow instead') if $logger;
239
0
0
                        Carp::croak(ref($class) . ': expect has been deprecated, use allow instead');
240                }
241
242                # Drop cached params so a new allow schema is applied on next call
243
8
8
8
12
9
16
                my %merged = (%{$class}, %{$params});
244
8
9
                delete $merged{'paramref'};
245
8
22
                return bless \%merged, ref($class);
246        }
247
248        # Load the configuration from a config file, if provided
249
731
985
        $params = Object::Configure::configure($class, $params);
250
251        # Validate logger object has required methods
252
730
1667191
        if(defined $params->{'logger'}) {
253
730
4194
                unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
254
0
0
                        Carp::croak("Logger must be an object with info() and error() methods");
255                }
256        }
257
258
730
1355
        if(defined($params->{'expect'})) {
259                # if(ref($params->{expect}) ne 'ARRAY') {
260                        # Carp::croak(__PACKAGE__, ': expect must be a reference to an array');
261                # }
262                # # warn __PACKAGE__, ': expect is deprecated, use allow instead';
263
5
9
                if(my $logger = $params->{'logger'}) {
264
5
12
                        $logger->error("$class: expect has been deprecated, use allow instead");
265                }
266
5
1023
                Carp::croak("$class: expect has been deprecated, use allow instead");
267        }
268
269        # Return the blessed object with sensible defaults
270        return bless {
271                max_upload_size => $MAX_UPLOAD_SIZE_DEFAULT,
272                allow           => undef,
273                upload_dir      => undef,
274
725
725
559
1661
                %{$params}      # Caller-supplied args override the defaults above
275        }, $class;
276}
277
278 - 305
=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
306
307sub script_name
308{
309
35
895
        my $self = shift;
310
311
35
42
        unless($self->{script_name}) {
312
25
50
                $self->_find_paths();
313        }
314
35
96
        return $self->{script_name};
315}
316
317sub _find_paths :Protected {
318
0
        my $self = shift;
319
320
0
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
321
322
0
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
323
324        # Determine script name
325
0
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
326
0
        $self->{script_name} = $self->_untaint_filename({
327                filename => File::Basename::basename($script_name)
328        });
329
330        # Determine script path
331
0
        if(my $script_path = $self->_get_env('SCRIPT_FILENAME')) {
332
0
                $self->{script_path} = $script_path;
333        } elsif($script_name = $self->_get_env('SCRIPT_NAME')) {
334
0
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
335
0
                        $script_name = $self->_get_env('SCRIPT_NAME');
336
337                        # It's usually the case, e.g. /cgi-bin/foo.pl
338
0
                        $script_name =~ s{^/}{};
339
340
0
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
341                } else {
342
0
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
343                                # Called from a command line with a full path
344
0
                                $self->{script_path} = $script_name;
345                        } else {
346
0
                                require Cwd unless Cwd->can('abs_path');
347
348
0
                                if($script_name =~ /^\/(.+)/) {
349                                        # It's usually the case, e.g. /cgi-bin/foo.pl
350
0
                                        $script_name = $1;
351                                }
352
353
0
                                $self->{script_path} = File::Spec->catfile(Cwd::abs_path(), $script_name);
354                        }
355                }
356        } elsif(File::Spec->file_name_is_absolute($0)) {
357                # Called from a command line with a full path
358
0
                $self->{script_path} = $0;
359        } else {
360
0
                $self->{script_path} = File::Spec->rel2abs($0);
361        }
362
363        # Untaint and finalize script path
364        $self->{script_path} = $self->_untaint_filename({
365                filename => $self->{script_path}
366
0
        });
367
49
49
49
65002
41
838
}
368
369 - 386
=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
387
388sub script_path {
389
39
2471
        my $self = shift;
390
391
39
54
        unless($self->{script_path}) {
392
15
27
                $self->_find_paths();
393        }
394
39
93
        return $self->{script_path};
395}
396
397 - 411
=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
412
413sub script_dir
414{
415
26
56
        my $self = shift;
416
417        # Ensure $self is an object
418
26
30
        $self = __PACKAGE__->new() unless ref $self;
419
420        # Set script path if it is not already defined
421
26
64
        $self->_find_paths() unless $self->{script_path};
422
423        # Extract directory from script path based on OS
424        # Don't use File::Spec->splitpath() since that can leave the trailing slash
425
26
52
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
426
427
26
155
        return $self->{script_path} =~ $dir_regex ? $1 : $self->{script_path};
428}
429
430 - 449
=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
450
451sub host_name {
452
131
1033
        my $self = shift;
453
454
131
108
        unless($self->{site}) {
455
23
47
                $self->_find_site_details();
456        }
457
458
131
285
        return $self->{site};
459}
460
461sub _find_site_details :Protected {
462        my $self = shift;
463
464        # Log entry to the routine
465        $self->_trace('Entering _find_site_details');
466
467        return if $self->{site} && $self->{cgi_site};
468
469        # Determine cgi_site using environment variables or hostname
470        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
471                # Import necessary module
472                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
473
474                $self->{cgi_site} = URI::Heuristic::uf_uristr($host);
475                # Remove trailing dots from the name.  They are legal in URLs
476                # and some sites link using them to avoid spoofing (nice)
477                $self->{cgi_site} =~ s/\.+$//;       # Trim trailing dots
478
479                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
480                        $self->{cgi_site} =~ s/^http/$protocol/;
481                }
482        } else {
483                # Import necessary module
484                require Sys::Hostname unless Sys::Hostname->can('hostname');
485
486                $self->_debug('Falling back to using hostname');
487                $self->{cgi_site} = Sys::Hostname::hostname();
488        }
489
490        # Set site details if not already defined
491        $self->{site} ||= $self->{cgi_site};
492        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
493        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
494                unless $self->{cgi_site} =~ /^https?:\/\//;
495
496        # Warn if site details could not be determined
497        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
498
499        # Log exit
500        $self->_trace('Leaving _find_site_details');
501
49
49
49
15063
31
297
}
502
503 - 510
=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
511
512sub domain_name {
513
21
182
        my $self = shift;
514
515
21
27
        if(!ref($self)) {
516
3
5
                $self = __PACKAGE__->new();
517        }
518
21
39
        return $self->{domain} if $self->{domain};
519
520
16
27
        $self->_find_site_details();
521
522
16
86
        if(my $site = $self->{site}) {
523
16
35
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
524        }
525
526
16
571
        return $self->{domain};
527}
528
529 - 533
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
534
535sub cgi_host_url {
536
17
58
        my $self = shift;
537
538
17
19
        unless($self->{cgi_site}) {
539
11
17
                $self->_find_site_details();
540        }
541
542
17
106
        return $self->{cgi_site};
543}
544
545 - 700
=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
701
702sub params {
703
1098
9341
        my $self = shift;
704
705
1098
1347
        my $params = Params::Get::get_params(undef, @_);
706
707
1098
7650
        if((defined($self->{paramref})) && ((!defined($params->{'allow'})) || defined($self->{allow}) && ($params->{'allow'} eq $self->{allow}))) {
708
186
173
                return $self->{paramref};
709        }
710
711
912
770
        if(defined($params->{allow})) {
712
61
54
                $self->{allow} = $params->{allow};
713        }
714
912
610
        if(defined($params->{upload_dir})) {
715
6
7
                $self->{upload_dir} = $params->{upload_dir};
716        }
717
912
627
        if(defined($params->{'logger'})) {
718
2
4
                $self->set_logger($params->{'logger'});
719        }
720
912
1203
        $self->_trace('Entering params');
721
722
912
14362
        my @pairs;
723
912
532
        my $content_type = $ENV{'CONTENT_TYPE'};
724
912
499
        my %FORM;
725
726
912
1572
        if((!$ENV{'GATEWAY_INTERFACE'}) || (!$ENV{'REQUEST_METHOD'})) {
727                # require IO::Interactive;
728                # IO::Interactive->import();
729
730
581
534
                if(@ARGV) {
731
37
112
                        @pairs = @ARGV;
732
37
40
                        if(defined($pairs[0])) {
733
37
73
                                if($pairs[0] eq '--robot') {
734
4
4
                                        $self->{is_robot} = 1;
735
4
17
                                        shift @pairs;
736                                } elsif($pairs[0] eq '--mobile') {
737
5
7
                                        $self->{is_mobile} = 1;
738
5
5
                                        shift @pairs;
739                                } elsif($pairs[0] eq '--search-engine') {
740
4
7
                                        $self->{is_search_engine} = 1;
741
4
3
                                        shift @pairs;
742                                } elsif($pairs[0] eq '--tablet') {
743
4
5
                                        $self->{is_tablet} = 1;
744
4
5
                                        shift @pairs;
745                                }
746                        }
747                } elsif($stdin_data) {
748                        # Re-use previously read STDIN (class variable shared across instances)
749
0
0
                        @pairs = split(/\n/, $stdin_data);
750                }
751        } elsif(($ENV{'REQUEST_METHOD'} eq 'GET') || ($ENV{'REQUEST_METHOD'} eq 'HEAD')) {
752
251
270
                if(my $query = $ENV{'QUERY_STRING'}) {
753
243
260
                        if((defined($content_type)) && ($content_type =~ /multipart\/form-data/i)) {
754
2
3
                                if($ENV{'REMOTE_ADDR'}) {
755
1
3
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Multipart/form-data not supported for GET (query string = $query)" });
756                                } else {
757
1
1
                                        $self->_warn('Multipart/form-data not supported for GET');
758                                }
759
1
2
                                $self->status(501);  # Not implemented
760
1
1
                                return;
761                        }
762
241
220
                        $query =~ s/\\u0026/\&/g;
763
241
283
                        @pairs = split(/&/, $query);
764                } else {
765
8
18
                        return;
766                }
767        } elsif($ENV{'REQUEST_METHOD'} eq 'POST') {
768
66
104
                my $content_length = $self->_get_env('CONTENT_LENGTH');
769
66
152
                if((!defined($content_length)) || ($content_length =~ /\D/)) {
770
10
9
                        $self->{status} = 411;
771
10
20
                        return;
772                }
773
56
135
                if(($self->{max_upload_size} >= 0) && ($content_length > $self->{max_upload_size})) {       # Set maximum posts
774                        # TODO: Design a way to tell the caller to send HTTP
775                        # status 413
776
10
12
                        $self->{status} = 413;
777
10
42
                        $self->_warn('Large upload prohibited');
778
10
15
                        return;
779                }
780
781
46
179
                if((!defined($content_type)) || ($content_type =~ /application\/x-www-form-urlencoded/)) {
782
13
10
                        my $buffer;
783
13
13
                        if($stdin_data) {
784
9
9
                                $buffer = $stdin_data;
785                        } else {
786
4
13
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
787
1
450
                                        $self->_warn('POST failed: something else may have read STDIN');
788                                }
789
4
1303
                                $stdin_data = $buffer;
790                        }
791
13
19
                        @pairs = split(/&/, $buffer);
792
793                        # if($ENV{'QUERY_STRING'}) {
794                                # my @getpairs = split(/&/, $ENV{'QUERY_STRING'});
795                                # push(@pairs, @getpairs);
796                        # }
797                } elsif($content_type =~ /multipart\/form-data/i) {
798
22
28
                        if(!defined($self->{upload_dir})) {
799
3
6
                                if($ENV{'REMOTE_ADDR'}) {
800                                        # This could be an attack
801
1
3
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Attempt to upload a file of $content_length bytes when upload_dir has not been set" });
802                                } else {
803
2
9
                                        $self->_warn({ warning => 'Attempt to upload a file when upload_dir has not been set' });
804                                }
805
2
5
                                $self->status(501);  # Not implemented
806
2
3
                                return;
807                        }
808
809                        # Validate 'upload_dir'
810                        # Ensure the upload directory is safe and accessible
811                        # - Check permissions
812                        # - Validate path to prevent directory traversal attacks
813                        # TODO: Consider using a temporary directory for uploads and moving them later
814
19
74
                        if(!File::Spec->file_name_is_absolute($self->{upload_dir})) {
815
4
13
                                $self->_warn({
816                                        warning => "upload_dir $self->{upload_dir} isn't a full pathname"
817                                });
818
3
7
                                $self->status(500);
819
3
2
                                delete $self->{upload_dir};
820
3
5
                                return;
821                        }
822
15
83
                        if(!-d $self->{upload_dir}) {
823
5
16
                                $self->_warn({
824                                        warning => "upload_dir $self->{upload_dir} isn't a directory"
825                                });
826
3
6
                                $self->status(500);
827
3
3
                                delete $self->{upload_dir};
828
3
5
                                return;
829                        }
830
10
33
                        if(!-w $self->{upload_dir}) {
831
2
2
                                delete $self->{paramref};
832
2
6
                                $self->_warn({
833                                        warning => "upload_dir $self->{upload_dir} isn't writeable"
834                                });
835
1
2
                                $self->status(500);
836
1
1
                                delete $self->{upload_dir};
837
1
2
                                return;
838                        }
839
8
18
                        my $tmpdir = $self->tmpdir();
840
8
57
                        if($self->{'upload_dir'} !~ /^\Q$tmpdir\E/) {
841                                $self->_warn({
842
1
3
                                        warning => 'upload_dir ' . $self->{'upload_dir'} . " isn't somewhere in the temporary area $tmpdir"
843                                });
844
1
2
                                $self->status(500);
845
1
0
                                delete $self->{upload_dir};
846
1
2
                                return;
847                        }
848
7
18
                        if($content_type =~ /boundary=(\S+)$/) {
849
7
23
                                @pairs = $self->_multipart_data({
850                                        length => $content_length,
851                                        boundary => $1
852                                });
853                        }
854                } elsif($content_type =~ /text\/xml/i) {
855
6
5
                        my $buffer;
856
6
13
                        if($stdin_data) {
857
5
5
                                $buffer = $stdin_data;
858                        } else {
859
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
860
0
0
                                        $self->_warn({
861                                                warning => 'XML failed: something else may have read STDIN'
862                                        });
863                                }
864
1
472
                                $stdin_data = $buffer;
865                        }
866
867
6
9
                        $FORM{XML} = $buffer;
868
869
6
11
                        $self->{paramref} = \%FORM;
870
871
6
11
                        return \%FORM;
872                } elsif($content_type =~ /application\/json/i) {
873
3
77
                        require JSON::MaybeXS && JSON::MaybeXS->import() unless JSON::MaybeXS->can('parse_json');
874                        # require JSON::MaybeXS;
875                        # JSON::MaybeXS->import();
876
877
3
2
                        my $buffer;
878
879
3
5
                        if($stdin_data) {
880
2
2
                                $buffer = $stdin_data;
881                        } else {
882
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
883
0
0
                                        $self->_warn({
884                                                warning => 'read failed: something else may have read STDIN'
885                                        });
886                                }
887
1
423
                                $stdin_data = $buffer;
888                        }
889                        # JSON::Parse::assert_valid_json($buffer);
890                        # my $paramref = JSON::Parse::parse_json($buffer);
891
3
20
                        my $paramref = decode_json($buffer);
892
3
3
3
4
                        foreach my $key(keys(%{$paramref})) {
893
6
11
                                push @pairs, "$key=" . $paramref->{$key};
894                        }
895                } else {
896
2
2
                        my $buffer;
897
2
2
                        if($stdin_data) {
898
1
1
                                $buffer = $stdin_data;
899                        } else {
900
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
901
0
0
                                        $self->_warn({
902                                                warning => 'read failed: something else may have read STDIN'
903                                        });
904                                }
905
1
28
                                $stdin_data = $buffer;
906                        }
907
908
2
5
                        $self->_warn({
909                                warning => "POST: Invalid or unsupported content type: $content_type: $buffer",
910                        });
911                }
912        } elsif($ENV{'REQUEST_METHOD'} eq 'OPTIONS') {
913
4
5
                $self->{status} = 405;
914
4
9
                return;
915        } elsif($ENV{'REQUEST_METHOD'} eq 'DELETE') {
916
5
6
                $self->{status} = 405;
917
5
9
                return;
918        } else {
919                # TODO: Design a way to tell the caller to send HTTP
920                # status 501
921
5
6
                $self->{status} = 501;
922                $self->_warn({
923                        warning => 'Use POST, GET or HEAD, not ' . $ENV{REQUEST_METHOD}
924
5
15
                });
925        }
926
927
848
613
        unless(scalar @pairs) {
928
557
1341
                return;
929        }
930
931
291
2836
        require String::Clean::XSS;
932
291
115396
        String::Clean::XSS->import();
933        # require String::EscapeCage;
934        # String::EscapeCage->import();
935
936
291
237
        foreach my $arg (@pairs) {
937
1977
1366
                my($key, $value) = split(/=/, $arg, 2);
938
939
1977
1153
                next unless($key);
940
941
1967
888
                $key =~ s/\0//g;        # Strip encoded NUL byte poison
942
1967
780
                $key =~ s/%00//g;       # Strip NUL byte poison
943
1967
1
853
3
                $key =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
944
1967
897
                $key =~ tr/+/ /;
945
1967
1003
                if(defined($value)) {
946
1965
867
                        $value =~ s/%00//g;   # Strip encoded NUL byte poison
947
1965
204
851
239
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;   # URL-decode
948
1965
782
                        $value =~ tr/+/ /;
949
1965
892
                        $value =~ s/\0//g;    # Strip NUL again: %2500 -> %00 -> \0 after second pass
950
1965
873
                        $value =~ s/%00//g;   # Strip literal %00 again: catches %2500 -> %00
951                } else {
952
2
2
                        $value = '';
953                }
954
955
1967
1181
                $key = _sanitise_input($key);
956
957
1967
93882
                if($self->{allow}) {
958                        # Is this a permitted argument?
959
173
162
                        if(!exists($self->{allow}->{$key})) {
960
31
69
                                $self->_info("Discard unallowed argument '$key'");
961
31
302
                                $self->status(422);
962
31
24
                                next;   # Skip to the next parameter
963                        }
964
965                        # Do we allow any value, or must it be validated?
966
142
140
                        if(defined(my $schema = $self->{allow}->{$key})) {        # Get the schema for this key
967
124
175
                                if(!ref($schema)) {
968                                        # Can only contain one value
969
9
22
                                        if($value ne $schema) {
970
4
7
                                                $self->_info("Block $key = $value");
971
4
55
                                                $self->status(422);
972
4
4
                                                next;   # Skip to the next parameter
973                                        }
974                                } elsif(ref($schema) eq 'Regexp') {
975
54
145
                                        if($value !~ $schema) {
976                                                # Simple regex
977
27
52
                                                $self->_info("Block $key = $value");
978
27
225
                                                $self->status(422);
979
27
32
                                                next;   # Skip to the next parameter
980                                        }
981                                } elsif(ref($schema) eq 'CODE') {
982
26
42
                                        unless($schema->($key, $value, $self)) {
983
10
127
                                                $self->_info("Block $key = $value");
984
10
80
                                                next;
985                                        }
986                                } else {
987                                        # Set of rules
988
35
13
                                        eval {
989                                                $value = Params::Validate::Strict::validate_strict({
990                                                        schema => { $key => $schema },
991                                                        args => { $key => $value },
992                                                        unknown_parameter_handler => 'die',
993
35
61
                                                        logger => $self->{'logger'}
994                                                });
995                                        };
996
35
3307
                                        if($@) {
997
9
18
                                                $self->_info("Block $key = $value: $@");
998
9
96
                                                $self->status(422);
999
9
8
                                                next;   # Skip to the next parameter
1000                                        }
1001
26
26
8
23
                                        if(scalar keys %{$value}) {
1002
26
16
                                                $value = $value->{$key};
1003                                        } else {
1004
0
0
                                                $self->_info("Block $key = $value");
1005
0
0
                                                $self->status(422);
1006
0
0
                                                next;   # Skip to the next parameter
1007                                        }
1008                                }
1009                        }
1010                }
1011
1012                # if($self->{expect} && (List::Util::none { $_ eq $key } @{$self->{expect}})) {
1013                        # next;
1014                # }
1015
1884
1147
                my $orig_value = $value;
1016
1884
1505
                $value = _sanitise_input($value);
1017
1884
78984
                if((!defined($ENV{'REQUEST_METHOD'})) || ($ENV{'REQUEST_METHOD'} eq 'GET')) {
1018                           # ($value =~ /\/AND\/.++\(SELECT\//) || # United/**/States)/**/AND/**/(SELECT/**/6734/**/FROM/**/(SELECT(SLEEP(5)))lRNi)/**/AND/**/(8984=8984
1019                        # From http://www.symantec.com/connect/articles/detection-sql-injection-and-cross-site-scripting-attacks
1020                        # Facebook FBCLID can have "--"
1021
1022                        # Pre-filter: only run quote-based regexes if value contains injection chars.
1023
1024                        # Compute pre-filter flags from orig_value so quotes stripped by
1025                        # convert_XSS don't cause injection patterns to be missed
1026
1027
1849
1606
                        my $has_quote  = index($orig_value, "'")    >= 0 || index($orig_value, '%27') >= 0;
1028
1849
1430
                        my $has_hash   = index($orig_value, '#')    >= 0 || index($orig_value, '%23') >= 0;
1029
1849
1365
                        my $has_equals = index($orig_value, '=')    >= 0 || index($orig_value, '%3D') >= 0;
1030
1849
1376
                        my $has_semi   = index($orig_value, ';')    >= 0 || index($orig_value, '%3B') >= 0;
1031
1849
796
                        my $has_dash   = index($orig_value, '--')   >= 0;
1032
1033                        # All WAF patterns run on $orig_value (pre-XSS-sanitisation)
1034                        # convert_XSS encodes ', =, < etc. as HTML entities, which would hide
1035                        # injection patterns from the WAF if we checked $value instead.
1036
1849
1984
                        if($has_quote || $has_hash || ($has_equals && $has_dash)) {
1037
18
125
                                if(($orig_value =~ /(\%27)|(\')|(\%23)|(\#)/ix) ||
1038                                   (($has_equals && ($has_quote || $has_semi || $has_dash)) &&
1039                                   $orig_value =~ /((\%3D)|(=))[^-]*+((\%27)|(\')|(\-\-)|(\%3B)|(;))/i) ||
1040                                   ($has_quote &&
1041                                    $orig_value =~ /\w*((\%27)|(\'))((\%6F)|o|(\%4F))((\%72)|r|(\%52))\s*(OR|AND|UNION|SELECT|--)/ix) ||
1042                                    ($has_quote &&
1043                                    $orig_value =~ /((\%27)|(\'))union/ix)) {
1044
18
28
                                        $self->status(403);
1045
18
21
                                        if($ENV{'REMOTE_ADDR'}) {
1046
1
3
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
1047                                        } else {
1048
17
36
                                                $self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
1049                                        }
1050
18
34
                                        return;
1051                                }
1052                        }
1053
1054
1831
1380
                        my $has_select = index($orig_value, 'SELECT') >= 0 || index($orig_value, 'select') >= 0;
1055
1831
764
                        my $has_dump   = index($orig_value, 'var_dump') >= 0;
1056
1831
749
                        my $has_exec   = index($orig_value, 'exec') >= 0;
1057
1831
746
                        my $has_or  = index($orig_value, ' OR ')  >= 0;
1058
1831
712
                        my $has_and = index($orig_value, ' AND ') >= 0;
1059
1831
1329
                        my $has_slash  = index($orig_value, '/**/') >= 0 || index($orig_value, '/AND/') >= 0;
1060
1061
1831
5085
                        if(($has_select && $orig_value =~ /select[[a-z]\s\*]from/ix) ||
1062                           ($has_and    && $orig_value =~ /\sAND\s1=1/ix) ||
1063                           ($has_or && $has_and && $orig_value =~ /\sOR\s.*\sAND\s/) ||
1064                           ($has_slash  && $orig_value =~ /\/\*\*\/ORDER\/\*\*\/BY\/\*\*/ix) ||
1065                           ($has_dump   && $orig_value =~ /var_dump[^m]*+md5/) ||
1066                           ($has_slash  && $has_select && $orig_value =~ /\/AND\/[^(]*+\(SELECT\//) ||
1067                           ($has_exec   && $orig_value =~ /exec(\s|\+)++(s|x)p\w+/ix)) {
1068
8
10
                                $self->status(403);
1069
8
8
                                if($ENV{'REMOTE_ADDR'}) {
1070
0
0
                                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
1071                                } else {
1072
8
12
                                        $self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
1073                                }
1074
8
28
                                return;
1075                        }
1076
1077
1823
1102
                        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1078
12
65
                                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/)) {
1079
3
6
                                        $self->status(403);
1080
3
6
                                        if($ENV{'REMOTE_ADDR'}) {
1081
2
5
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1082                                        } else {
1083
1
2
                                                $self->_warn("SQL injection attempt blocked for '$agent'");
1084                                        }
1085
3
7
                                        return;
1086                                }
1087                        }
1088
1089
1820
5676
                        if(($value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
1090                           ($value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i) ||
1091                           ($orig_value =~ /((\%3C)|<)((\%2F)|\/)*[a-z0-9\%]+((\%3E)|>)/ix) ||
1092                           ($orig_value =~ /((\%3C)|<)[^\n]+((\%3E)|>)/i)) {
1093
12
16
                                $self->status(403);
1094
12
36
                                $self->_warn("XSS injection attempt blocked for '$value'");
1095
12
26
                                return;
1096                        }
1097
1098
1808
965
                        if($value =~ /mustleak\.com\//) {
1099
4
6
                                $self->status(403);
1100
4
9
                                $self->_warn("Blocked mustleak attack for '$key'");
1101
4
10
                                return;
1102                        }
1103
1104
1804
1286
                        if($value =~ /\.\.\//) {
1105
9
12
                                $self->status(403);
1106
9
19
                                $self->_warn("Blocked directory traversal attack for '$key'");
1107
8
17
                                return;
1108                        }
1109                }
1110
1830
991
                if(length($value) > 0) {
1111                        # Don't add if it's already there
1112
1819
1180
                        if($FORM{$key} && ($FORM{$key} ne $value)) {
1113
7
8
                                $FORM{$key} .= ",$value";
1114                        } else {
1115
1812
1469
                                $FORM{$key} = $value;
1116                        }
1117                }
1118        }
1119
1120
235
198
        unless(%FORM) {
1121
34
61
                return;
1122        }
1123
1124
201
176
        if($self->{'logger'}) {
1125
201
277
                while(my ($key,$value) = each %FORM) {
1126
1802
11892
                        $self->_debug("$key=$value");
1127                }
1128        }
1129
1130
201
1850
        $self->{paramref} = \%FORM;
1131
1132
201
309
        return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
1133}
1134
1135 - 1183
=head2 param($field)

Get a single CGI parameter value by name.
When called without arguments it delegates to C<params()> and returns all parameters.
When called with a field name it returns that parameter's (sanitised) value,
or C<undef> if the parameter was not supplied or is not in the allow list.

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

        # With an allow list:
        my $info2 = CGI::Info->new();
        my $allowed = { foo => qr/\d+/ };
        $info2->params(allow => $allowed);
        my $bar2 = $info2->param('bar');   # logs a warning; returns undef

=over 4

=item $field

Optional. The name of the CGI parameter to retrieve.
If omitted, all parameters (as a hash-ref) are returned via C<params()>.

=back

=head3 API SPECIFICATION

=head4 Input

        {
                field => { type => 'scalar', optional => 1 },
        }

=head4 Output

        # When $field is supplied
        { type => 'scalar', optional => 1 }
        # When $field is omitted (delegates to params())
        { type => 'hashref', optional => 1 }

=head3 MESSAGES

        | Level | Message                                  | Meaning                              | Action                                  |
        |-------|------------------------------------------|--------------------------------------|-----------------------------------------|
        | warn  | param: <field> isn't in the allow list   | Caller requested a parameter outside | Review the allow list passed to new()   |
        |       |                                          | the schema set by params(allow=>\%h) | or params(); add the key if legitimate  |

=cut
1184
1185sub param {
1186
82
10422
        my ($self, $field) = @_;
1187
1188
82
98
        if(!defined($field)) {
1189
5
9
                return $self->params();
1190        }
1191        # Is this a permitted argument?
1192
77
134
        if($self->{allow} && !exists($self->{allow}->{$field})) {
1193
11
27
                $self->_warn({
1194                        warning => "param: $field isn't in the allow list"
1195                });
1196
7
14
                return;
1197        }
1198
1199        # Prevent deep recursion which can happen when a validation routine calls param()
1200
66
34
        my $allow;
1201
66
79
        if($self->{in_param} && $self->{allow}) {
1202
4
4
                $allow = delete $self->{allow};
1203        }
1204
66
52
        $self->{in_param} = 1;
1205
1206
66
115
        my $params = $self->params();
1207
1208
66
1630
        $self->{in_param} = 0;
1209
66
73
        $self->{allow} = $allow if($allow);
1210
1211
66
81
        if($params) {
1212
59
87
                return Return::Set::set_return($params->{$field}, { type => 'string' });
1213        }
1214}
1215
1216sub _sanitise_input :Protected {
1217        my $arg = shift;
1218
1219        # Protected function: inline check because the ($) prototype means no $self,
1220        unless($ENV{HARNESS_ACTIVE}) {
1221                my $calling_pkg = (caller)[0];
1222                unless($calling_pkg && ($calling_pkg eq __PACKAGE__ || $calling_pkg->isa(__PACKAGE__))) {
1223                        Carp::croak('_sanitise_input() is a protected function and cannot be called from outside ' . __PACKAGE__);
1224                }
1225        }
1226
1227        return if(!defined($arg));
1228
1229        # Remove hacking attempts and spaces
1230        $arg =~ s/[\r\n]//g;
1231        $arg =~ s/\s+$//;
1232        $arg =~ s/^\s+//;
1233
1234        $arg =~ s/<!--.*-->//g;
1235        # Allow :
1236        # $arg =~ s/[;<>\*|`&\$!?#\(\)\[\]\{\}'"\\\r]//g;
1237
1238        # return $arg;
1239        # return String::EscapeCage->new(convert_XSS($arg))->escapecstring();
1240        return convert_XSS($arg);
1241
49
49
49
78192
39
431
}
1242
1243sub _multipart_data :Protected {
1244        my ($self, $args) = @_;
1245
1246        $self->_trace('Entering _multipart_data');
1247
1248        my $total_bytes = $$args{length};
1249
1250        $self->_debug("_multipart_data: total_bytes = $total_bytes");
1251
1252        if($total_bytes == 0) {
1253                return;
1254        }
1255
1256        unless($stdin_data) {
1257                while(<STDIN>) {
1258                        chop(my $line = $_);
1259                        $line =~ s/[\r\n]//g;
1260                        $stdin_data .= "$line\n";
1261                }
1262                if(!$stdin_data) {
1263                        return;
1264                }
1265        }
1266
1267        my $boundary = $$args{boundary};
1268
1269        my @pairs;
1270        my $writing_file = 0;
1271        my $key;
1272        my $value;
1273        my $in_header = 0;
1274        my $fout;
1275
1276        foreach my $line(split(/\n/, $stdin_data)) {
1277                if($line =~ /^--\Q$boundary\E--$/) {
1278                        last;
1279                }
1280                if($line =~ /^--\Q$boundary\E$/) {
1281                        if($writing_file) {
1282                                close $fout;
1283                                $writing_file = 0;
1284                        } elsif(defined($key)) {
1285                                push(@pairs, "$key=$value");
1286                                $value = undef;
1287                        }
1288                        $in_header = 1;
1289                } elsif($in_header) {
1290                        if(length($line) == 0) {
1291                                $in_header = 0;
1292                        } elsif($line =~ /^Content-Disposition: (.+)/i) {
1293                                my $field = $1;
1294                                if($field =~ /name="(.+?)"/) {
1295                                        $key = $1;
1296                                }
1297                                if($field =~ /filename="(.+)?"/) {
1298                                        my $filename = $1;
1299                                        unless(defined($filename)) {
1300                                                $self->_warn('No upload filename given');
1301                                        } elsif($filename =~ /[\\\/\|]/) {
1302                                                $self->_warn("Disallowing invalid filename: $filename");
1303                                        } else {
1304                                                $filename = $self->_create_file_name({
1305                                                        filename => $filename
1306                                                });
1307
1308                                                # Don't do this since it taints the string and I can't work out how to untaint it
1309                                                # my $full_path = Cwd::realpath(File::Spec->catfile($self->{upload_dir}, $filename));
1310                                                # $full_path =~ m/^(\/[\w\.]+)$/;
1311                                                my $full_path = File::Spec->catfile($self->{upload_dir}, $filename);
1312                                                unless(open($fout, '>', $full_path)) {
1313                                                        $self->_warn("Can't open $full_path");
1314                                                }
1315                                                $writing_file = 1;
1316                                                push(@pairs, "$key=$filename");
1317                                        }
1318                                }
1319                        }
1320                        # TODO: handle Content-Type: text/plain, etc.
1321                } else {
1322                        if($writing_file) {
1323                                print $fout "$line\n";
1324                        } else {
1325                                $value .= $line;
1326                        }
1327                }
1328        }
1329
1330        if($writing_file) {
1331                close $fout;
1332        }
1333
1334        $self->_trace('Leaving _multipart_data');
1335
1336        return @pairs;
1337
49
49
49
16173
32
309
}
1338
1339# Robust filename generation (preventing overwriting)
1340sub _create_file_name :Protected {
1341        my ($self, $args) = @_;
1342
1343        my $filename = $$args{filename} . '_' . time;
1344
1345        my $counter = 0;
1346        my $rc;
1347
1348        do {
1349                $rc = $filename . ($counter ? "_$counter" : '');
1350                $counter++;
1351        } until(! -e $rc);      # Check if file exists
1352
1353        return $rc;
1354
49
49
49
5030
37
230
}
1355
1356# Untaint a filename. Regex from CGI::Untaint::Filenames
1357sub _untaint_filename :Protected {
1358        my ($self, $args) = @_;
1359
1360        if($$args{filename} =~ /(^[\w\+_\040\#\(\)\{\}\[\]\/\-\^,\.:;&%@\\~]+\$?$)/) {
1361                return $1;
1362        }
1363        return;
1364
49
49
49
6424
39
224
}
1365
1366 - 1374
=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 overridden by the IS_MOBILE environment setting

=cut
1375
1376sub is_mobile {
1377
194
2074
        my $self = shift;
1378
1379
194
169
        if(defined($self->{is_mobile})) {
1380
122
115
                return $self->{is_mobile};
1381        }
1382
1383
72
82
        if($ENV{'IS_MOBILE'}) {
1384
4
7
                return $ENV{'IS_MOBILE'}
1385        }
1386
1387        # Support Sec-CH-UA-Mobile
1388
68
84
        if(my $ch_ua_mobile = $ENV{'HTTP_SEC_CH_UA_MOBILE'}) {
1389
6
10
                if($ch_ua_mobile eq '?1') {
1390
3
4
                        $self->{is_mobile} = 1;
1391
3
6
                        return 1;
1392                }
1393        }
1394
1395
65
74
        if($ENV{'HTTP_X_WAP_PROFILE'}) {
1396                # E.g. Blackberry
1397                # TODO: Check the sanity of this variable
1398
3
5
                $self->{is_mobile} = 1;
1399
3
6
                return 1;
1400        }
1401
1402
62
77
        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1403
48
370436
                if($agent =~ /.+(Android|iPhone).+/) {
1404
15
20
                        $self->{is_mobile} = 1;
1405
15
23
                        return 1;
1406                }
1407
1408                # From http://detectmobilebrowsers.com/
1409
33
1600
                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) {
1410
1
2
                        $self->{is_mobile} = 1;
1411
1
2
                        return 1;
1412                }
1413
1414                # Save loading and calling HTTP::BrowserDetect
1415
32
35
                my $remote = $ENV{'REMOTE_ADDR'};
1416
32
77
                if(defined($remote) && $self->{cache}) {
1417
2
5
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1418
2
9
                                return $self->{is_mobile} = ($type eq 'mobile');
1419                        }
1420                }
1421
1422
30
38
                unless($self->{browser_detect}) {
1423
24
24
19
2893
                        if(eval { require HTTP::BrowserDetect; }) {
1424
24
53805
                                HTTP::BrowserDetect->import();
1425
24
50
                                $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1426                        }
1427                }
1428
1429
30
2129
                if($self->{browser_detect}) {
1430
30
52
                        my $device = $self->{browser_detect}->device();
1431                        # Without the ?1:0 it will set to the empty string not 0
1432
30
135
                        my $is_mobile = (defined($device) && ($device =~ /blackberry|webos|iphone|ipod|ipad|android/i)) ? 1 : 0;
1433
30
48
                        if($is_mobile && $self->{cache} && defined($remote)) {
1434
0
0
                                $self->{cache}->set("$remote/$agent", 'mobile', $CACHE_TTL_SEARCH);
1435                        }
1436
30
72
                        return $self->{is_mobile} = $is_mobile;
1437                }
1438        }
1439
1440
14
50
        return 0;
1441}
1442
1443 - 1447
=head2 is_tablet

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

=cut
1448
1449sub is_tablet {
1450
18
131
        my $self = shift;
1451
1452
18
24
        if(defined($self->{is_tablet})) {
1453
3
6
                return $self->{is_tablet};
1454        }
1455
1456
15
236
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /.+(iPad|TabletPC).+/)) {
1457                # TODO: add others when I see some nice user_agents
1458
5
6
                $self->{is_tablet} = 1;
1459        } else {
1460
10
10
                $self->{is_tablet} = 0;
1461        }
1462
1463
15
31
        return $self->{is_tablet};
1464}
1465
1466 - 1492
=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
1493
1494sub as_string
1495{
1496
903
9268
        my $self = shift;
1497
1498
903
819
        my $args = Params::Validate::Strict::validate_strict({
1499                args => Params::Get::get_params(undef, @_) || {},
1500                schema => {
1501                        raw => {
1502                                'type' => 'boolean',
1503                                'optional' => 1
1504                        }
1505                }
1506        });
1507
1508        # Retrieve object parameters
1509
598
38898
        my $params = $self->params() || return '';
1510
1511
46
256
        my $rc;
1512
1513
46
50
        if($args->{'raw'}) {
1514                # Raw mode: return key=value pairs without escaping
1515                $rc = join '; ', map {
1516
8
17
                        "$_=" . $params->{$_}
1517
6
6
7
8
                } sort keys %{$params};
1518        } else {
1519                # Escaped mode: escape special characters
1520                $rc = join '; ', map {
1521
62
48
                        my $value = $params->{$_};
1522
1523
62
50
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1524
62
102
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1525
62
95
                        "$_=$value"
1526
40
40
37
75
                } sort keys %{$params};
1527        }
1528
1529
46
53
        $rc ||= '';
1530
1531
46
68
        $self->_trace("as_string: returning '$rc'");
1532
1533
46
484
        return $rc;
1534}
1535
1536 - 1541
=head2 protocol

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

=cut
1542
1543sub protocol {
1544
54
672
        my $self = shift;
1545
1546
54
98
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^(.+):\/\/.+/)) {
1547
7
18
                return $1;
1548        }
1549
47
75
        if($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1550
6
11
                return 'http';
1551        }
1552
1553
41
59
        if(my $port = $ENV{'SERVER_PORT'}) {
1554
23
890
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1555
19
35
                        if($name =~ /https?/) {
1556
15
32
                                return $name;
1557                        } elsif($name eq 'www') {
1558                                # e.g. NetBSD and OpenBSD
1559
0
0
                                return 'http';
1560                        }
1561                        # Return an error, maybe missing something
1562                } elsif($port == 80) {
1563                        # e.g. Solaris
1564
0
0
                        return 'http';
1565                } elsif($port == 443) {
1566
0
0
                        return 'https';
1567                }
1568        }
1569
1570
26
529
        if($ENV{'REMOTE_ADDR'}) {
1571
1
3
                $self->_warn("Can't determine the calling protocol");
1572        }
1573
26
54
        return;
1574}
1575
1576 - 1601
=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
1602
1603sub tmpdir {
1604
1638
2205
        my $self = shift;
1605
1606
1638
972
        my $name = 'tmp';
1607
1638
1955
        if($^O eq 'MSWin32') {
1608
0
0
                $name = 'temp';
1609        }
1610
1611
1638
825
        my $dir;
1612
1613
1638
1465
        if(!ref($self)) {
1614
5
6
                $self = __PACKAGE__->new();
1615        }
1616
1638
1548
        my $params = Params::Get::get_params(undef, @_);
1617
1618
1638
14978
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1619
9
23
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1620
9
52
                if((-d $dir) && (-w $dir)) {
1621
3
5
                        return $self->_untaint_filename({ filename => $dir });
1622                }
1623
6
7
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1624
6
21
                if((-d $dir) && (-w $dir)) {
1625
6
15
                        return $self->_untaint_filename({ filename => $dir });
1626                }
1627        }
1628
1629
1559
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1629
2
23
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1630
2
14
                if((-d $dir) && (-w $dir)) {
1631
1
3
                        return $self->_untaint_filename({ filename => $dir });
1632                }
1633        }
1634
1628
2257
        if($params->{'default'} && ref($params->{'default'})) {
1635
608
4022
                croak(ref($self), ': tmpdir must be given a scalar');
1636        }
1637
1020
3966
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1638}
1639
1640 - 1652
=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
1653
1654sub rootdir {
1655
30
1684
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1656
14
21
                return $ENV{'C_DOCUMENT_ROOT'};
1657        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1658
4
7
                return $ENV{'DOCUMENT_ROOT'};
1659        }
1660
12
9
        my $script_name = $0;
1661
1662
12
28
        unless(File::Spec->file_name_is_absolute($script_name)) {
1663
12
93
                $script_name = File::Spec->rel2abs($script_name);
1664        }
1665
12
17
        if($script_name =~ /.cgi\-bin.*/) {     # kludge for outside CGI environment
1666
0
0
                $script_name =~ s/.cgi\-bin.*//;
1667        }
1668
12
31
        if(-f $script_name) {   # More kludge
1669
12
15
                if($^O eq 'MSWin32') {
1670
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1671
0
0
                                return $1;
1672                        }
1673                } else {
1674
12
28
                        if($script_name =~ /(.+)\/.+?$/) {
1675
12
17
                                return $1;
1676                        }
1677                }
1678        }
1679
0
0
        return $script_name;
1680}
1681
1682 - 1686
=head2 root_dir

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

=cut
1687
1688sub root_dir
1689{
1690
7
629
        if($_[0] && ref($_[0])) {
1691
3
3
                my $self = shift;
1692
1693
3
4
                return $self->rootdir(@_);
1694        }
1695
4
5
        return __PACKAGE__->rootdir(@_);
1696}
1697
1698 - 1702
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1703
1704sub documentroot
1705{
1706
6
286
        if($_[0] && ref($_[0])) {
1707
2
2
                my $self = shift;
1708
1709
2
3
                return $self->rootdir(@_);
1710        }
1711
4
6
        return __PACKAGE__->rootdir(@_);
1712}
1713
1714 - 1726
=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
1727
1728sub logdir {
1729
21
1313
        my $self = shift;
1730
21
16
        my $dir = shift;
1731
1732
21
28
        if(!ref($self)) {
1733
1
2
                $self = __PACKAGE__->new();
1734        }
1735
1736
21
30
        if($dir) {
1737
11
81
                if(length($dir) && (-d $dir) && (-w $dir)) {
1738
5
11
                        return $self->{'logdir'} = $dir;
1739                }
1740
6
18
                $self->_warn("Invalid logdir: $dir");
1741
6
397
                Carp::croak("Invalid logdir: $dir");
1742        }
1743
1744
10
59
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1745
26
104
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1746
10
7
                        $dir = $rc;
1747
10
11
                        last;
1748                }
1749        }
1750
10
29
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1751
10
26
        $self->{logdir} ||= $dir;
1752
1753
10
17
        return $dir;
1754}
1755
1756 - 1771
=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
1772
1773sub is_robot {
1774
53
837
        my $self = shift;
1775
1776
53
61
        if(defined($self->{is_robot})) {
1777
9
14
                return $self->{is_robot};
1778        }
1779
1780
44
39
        my $agent = $ENV{'HTTP_USER_AGENT'};
1781
44
37
        my $remote = $ENV{'REMOTE_ADDR'};
1782
1783
44
71
        unless($remote && $agent) {
1784                # Probably not running in CGI - assume real person
1785
11
18
                return 0;
1786        }
1787
1788        # See also params()
1789
33
713
        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/)) {
1790
4
8
                $self->status(403);
1791
4
5
                $self->{is_robot} = 1;
1792
4
6
                if($ENV{'REMOTE_ADDR'}) {
1793
4
16
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1794                } else {
1795
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1796                }
1797
4
7
                return 1;
1798        }
1799
29
92191
        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) {
1800
11
15
                $self->{is_robot} = 1;
1801
11
18
                return 1;
1802        }
1803
1804        # TODO:
1805        # Download and use list from
1806        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1807
1808
18
29
        my $key = "$remote/$agent";
1809
1810
18
42
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1811                # https://agency.ohow.co/google-analytics-implementation-audit/google-analytics-historical-spam-list/
1812
4
24
                my @crawler_lists = (
1813                        'http://fix-website-errors.com',
1814                        'http://keywords-monitoring-your-success.com',
1815                        'http://free-video-tool.com',
1816                        'http://magnet-to-torrent.com',
1817                        'http://torrent-to-magnet.com',
1818                        'http://dogsrun.net',
1819                        'http://###.responsive-test.net',
1820                        'http://uptime.com',
1821                        'http://uptimechecker.com',
1822                        'http://top1-seo-service.com',
1823                        'http://fast-wordpress-start.com',
1824                        'http://wordpress-crew.net',
1825                        'http://dbutton.net',
1826                        'http://justprofit.xyz',
1827                        'http://video--production.com',
1828                        'http://buttons-for-website.com',
1829                        'http://buttons-for-your-website.com',
1830                        'http://success-seo.com',
1831                        'http://videos-for-your-business.com',
1832                        'http://semaltmedia.com',
1833                        'http://dailyrank.net',
1834                        'http://uptimebot.net',
1835                        'http://sitevaluation.org',
1836                        'http://100dollars-seo.com',
1837                        'http://forum69.info',
1838                        'http://partner.semalt.com',
1839                        'http://best-seo-offer.com',
1840                        'http://best-seo-solution.com',
1841                        'http://semalt.semalt.com',
1842                        'http://semalt.com',
1843                        'http://7makemoneyonline.com',
1844                        'http://anticrawler.org',
1845                        'http://baixar-musicas-gratis.com',
1846                        'http://descargar-musica-gratis.net',
1847
1848                        # Mine
1849                        'http://www.seokicks.de/robot.html',
1850                );
1851                # Normalise stray backslashes in the referrer before matching
1852
4
3
                $referrer =~ s/\\/_/g;
1853                # Block if the referrer starts with a known crawler domain (or contains a closing
1854                # parenthesis, which is a common hallmark of spam referrers).
1855                # quotemeta() prevents accidental regex metacharacters in the referrer.
1856
4
33
11
125
                if(($referrer =~ /\)/) || (List::Util::any { $referrer =~ /^\Q$_\E/i } @crawler_lists)) {
1857
4
7
                        $self->_debug("is_robot: blocked trawler $referrer");
1858
1859
4
22
                        if($self->{cache}) {
1860
0
0
                                $self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
1861                        }
1862
4
13
                        $self->{is_robot} = 1;
1863
4
12
                        return 1;
1864                }
1865        }
1866
1867
14
41
        if(defined($remote) && $self->{cache}) {
1868
1
3
                if(my $type = $self->{cache}->get("$remote/$agent")) {
1869
1
5
                        return $self->{is_robot} = ($type eq 'robot');
1870                }
1871        }
1872
1873        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1874        # that is easily spoofed
1875
13
41
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1876                # Mark Facebook as a search engine, not a robot
1877
1
1
                if($self->{cache}) {
1878
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
1879                }
1880
1
2
                return 0;
1881        }
1882
1883
12
18
        unless($self->{browser_detect}) {
1884
4
4
2
330
                if(eval { require HTTP::BrowserDetect; }) {
1885
4
6512
                        HTTP::BrowserDetect->import();
1886
4
5
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1887                }
1888        }
1889
12
317
        if($self->{browser_detect}) {
1890
12
23
                my $is_robot = $self->{browser_detect}->robot();
1891
12
1187
                if(defined($is_robot)) {
1892
2
8
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1893                }
1894
12
59
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1895
12
42
                $self->_debug("is_robot: $is_robot");
1896
1897
12
165
                if($is_robot) {
1898
2
2
                        if($self->{cache}) {
1899
0
0
                                $self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
1900                        }
1901
2
3
                        $self->{is_robot} = $is_robot;
1902
2
4
                        return $is_robot;
1903                }
1904        }
1905
1906
10
15
        if($self->{cache}) {
1907
0
0
                $self->{cache}->set($key, 'unknown', $CACHE_TTL_ROBOT);
1908        }
1909
10
11
        $self->{is_robot} = 0;
1910
10
22
        return 0;
1911}
1912
1913 - 1925
=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 overridden by the IS_SEARCH_ENGINE environment setting

=cut
1926
1927sub is_search_engine
1928{
1929
57
595
        my $self = shift;
1930
1931
57
70
        if(defined($self->{is_search_engine})) {
1932
9
18
                return $self->{is_search_engine};
1933        }
1934
1935
48
55
        if($ENV{'IS_SEARCH_ENGINE'}) {
1936
3
6
                return $ENV{'IS_SEARCH_ENGINE'}
1937        }
1938
1939
45
50
        my $remote = $ENV{'REMOTE_ADDR'};
1940
45
34
        my $agent = $ENV{'HTTP_USER_AGENT'};
1941
1942
45
105
        unless($remote && $agent) {
1943                # Probably not running in CGI - assume not a search engine
1944
13
21
                return 0;
1945        }
1946
1947
32
30
        my $key;
1948
1949
32
44
        if($self->{cache}) {
1950
1
1
                $key = "$remote/$agent";
1951
1
2
                if(defined($remote) && $self->{cache}) {
1952
1
2
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1953
1
6
                                return $self->{is_search} = ($type eq 'search');
1954                        }
1955                }
1956        }
1957
1958        # Don't use HTTP_USER_AGENT to detect more than we really have to since
1959        # that is easily spoofed
1960
31
88
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1961                # Mark Facebook as a search engine, not a robot
1962
3
4
                if($self->{cache}) {
1963
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
1964                }
1965
3
4
                return 1;
1966        }
1967
1968
28
36
        unless($self->{browser_detect}) {
1969
14
14
7
338
                if(eval { require HTTP::BrowserDetect; }) {
1970
14
7103
                        HTTP::BrowserDetect->import();
1971
14
19
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1972                }
1973        }
1974
28
1145
        if(my $browser = $self->{browser_detect}) {
1975
28
46
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
1976
28
3857
                if(!$is_search) {
1977
19
80
                        if(($agent =~ /SeznamBot\//) ||
1978                           ($agent =~ /Google-InspectionTool\//) ||
1979                           ($agent =~ /Googlebot\//)) {
1980
3
3
                                $is_search = 1;
1981                        }
1982                }
1983
28
46
                if($is_search && $self->{cache}) {
1984
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
1985                }
1986
28
58
                return $self->{is_search_engine} = $is_search;
1987        }
1988
1989        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
1990
0
0
        my $hostname = gethostbyaddr(inet_aton($remote), AF_INET) || $remote;
1991
1992
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
1993
1994
0
0
        if((defined($hostname) && ($hostname =~ /google|msnbot|bingbot|amazonbot|GPTBot/) && ($hostname !~ /^google-proxy/)) ||
1995           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
1996
0
0
                if($self->{cache}) {
1997
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
1998                }
1999
0
0
                $self->{is_search_engine} = 1;
2000
0
0
                return 1;
2001        }
2002
2003
0
0
        $self->{is_search_engine} = 0;
2004
0
0
        return 0;
2005}
2006
2007 - 2029
=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
2030
2031sub browser_type {
2032
38
592
        my $self = shift;
2033
2034
38
47
        if($self->is_mobile()) {
2035
13
28
                return 'mobile';
2036        }
2037
25
36
        if($self->is_search_engine()) {
2038
8
17
                return 'search';
2039        }
2040
17
26
        if($self->is_robot()) {
2041
6
13
                return 'robot';
2042        }
2043
11
21
        return 'web';
2044}
2045
2046 - 2061
=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
2062
2063sub get_cookie {
2064
15
326
        my $self = shift;
2065
2066
15
17
        return $self->cookie(\@_);
2067}
2068
2069 - 2113
=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
2114
2115sub cookie
2116{
2117
2129
3259
        my $self = shift;
2118
2129
2201
        my $params = Params::Validate::Strict::validate_strict({
2119                args => Params::Get::get_params('cookie_name', @_),
2120                schema => {
2121                        cookie_name => {
2122                                'type' => 'string',
2123                                'min' => 1,
2124                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
2125                        }
2126                }
2127        });
2128
2129
391
30470
        my $field = $params->{'cookie_name'};
2130
2131        # Validate field argument
2132
391
389
        if(!defined($field)) {
2133
93
275
                $self->_error('what cookie do you want?');
2134
93
719
                Carp::croak('what cookie do you want?');
2135
0
0
                return;
2136        }
2137
298
248
        if(ref($field)) {
2138
0
0
                $self->_error('Cookie name should be a string');
2139
0
0
                Carp::croak('Cookie name should be a string');
2140
0
0
                return;
2141        }
2142
2143        # Load cookies if not already loaded
2144
298
286
        unless($self->{jar}) {
2145
30
31
                if(defined $ENV{'HTTP_COOKIE'}) {
2146                        # grep { /=/ } filters out malformed tokens (empty strings, bare
2147                        # semicolons, entries with no name=value separator) that would
2148                        # otherwise cause split(/=/, $_, 2) to return a single-element list
2149                        # and make the flattened list odd-length, corrupting the hash.
2150                        $self->{jar} = {
2151
48
69
                                map  { split(/=/, $_, 2) }
2152
49
52
                                grep { /=/ }
2153
26
47
                                split(/; /, $ENV{'HTTP_COOKIE'})
2154                        };
2155                }
2156        }
2157
2158        # Return the cookie value if it exists, otherwise return undef
2159
298
572
        return $self->{jar}{$field};
2160}
2161
2162 - 2177
=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
2178
2179sub status
2180{
2181
264
14772
        my $self = shift;
2182
264
157
        my $status = shift;
2183
2184        # Set status if provided
2185
264
332
        return $self->{status} = $status if(defined($status));
2186
2187        # Determine status based on request method if status is not set
2188
118
135
        unless (defined $self->{status}) {
2189
33
31
                my $method = $ENV{'REQUEST_METHOD'};
2190
2191
33
95
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
2192
29
65
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
2193
2194
26
55
                return 200;
2195        }
2196
2197        # Return current status or 200 by default
2198
85
201
        return $self->{status} || 200;
2199}
2200
2201 - 2213
=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
2214
2215sub messages
2216{
2217
40
3966
        my $self = shift;
2218
2219
40
58
        return $self->{'messages'};
2220}
2221
2222 - 2226
=head2  messages_as_string

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

=cut
2227
2228sub messages_as_string
2229{
2230
8
302
        my $self = shift;
2231
2232
8
13
        if(scalar($self->{'messages'})) {
2233
4
12
4
5
10
4
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2234
4
9
                return join('; ', @messages);
2235        }
2236
4
8
        return '';
2237}
2238
2239 - 2258
=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
2259
2260sub cache
2261{
2262
12
54
        my $self = shift;
2263
12
10
        my $cache = shift;
2264
2265
12
18
        if($cache) {
2266
5
127
                croak(ref($self), ':cache($cache) is not an object') if(!Scalar::Util::blessed($cache));
2267
3
24
                croak(ref($self), ':cache($cache) does not support the get() method') if(!$cache->can('get'));
2268
2
4
                croak(ref($self), ':cache($cache) does not support the set() method') if(!$cache->can('set'));
2269
2
2
                $self->{'cache'} = $cache;
2270        }
2271
9
11
        return $self->{'cache'};
2272}
2273
2274 - 2281
=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
2282
2283sub set_logger
2284{
2285
11
81
        my $self = shift;
2286
11
17
        my $params = Params::Get::get_params('logger', @_);
2287
2288
11
140
        if(my $logger = $params->{'logger'}) {
2289
11
21
                if(Scalar::Util::blessed($logger)) {
2290
7
9
                        $self->{'logger'} = $logger;
2291                } else {
2292
4
6
                        $self->{'logger'} = Log::Abstraction->new($logger);
2293                }
2294        } else {
2295
0
0
                $self->{'logger'} = Log::Abstraction->new();
2296        }
2297
11
64
        return $self;
2298}
2299
2300# Log and remember a message
2301sub _log :Protected {
2302        my ($self, $level, @messages) = @_;
2303
2304        if(scalar(@messages)) {
2305                # Note: consider adding caller's function name to log messages in a future release
2306                # if(($level eq 'warn') || ($level eq 'info')) {
2307                        push @{$self->{'messages'}}, { level => $level, message => join(' ', grep defined, @messages) };
2308                # }
2309
2310                if(scalar(@messages) && (my $logger = $self->{'logger'})) {
2311                        $self->{'logger'}->$level(join('', grep defined, @messages));
2312                }
2313        }
2314
49
49
49
109815
40
502
}
2315
2316sub _debug :Protected {
2317        my $self = shift;
2318        $self->_log('debug', @_);
2319
49
49
49
4498
100
293
}
2320
2321sub _info :Protected {
2322        my $self = shift;
2323        $self->_log('info', @_);
2324
49
49
49
3042
86
208
}
2325
2326sub _notice :Protected {
2327        my $self = shift;
2328        $self->_log('notice', @_);
2329
49
49
49
2682
54
163
}
2330
2331sub _trace :Protected {
2332        my $self = shift;
2333        $self->_log('trace', @_);
2334
49
49
49
2417
28
143
}
2335
2336# Emit a warning message somewhere
2337sub _warn :Protected {
2338        my $self = shift;
2339        my $params = Params::Get::get_params('warning', @_);
2340
2341        $self->_log('warn', $params->{'warning'});
2342        if(!defined($self->{'logger'})) {
2343                Carp::carp($params->{'warning'});
2344        }
2345
49
49
49
3421
27
163
}
2346
2347# Emit an error message somewhere
2348sub _error :Protected {
2349        my $self = shift;
2350        my $params = Params::Get::get_params('warning', @_);
2351
2352        $self->_log('error', $params->{'warning'});
2353        if(!defined($self->{'logger'})) {
2354                Carp::croak($params->{'warning'});
2355        }
2356
49
49
49
3203
35
150
}
2357
2358# Ensure all environment variables are sanitized and validated before use.
2359# Use regular expressions to enforce strict input formats.
2360sub _get_env :Protected {
2361        my ($self, $var) = @_;
2362
2363        return unless defined $ENV{$var};
2364
2365        # Strict sanitization: allow alphanumeric and limited special characters
2366        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2367                return $ENV{$var};
2368        }
2369        $self->_warn("Invalid value in environment variable: $var");
2370
2371        return;
2372
49
49
49
6251
31
177
}
2373
2374 - 2380
=head2 reset

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

=cut
2381
2382sub reset {
2383
454
797654
        my $class = shift;
2384
2385
454
550
        unless($class eq __PACKAGE__) {
2386
2
23
                carp('Reset is a class method');
2387
1
138
                return;
2388        }
2389
2390
452
432
        $stdin_data = undef;
2391}
2392
2393sub AUTOLOAD
2394{
2395
732
164840
        our $AUTOLOAD;
2396
2397
732
825
        my $self = shift or return;
2398
2399
732
631
        return if(!defined($AUTOLOAD));
2400
2401        # Extract the method name from the AUTOLOAD variable
2402
731
1935
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2403
2404        # Skip if called on destruction
2405
731
1841
        return if($method eq 'DESTROY');
2406
2407
20
20
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2408
2409        # Allow the AUTOLOAD feature to be disabled
2410
20
49
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2411
2412        # Ensure the method is called on the correct package object or a subclass
2413
17
34
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2414
2415        # Validate method name - only allow safe parameter names
2416
17
34
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2417
2418        # Delegate to the param method
2419
17
32
        return $self->param($method);
2420}
2421
2422 - 2547
=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<Configure an Object at Runtime|Object::Configure>

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

=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

=head2 FORMAL SPECIFICATION

=head3 new

  -- CGI::Info construction
  new : ClassName x Params --> CGIInfo

  -- Normal (non-clone) path
  new(class, params) ^=
    let configured == Object::Configure::configure(class, params)
    in  CGIInfo {
          max_upload_size |-> configured.max_upload_size ?? MAX_UPLOAD_SIZE_DEFAULT,
          allow           |-> configured.allow ?? null,
          upload_dir      |-> configured.upload_dir ?? null,
          ...configured
        }

  -- Pre-conditions
  pre new(class, params) ^=
    params.logger = null
    v (blessed(params.logger)
       ^ params.logger.can('warn')
       ^ params.logger.can('info')
       ^ params.logger.can('error'))
    ^ params.expect = null

  -- Clone path (invocant is an existing object)
  clone : CGIInfo x Params --> CGIInfo
  clone(self, params) ^=
    let merged == (self (+) params) \ {paramref}
    in  CGIInfo { ...merged }

=head3 param

Let F be the set of all possible CGI field names, V be the set of all
possible (sanitised) scalar values, and allow : F -> Regex | undef be the
current allow-list schema (undef means all fields are permitted).

  param : F? -> V | HashRef | undef

  param() =  params()

  param(f) =
    f not in dom(allow) /\ allow /= undef =>  warn; undef
    f in params()                          =>  params()(f)
    otherwise                              =>  undef

Safety invariant: for all f, param(f) /= undef => f in dom(allow) \/ allow = undef.

=head1 LICENCE AND COPYRIGHT

Copyright 2010-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
2548
25491;