File Coverage

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

linestmtbrancondsubtimecode
1package CGI::Info;
2
3
57
57
57
2718646
51
1299
use warnings;
4
57
57
57
77
45
606
use strict;
5
57
57
57
6394
195773
158
use autodie qw(:all);
6
7
57
57
292378
185
use 5.010;      # Minimum version for features used here
8
9# Core modules
10
57
57
57
10388
25521
103
use boolean;
11
57
57
57
1687
42
1053
use Carp;
12
57
57
57
7262
51164
1148
use Readonly;
13
57
57
57
137
54
724
use Scalar::Util;
14
57
57
57
15076
99214
10210
use Socket;     # AF_INET constant
15
16# CPAN modules
17
57
57
57
17048
3465442
1050
use Object::Configure 0.19;
18
57
57
57
215
72
837
use File::Spec;
19
57
57
57
100
312
467
use Log::Abstraction 0.10;
20
57
57
57
14417
139648
1573
use Net::CIDR;
21
57
57
57
172
386
855
use Params::Get 0.13;
22
57
57
57
122
476
655
use Params::Validate::Strict 0.35;
23
57
57
57
120
53
555
use Return::Set;
24
57
57
57
12047
690626
835
use Sys::Path;
25
57
57
57
12765
211972
153
use Sub::Protected;
26
27
57
57
57
7007
41
297
use namespace::clean;
28
29# ---------------------------------------------------------------------------
30# Module-level constants -- avoids magic numbers scattered through the code
31# ---------------------------------------------------------------------------
32Readonly my $MAX_UPLOAD_SIZE_DEFAULT => 512 * 1024;  # 512 KB default upload cap
33Readonly my $CACHE_TTL_ROBOT         => '1 day';     # TTL for robot-detection cache entries
34Readonly my $CACHE_TTL_SEARCH        => '1 day';     # TTL for search-engine cache entries
35
36# Compiled once at module-load time: replaces the 29-element @crawler_lists array
37# that was re-allocated on every is_robot() call.  Building the alternation with
38# quotemeta() is equivalent to the former List::Util::any { /^\Q$_\E/i } loop
39# but avoids both per-call array construction and per-element regex compilation.
40Readonly my $CRAWLER_REFERER_RE => do {
41        my @domains = (
42                'http://fix-website-errors.com',
43                'http://keywords-monitoring-your-success.com',
44                'http://free-video-tool.com',
45                'http://magnet-to-torrent.com',
46                'http://torrent-to-magnet.com',
47                'http://dogsrun.net',
48                'http://###.responsive-test.net',
49                'http://uptime.com',
50                'http://uptimechecker.com',
51                'http://top1-seo-service.com',
52                'http://fast-wordpress-start.com',
53                'http://wordpress-crew.net',
54                'http://dbutton.net',
55                'http://justprofit.xyz',
56                'http://video--production.com',
57                'http://buttons-for-website.com',
58                'http://buttons-for-your-website.com',
59                'http://success-seo.com',
60                'http://videos-for-your-business.com',
61                'http://semaltmedia.com',
62                'http://dailyrank.net',
63                'http://uptimebot.net',
64                'http://sitevaluation.org',
65                'http://100dollars-seo.com',
66                'http://forum69.info',
67                'http://partner.semalt.com',
68                'http://best-seo-offer.com',
69                'http://best-seo-solution.com',
70                'http://semalt.semalt.com',
71                'http://semalt.com',
72                'http://7makemoneyonline.com',
73                'http://anticrawler.org',
74                'http://baixar-musicas-gratis.com',
75                'http://descargar-musica-gratis.net',
76                'http://www.seokicks.de/robot.html',
77        );
78        my $alt = join '|', map { quotemeta $_ } @domains;
79        qr/^(?:$alt)/i;
80};
81
82sub _sanitise_input;
83
84 - 92
=head1 NAME

CGI::Info - Information about the CGI environment

=head1 VERSION

Version 1.14

=cut
93
94our $VERSION = '1.14';
95
96 - 242
=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
243
244our $stdin_data;        # Class variable storing STDIN in case the class
245                        # is instantiated more than once
246
247sub new
248{
249
1030
2792650
        my $class = shift;
250
251        # Handle hash or hashref arguments
252
1030
1903
        my $params = Params::Get::get_params(undef, \@_);
253
254
1028
11951
        if (defined($class)) {
255
1023
1895
                my $is_valid = Scalar::Util::blessed($class) || (eval { $class->isa(__PACKAGE__) });
256
1023
1291
                unless ($is_valid) {
257                        # Called as CGI::Info::new(...) or similar wrong function call
258
1
95
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
259                }
260        } else {
261                # If class is undef, but there are arguments/params passed
262
5
1
10
2
                if (defined($params) && keys %{$params}) {
263
1
9
                        croak(__PACKAGE__, ' use ->new() not ::new() to instantiate');
264                }
265                # Called as CGI::Info::new() with 0 arguments (undef $class)
266
4
6
                $class = __PACKAGE__;
267        }
268
269
1026
1248
        if(Scalar::Util::blessed($class)) {
270                # If $class is an object, clone it with new arguments
271
14
38
                $params ||= {};
272
273                # Validate any new logger passed to the clone
274
14
24
                if(defined $params->{'logger'}) {
275
1
4
                        unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
276
1
76
                                Carp::croak('Logger must be an object with info() and error() methods');
277                        }
278                }
279
280                # expect is deprecated even when cloning
281
13
21
                if(defined($params->{'expect'})) {
282
2
7
                        my $logger = $params->{'logger'} // $class->{'logger'};
283
2
9
                        $logger->error(ref($class) . ': expect has been deprecated, use allow instead') if $logger;
284
2
123
                        Carp::croak(ref($class) . ': expect has been deprecated, use allow instead');
285                }
286
287                # Drop cached params so a new allow schema is applied on next call
288
11
11
11
9
17
23
                my %merged = (%{$class}, %{$params});
289
11
16
                delete $merged{'paramref'};
290
11
31
                return bless \%merged, ref($class);
291        }
292
293        # Load the configuration from a config file, if provided
294
1012
1597
        $params = Object::Configure::configure($class, $params);
295
296        # Validate logger object has required methods
297
1011
2782896
        if(defined $params->{'logger'}) {
298
1011
7714
                unless(Scalar::Util::blessed($params->{'logger'}) && $params->{'logger'}->can('warn') && $params->{'logger'}->can('info') && $params->{'logger'}->can('error')) {
299
0
0
                        Carp::croak("Logger must be an object with info() and error() methods");
300                }
301        }
302
303
1011
1430
        if(defined($params->{'expect'})) {
304                # if(ref($params->{expect}) ne 'ARRAY') {
305                        # Carp::croak(__PACKAGE__, ': expect must be a reference to an array');
306                # }
307                # # warn __PACKAGE__, ': expect is deprecated, use allow instead';
308
6
21
                if(my $logger = $params->{'logger'}) {
309
6
15
                        $logger->error("$class: expect has been deprecated, use allow instead");
310                }
311
6
1293
                Carp::croak("$class: expect has been deprecated, use allow instead");
312        }
313
314        # Return the blessed object with sensible defaults
315        return bless {
316                max_upload_size => $MAX_UPLOAD_SIZE_DEFAULT,
317                allow           => undef,
318                upload_dir      => undef,
319
1005
1005
868
2657
                %{$params}      # Caller-supplied args override the defaults above
320        }, $class;
321}
322
323 - 350
=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
351
352sub script_name
353{
354
35
1115
        my $self = shift;
355
356
35
60
        unless($self->{script_name}) {
357
25
58
                $self->_find_paths();
358        }
359
35
81
        return $self->{script_name};
360}
361
362sub _find_paths :Protected {
363
0
        my $self = shift;
364
365
0
        $self->_trace(__PACKAGE__ . ': entering _find_paths');
366
367
0
        require File::Basename && File::Basename->import() unless File::Basename->can('basename');
368
369        # Determine script name
370
0
        my $script_name = $self->_get_env('SCRIPT_NAME') // $0;
371
0
        $self->{script_name} = $self->_untaint_filename({
372                filename => File::Basename::basename($script_name)
373        });
374
375        # Determine script path
376
0
        if(my $script_path = $self->_get_env('SCRIPT_FILENAME')) {
377
0
                $self->{script_path} = $script_path;
378        } elsif($script_name = $self->_get_env('SCRIPT_NAME')) {
379
0
                if(my $document_root = $self->_get_env('DOCUMENT_ROOT')) {
380
0
                        $script_name = $self->_get_env('SCRIPT_NAME');
381
382                        # It's usually the case, e.g. /cgi-bin/foo.pl
383
0
                        $script_name =~ s{^/}{};
384
385
0
                        $self->{script_path} = File::Spec->catfile($document_root, $script_name);
386                } else {
387
0
                        if(File::Spec->file_name_is_absolute($script_name) && (-r $script_name)) {
388                                # Called from a command line with a full path
389
0
                                $self->{script_path} = $script_name;
390                        } else {
391
0
                                require Cwd unless Cwd->can('abs_path');
392
393
0
                                if($script_name =~ /^\/(.+)/) {
394                                        # It's usually the case, e.g. /cgi-bin/foo.pl
395
0
                                        $script_name = $1;
396                                }
397
398
0
                                $self->{script_path} = File::Spec->catfile(Cwd::abs_path(), $script_name);
399                        }
400                }
401        } elsif(File::Spec->file_name_is_absolute($0)) {
402                # Called from a command line with a full path
403
0
                $self->{script_path} = $0;
404        } else {
405
0
                $self->{script_path} = File::Spec->rel2abs($0);
406        }
407
408        # Untaint and finalize script path
409        $self->{script_path} = $self->_untaint_filename({
410                filename => $self->{script_path}
411
0
        });
412
57
57
57
96387
51
950
}
413
414 - 431
=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
432
433sub script_path {
434
39
2851
        my $self = shift;
435
436
39
61
        unless($self->{script_path}) {
437
15
40
                $self->_find_paths();
438        }
439
39
116
        return $self->{script_path};
440}
441
442 - 456
=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
457
458sub script_dir
459{
460
26
66
        my $self = shift;
461
462        # Ensure $self is an object
463
26
50
        $self = __PACKAGE__->new() unless ref $self;
464
465        # Set script path if it is not already defined
466
26
65
        $self->_find_paths() unless $self->{script_path};
467
468        # Extract directory from script path based on OS
469        # Don't use File::Spec->splitpath() since that can leave the trailing slash
470
26
73
        my $dir_regex = $^O eq 'MSWin32' ? qr{(.+)\\.+?$} : qr{(.+)/.+?$};
471
472
26
206
        return $self->{script_path} =~ $dir_regex ? $1 : $self->{script_path};
473}
474
475 - 494
=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
495
496sub host_name {
497
132
1320
        my $self = shift;
498
499
132
118
        unless($self->{site}) {
500
24
65
                $self->_find_site_details();
501        }
502
503
132
539
        return $self->{site};
504}
505
506sub _find_site_details :Protected {
507        my $self = shift;
508
509        # Log entry to the routine
510        $self->_trace('Entering _find_site_details');
511
512        return if $self->{site} && $self->{cgi_site};
513
514        # Determine cgi_site using environment variables or hostname
515        if (my $host = ($ENV{'HTTP_HOST'} || $ENV{'SERVER_NAME'} || $ENV{'SSL_TLS_SNI'})) {
516                # Import necessary module
517                        require URI::Heuristic unless URI::Heuristic->can('uf_uristr');
518
519                $self->{cgi_site} = URI::Heuristic::uf_uristr($host);
520                # Remove trailing dots from the name.  They are legal in URLs
521                # and some sites link using them to avoid spoofing (nice)
522                $self->{cgi_site} =~ s/\.+$//;       # Trim trailing dots
523
524                if($ENV{'SERVER_NAME'} && ($host eq $ENV{'SERVER_NAME'}) && (my $protocol = $self->protocol()) && $self->protocol() ne 'http') {
525                        $self->{cgi_site} =~ s/^http/$protocol/;
526                }
527        } else {
528                # Import necessary module
529                require Sys::Hostname unless Sys::Hostname->can('hostname');
530
531                $self->_debug('Falling back to using hostname');
532                $self->{cgi_site} = Sys::Hostname::hostname();
533        }
534
535        # Set site details if not already defined
536        $self->{site} ||= $self->{cgi_site};
537        $self->{site} =~ s/^https?:\/\/(.+)/$1/;
538        $self->{cgi_site} = ($self->protocol() || 'http') . '://' . $self->{cgi_site}
539                unless $self->{cgi_site} =~ /^https?:\/\//;
540
541        # Warn if site details could not be determined
542        $self->_warn('Could not determine site name') unless($self->{site} && $self->{cgi_site});
543
544        # Log exit
545        $self->_trace('Leaving _find_site_details');
546
57
57
57
20431
52
503
}
547
548 - 555
=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
556
557sub domain_name {
558
23
241
        my $self = shift;
559
560
23
36
        if(!ref($self)) {
561
4
7
                $self = __PACKAGE__->new();
562        }
563
23
53
        return $self->{domain} if $self->{domain};
564
565
18
53
        $self->_find_site_details();
566
567
18
169
        if(my $site = $self->{site}) {
568
18
55
                $self->{domain} = ($site =~ /^www\.(.+)/) ? $1 : $site;
569        }
570
571
18
47
        return $self->{domain};
572}
573
574 - 578
=head2 cgi_host_url

Return the URL of the machine running the CGI script.

=cut
579
580sub cgi_host_url {
581
17
70
        my $self = shift;
582
583
17
29
        unless($self->{cgi_site}) {
584
11
37
                $self->_find_site_details();
585        }
586
587
17
187
        return $self->{cgi_site};
588}
589
590 - 745
=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
746
747sub params {
748
1194
11609
        my $self = shift;
749
750
1194
1400
        my $params = Params::Get::get_params(undef, @_);
751
752
1194
10900
        if((defined($self->{paramref})) && ((!defined($params->{'allow'})) || defined($self->{allow}) && ($params->{'allow'} eq $self->{allow}))) {
753
190
165
                return $self->{paramref};
754        }
755
756
1004
1285
        if(defined($params->{allow})) {
757
72
93
                $self->{allow} = $params->{allow};
758        }
759
1004
1096
        if(defined($params->{upload_dir})) {
760
9
12
                $self->{upload_dir} = $params->{upload_dir};
761        }
762
1004
1102
        if(defined($params->{'logger'})) {
763
2
6
                $self->set_logger($params->{'logger'});
764        }
765
1004
2024
        $self->_trace('Entering params');
766
767
1004
28485
        my @pairs;
768
1004
1043
        my $content_type = $ENV{'CONTENT_TYPE'};
769
1004
827
        my %FORM;
770
771
1004
2551
        if((!$ENV{'GATEWAY_INTERFACE'}) || (!$ENV{'REQUEST_METHOD'})) {
772                # require IO::Interactive;
773                # IO::Interactive->import();
774
775
585
891
                if(@ARGV) {
776
45
109
                        @pairs = @ARGV;
777
45
50
                        if(defined($pairs[0])) {
778
45
129
                                if($pairs[0] eq '--robot') {
779
6
9
                                        $self->{is_robot} = 1;
780
6
9
                                        shift @pairs;
781                                } elsif($pairs[0] eq '--mobile') {
782
7
11
                                        $self->{is_mobile} = 1;
783
7
10
                                        shift @pairs;
784                                } elsif($pairs[0] eq '--search-engine') {
785
6
12
                                        $self->{is_search_engine} = 1;
786
6
8
                                        shift @pairs;
787                                } elsif($pairs[0] eq '--tablet') {
788
6
8
                                        $self->{is_tablet} = 1;
789
6
7
                                        shift @pairs;
790                                }
791                        }
792                } elsif($stdin_data) {
793                        # Re-use previously read STDIN (class variable shared across instances)
794
0
0
                        @pairs = split(/\n/, $stdin_data);
795                }
796        } elsif(($ENV{'REQUEST_METHOD'} eq 'GET') || ($ENV{'REQUEST_METHOD'} eq 'HEAD')) {
797
319
467
                if(my $query = $ENV{'QUERY_STRING'}) {
798
310
422
                        if((defined($content_type)) && ($content_type =~ /multipart\/form-data/i)) {
799
4
6
                                if($ENV{'REMOTE_ADDR'}) {
800
2
7
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Multipart/form-data not supported for GET (query string = $query)" });
801                                } else {
802
2
4
                                        $self->_warn('Multipart/form-data not supported for GET');
803                                }
804
3
8
                                $self->status(501);  # Not implemented
805
3
6
                                return;
806                        }
807
306
370
                        $query =~ s/\\u0026/\&/g;
808
306
523
                        @pairs = split(/&/, $query);
809                } else {
810
9
25
                        return;
811                }
812        } elsif($ENV{'REQUEST_METHOD'} eq 'POST') {
813
79
171
                my $content_length = $self->_get_env('CONTENT_LENGTH');
814
79
231
                if((!defined($content_length)) || ($content_length =~ /\D/)) {
815
12
18
                        $self->{status} = 411;
816
12
23
                        return;
817                }
818
67
223
                if(($self->{max_upload_size} >= 0) && ($content_length > $self->{max_upload_size})) {       # Set maximum posts
819                        # TODO: Design a way to tell the caller to send HTTP
820                        # status 413
821
11
18
                        $self->{status} = 413;
822
11
23
                        $self->_warn('Large upload prohibited');
823
11
21
                        return;
824                }
825
826
56
270
                if((!defined($content_type)) || ($content_type =~ /application\/x-www-form-urlencoded/)) {
827
16
34
                        my $buffer;
828
16
23
                        if($stdin_data) {
829
12
15
                                $buffer = $stdin_data;
830                        } else {
831
4
28
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
832
1
531
                                        $self->_warn('POST failed: something else may have read STDIN');
833                                }
834
4
1617
                                $stdin_data = $buffer;
835                        }
836
16
30
                        @pairs = split(/&/, $buffer);
837
838                        # if($ENV{'QUERY_STRING'}) {
839                                # my @getpairs = split(/&/, $ENV{'QUERY_STRING'});
840                                # push(@pairs, @getpairs);
841                        # }
842                } elsif($content_type =~ /multipart\/form-data/i) {
843
25
51
                        if(!defined($self->{upload_dir})) {
844
3
5
                                if($ENV{'REMOTE_ADDR'}) {
845                                        # This could be an attack
846
1
5
                                        $self->_warn({ warning => "$ENV{REMOTE_ADDR}: Attempt to upload a file of $content_length bytes when upload_dir has not been set" });
847                                } else {
848
2
5
                                        $self->_warn({ warning => 'Attempt to upload a file when upload_dir has not been set' });
849                                }
850
2
7
                                $self->status(501);  # Not implemented
851
2
4
                                return;
852                        }
853
854                        # Validate 'upload_dir'
855                        # Ensure the upload directory is safe and accessible
856                        # - Check permissions
857                        # - Validate path to prevent directory traversal attacks
858                        # TODO: Consider using a temporary directory for uploads and moving them later
859
22
101
                        if(!File::Spec->file_name_is_absolute($self->{upload_dir})) {
860
5
21
                                $self->_warn({
861                                        warning => "upload_dir $self->{upload_dir} isn't a full pathname"
862                                });
863
4
11
                                $self->status(500);
864
4
4
                                delete $self->{upload_dir};
865
4
7
                                return;
866                        }
867
17
123
                        if(!-d $self->{upload_dir}) {
868
7
26
                                $self->_warn({
869                                        warning => "upload_dir $self->{upload_dir} isn't a directory"
870                                });
871
5
9
                                $self->status(500);
872
5
5
                                delete $self->{upload_dir};
873
5
10
                                return;
874                        }
875
10
64
                        if(!-w $self->{upload_dir}) {
876
2
3
                                delete $self->{paramref};
877
2
6
                                $self->_warn({
878                                        warning => "upload_dir $self->{upload_dir} isn't writeable"
879                                });
880
1
3
                                $self->status(500);
881
1
0
                                delete $self->{upload_dir};
882
1
2
                                return;
883                        }
884
8
19
                        my $tmpdir = $self->tmpdir();
885
8
63
                        if($self->{'upload_dir'} !~ /^\Q$tmpdir\E/) {
886                                $self->_warn({
887
1
4
                                        warning => 'upload_dir ' . $self->{'upload_dir'} . " isn't somewhere in the temporary area $tmpdir"
888                                });
889
1
2
                                $self->status(500);
890
1
2
                                delete $self->{upload_dir};
891
1
2
                                return;
892                        }
893
7
19
                        if($content_type =~ /boundary=(\S+)$/) {
894
7
23
                                @pairs = $self->_multipart_data({
895                                        length => $content_length,
896                                        boundary => $1
897                                });
898                        }
899                } elsif($content_type =~ /text\/xml/i) {
900
8
8
                        my $buffer;
901
8
13
                        if($stdin_data) {
902
6
7
                                $buffer = $stdin_data;
903                        } else {
904
2
8
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
905
0
0
                                        $self->_warn({
906                                                warning => 'XML failed: something else may have read STDIN'
907                                        });
908                                }
909
2
578
                                $stdin_data = $buffer;
910                        }
911
912
8
12
                        $FORM{XML} = $buffer;
913
914
8
14
                        $self->{paramref} = \%FORM;
915
916
8
18
                        return \%FORM;
917                } elsif($content_type =~ /application\/json/i) {
918
5
155
                        require JSON::MaybeXS && JSON::MaybeXS->import() unless JSON::MaybeXS->can('parse_json');
919                        # require JSON::MaybeXS;
920                        # JSON::MaybeXS->import();
921
922
5
6
                        my $buffer;
923
924
5
9
                        if($stdin_data) {
925
4
5
                                $buffer = $stdin_data;
926                        } else {
927
1
3
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
928
0
0
                                        $self->_warn({
929                                                warning => 'read failed: something else may have read STDIN'
930                                        });
931                                }
932
1
501
                                $stdin_data = $buffer;
933                        }
934                        # JSON::Parse::assert_valid_json($buffer);
935                        # my $paramref = JSON::Parse::parse_json($buffer);
936
5
79
                        my $paramref = decode_json($buffer);
937
4
4
4
6
                        foreach my $key(keys(%{$paramref})) {
938
8
14
                                push @pairs, "$key=" . $paramref->{$key};
939                        }
940                } else {
941
2
2
                        my $buffer;
942
2
4
                        if($stdin_data) {
943
1
1
                                $buffer = $stdin_data;
944                        } else {
945
1
2
                                if(read(STDIN, $buffer, $content_length) != $content_length) {
946
0
0
                                        $self->_warn({
947                                                warning => 'read failed: something else may have read STDIN'
948                                        });
949                                }
950
1
33
                                $stdin_data = $buffer;
951                        }
952
953
2
9
                        $self->_warn({
954                                warning => "POST: Invalid or unsupported content type: $content_type: $buffer",
955                        });
956                }
957        } elsif($ENV{'REQUEST_METHOD'} eq 'OPTIONS') {
958
7
13
                $self->{status} = 405;
959
7
13
                return;
960        } elsif($ENV{'REQUEST_METHOD'} eq 'DELETE') {
961
8
12
                $self->{status} = 405;
962
8
16
                return;
963        } else {
964                # TODO: Design a way to tell the caller to send HTTP
965                # status 501
966
6
10
                $self->{status} = 501;
967                $self->_warn({
968                        warning => 'Use POST, GET or HEAD, not ' . $ENV{REQUEST_METHOD}
969
6
21
                });
970        }
971
972
922
935
        unless(scalar @pairs) {
973
554
1464
                return;
974        }
975
976
368
5129
        require String::Clean::XSS;
977
368
99907
        String::Clean::XSS->import();
978        # require String::EscapeCage;
979        # String::EscapeCage->import();
980
981
368
410
        foreach my $arg (@pairs) {
982
2064
1830
                my($key, $value) = split(/=/, $arg, 2);
983
984
2064
1420
                next unless($key);
985
986
2054
1178
                $key =~ s/\0//g;        # Strip encoded NUL byte poison
987
2054
1109
                $key =~ s/%00//g;       # Strip NUL byte poison
988
2054
1
1095
3
                $key =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;
989
2054
1235
                $key =~ tr/+/ /;
990
2054
1318
                if(defined($value)) {
991
2052
1203
                        $value =~ s/%00//g;   # Strip encoded NUL byte poison
992
2052
240
1142
371
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;   # URL-decode (1st pass)
993
2052
9
1107
11
                        $value =~ s/%([a-fA-F\d][a-fA-F\d])/pack("C", hex($1))/eg;   # URL-decode (2nd pass: catches %252F -> %2F -> /)
994
2052
1006
                        $value =~ tr/+/ /;
995
2052
1107
                        $value =~ s/\0//g;    # Strip NUL: %2500 -> %00 -> NUL
996
2052
1156
                        $value =~ s/%00//g;   # Strip literal %00
997                } else {
998
2
2
                        $value = '';
999                }
1000
1001
2054
1569
                $key = _sanitise_input($key);
1002
1003
2054
123577
                if($self->{allow}) {
1004                        # Is this a permitted argument?
1005
184
200
                        if(!exists($self->{allow}->{$key})) {
1006
32
77
                                $self->_info("Discard unallowed argument '$key'");
1007
32
698
                                $self->status(422);
1008
32
30
                                next;   # Skip to the next parameter
1009                        }
1010
1011                        # Do we allow any value, or must it be validated?
1012
152
188
                        if(defined(my $schema = $self->{allow}->{$key})) {        # Get the schema for this key
1013
133
238
                                if(!ref($schema)) {
1014                                        # Can only contain one value
1015
11
24
                                        if($value ne $schema) {
1016
5
12
                                                $self->_info("Block $key = $value");
1017
5
103
                                                $self->status(422);
1018
5
9
                                                next;   # Skip to the next parameter
1019                                        }
1020                                } elsif(ref($schema) eq 'Regexp') {
1021
59
193
                                        if($value !~ $schema) {
1022                                                # Simple regex
1023
29
74
                                                $self->_info("Block $key = $value");
1024
29
491
                                                $self->status(422);
1025
29
41
                                                next;   # Skip to the next parameter
1026                                        }
1027                                } elsif(ref($schema) eq 'CODE') {
1028
28
70
                                        unless($schema->($key, $value, $self)) {
1029
11
147
                                                $self->_info("Block $key = $value");
1030
11
216
                                                next;
1031                                        }
1032                                } else {
1033                                        # Set of rules
1034
35
17
                                        eval {
1035                                                $value = Params::Validate::Strict::validate_strict({
1036                                                        schema => { $key => $schema },
1037                                                        args => { $key => $value },
1038                                                        unknown_parameter_handler => 'die',
1039
35
77
                                                        logger => $self->{'logger'}
1040                                                });
1041                                        };
1042
35
4760
                                        if($@) {
1043
9
44
                                                $self->_info("Block $key = $value: $@");
1044
9
264
                                                $self->status(422);
1045
9
11
                                                next;   # Skip to the next parameter
1046                                        }
1047
26
26
16
22
                                        if(scalar keys %{$value}) {
1048
26
21
                                                $value = $value->{$key};
1049                                        } else {
1050
0
0
                                                $self->_info("Block $key = $value");
1051
0
0
                                                $self->status(422);
1052
0
0
                                                next;   # Skip to the next parameter
1053                                        }
1054                                }
1055                        }
1056                }
1057
1058                # if($self->{expect} && (List::Util::none { $_ eq $key } @{$self->{expect}})) {
1059                        # next;
1060                # }
1061
1966
1400
                my $orig_value = $value;
1062
1966
1339
                $value = _sanitise_input($value);
1063
1064                # WAF: inspect all methods (GET and POST) for injection patterns.
1065                # Previously gated on GET only, which allowed POST to bypass all checks.
1066                {
1067                           # ($value =~ /\/AND\/.++\(SELECT\//) || # United/**/States)/**/AND/**/(SELECT/**/6734/**/FROM/**/(SELECT(SLEEP(5)))lRNi)/**/AND/**/(8984=8984
1068                        # From http://www.symantec.com/connect/articles/detection-sql-injection-and-cross-site-scripting-attacks
1069                        # Facebook FBCLID can have "--"
1070
1071                        # Pre-filter: only run quote-based regexes if value contains injection chars.
1072
1073                        # Compute pre-filter flags from orig_value so quotes stripped by
1074                        # convert_XSS don't cause injection patterns to be missed
1075
1076
1966
1966
99893
2283
                        my $has_quote  = index($orig_value, "'")    >= 0 || index($orig_value, '%27') >= 0;
1077
1966
1836
                        my $has_hash   = index($orig_value, '#')    >= 0 || index($orig_value, '%23') >= 0;
1078
1966
2293
                        my $has_equals = index($orig_value, '=')    >= 0 || index($orig_value, '%3D') >= 0;
1079
1966
1710
                        my $has_semi   = index($orig_value, ';')    >= 0 || index($orig_value, '%3B') >= 0;
1080
1966
1085
                        my $has_dash   = index($orig_value, '--')   >= 0;
1081
1082                        # All WAF patterns run on $orig_value (pre-XSS-sanitisation)
1083                        # convert_XSS encodes ', =, < etc. as HTML entities, which would hide
1084                        # injection patterns from the WAF if we checked $value instead.
1085
1966
2545
                        if($has_quote || $has_hash || ($has_equals && $has_dash)) {
1086
24
117
                                if(($orig_value =~ /(?:%27|'|%23|#)/i) ||
1087                                   (($has_equals && ($has_quote || $has_semi || $has_dash)) &&
1088                                   $orig_value =~ /(?:%3D|=)[^-]*+(?:%27|'|--|%3B|;)/i) ||
1089                                   ($has_quote &&
1090                                   # Detect 'or'-style injection: word + quote + url-encoded or literal 'or' + SQL keyword.
1091                                   # (?:%6F|o|%4F) = 'o', (?:%72|r|%52) = 'r', both case-folded via /i.
1092                                    $orig_value =~ /\w*(?:%27|')(?:%6F|o|%4F)(?:%72|r|%52)\s*(?:OR|AND|UNION|SELECT|--)/ix) ||
1093                                    ($has_quote &&
1094                                    $orig_value =~ /(?:%27|')union/ix)) {
1095
24
48
                                        $self->status(403);
1096
24
34
                                        if($ENV{'REMOTE_ADDR'}) {
1097
5
19
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
1098                                        } else {
1099
19
50
                                                $self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
1100                                        }
1101
24
65
                                        return;
1102                                }
1103                        }
1104
1105
1942
1730
                        my $has_select = index($orig_value, 'SELECT') >= 0 || index($orig_value, 'select') >= 0;
1106
1942
1045
                        my $has_dump   = index($orig_value, 'var_dump') >= 0;
1107
1942
946
                        my $has_exec   = index($orig_value, 'exec') >= 0;
1108
1942
996
                        my $has_or  = index($orig_value, ' OR ')  >= 0;
1109
1942
1004
                        my $has_and = index($orig_value, ' AND ') >= 0;
1110
1942
1738
                        my $has_slash  = index($orig_value, '/**/') >= 0 || index($orig_value, '/AND/') >= 0;
1111
1112
1942
7605
                        if(# \b anchors prevent matching inside longer words.
1113                           # {1,500}? is lazy+bounded: avoids catastrophic backtracking on
1114                           # "SELECT aaaa...aaaa" (no FROM) while still catching real queries.
1115                           ($has_select && $orig_value =~ /\bselect\b.{1,500}?\bfrom\b/is) ||
1116                           ($has_and    && $orig_value =~ /\sAND\s1=1/ix) ||
1117                           # Numeric tautology without quotes: OR 1=1, OR 2=2, etc.
1118                           ($has_or     && $orig_value =~ /\bOR\s+\d+\s*=\s*\d+/i) ||
1119                           # Bounded lazy .{1,500}? avoids backtracking on "OR aaaa..." with no AND.
1120                           ($has_or && $has_and && $orig_value =~ /\sOR\s.{1,500}?\sAND\s/) ||
1121                           ($has_slash  && $orig_value =~ /\/\*\*\/ORDER\/\*\*\/BY\/\*\*/ix) ||
1122                           ($has_dump   && $orig_value =~ /var_dump[^m]*+md5/) ||
1123                           ($has_slash  && $has_select && $orig_value =~ /\/AND\/[^(]*+\(SELECT\//) ||
1124                           ($has_exec   && $orig_value =~ /exec[\s+]++[sx]p\w+/ix)) {
1125
18
33
                                $self->status(403);
1126
18
22
                                if($ENV{'REMOTE_ADDR'}) {
1127
6
15
                                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
1128                                } else {
1129
12
29
                                        $self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
1130                                }
1131
18
38
                                return;
1132                        }
1133
1134
1924
1390
                        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1135                        # Bounded lazy .{1,500}? separates SQL keyword pairs without catastrophic backtracking.
1136                        # Possessive .++ would consume the trailing anchor — never match. Unbounded .+ risks ReDoS.
1137
36
369
                        if(($agent =~ /\bSELECT\b.{1,500}?\bAND\b/i) || ($agent =~ /\bORDER\s+BY\b/i) || ($agent =~ /\bOR\s+NOT\b/i) || ($agent =~ /\bAND\b\s+\d+=\d+/) || ($agent =~ /\bTHEN\b.{1,300}?\bELSE\b.{1,300}?\bEND\b/i) || ($agent =~ /\bAND\b.{1,500}?\bSELECT\b/i) || ($agent =~ /\sAND\s.{1,500}?\sAND\s/)) {
1138
3
7
                                        $self->status(403);
1139
3
8
                                        if($ENV{'REMOTE_ADDR'}) {
1140
2
7
                                                $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1141                                        } else {
1142
1
5
                                                $self->_warn("SQL injection attempt blocked for '$agent'");
1143                                        }
1144
3
7
                                        return;
1145                                }
1146                        }
1147
1148                        # XSS detection using [^>]+ instead of .+ or .++ :
1149                        #   - [^>]+ stops naturally at '>' — no backtracking, no ReDoS.
1150                        #   - [^>] also matches '\n', so multi-line payloads like
1151                        #     "<img\nsrc=x\nonerror=alert(1)>" are caught without /s.
1152                        #   - Replaces both the old [^\n]+ (stopped at newline — bypass)
1153                        #     and the broken .++ (possessive consumed '>' — never matched).
1154
1921
5041
                        if(($value =~ /(?:%3C|<)(?:%2F|\/)*[a-z0-9%]+(?:%3E|>)/ix) ||
1155                           ($value =~ /(?:%3C|<)[^>]+(?:%3E|>)/i) ||
1156                           ($orig_value =~ /(?:%3C|<)(?:%2F|\/)*[a-z0-9%]+(?:%3E|>)/ix) ||
1157                           ($orig_value =~ /(?:%3C|<)[^>]+(?:%3E|>)/i)) {
1158
22
41
                                $self->status(403);
1159
22
55
                                $self->_warn("XSS injection attempt blocked for '$value'");
1160
22
49
                                return;
1161                        }
1162
1163                        # Block javascript: URI scheme — no angle brackets, but still executes
1164                        # script when used in href or src attributes.
1165
1899
1240
                        if($orig_value =~ /\bjavascript\s*:/i) {
1166
2
4
                                $self->status(403);
1167
2
5
                                $self->_warn("XSS injection attempt blocked for '$value'");
1168
2
4
                                return;
1169                        }
1170
1171
1897
1219
                        if($value =~ /mustleak\.com\//) {
1172
7
33
                                $self->status(403);
1173
7
17
                                $self->_warn("Blocked mustleak attack for '$key'");
1174
7
17
                                return;
1175                        }
1176
1177
1890
1675
                        if($value =~ /\.\.\//) {
1178
15
29
                                $self->status(403);
1179
15
34
                                $self->_warn("Blocked directory traversal attack for '$key'");
1180
14
32
                                return;
1181                        }
1182                }
1183
1875
1215
                if(length($value) > 0) {
1184                        # Don't add if it's already there
1185
1863
1671
                        if($FORM{$key} && ($FORM{$key} ne $value)) {
1186
9
14
                                $FORM{$key} .= ",$value";
1187                        } else {
1188
1854
2481
                                $FORM{$key} = $value;
1189                        }
1190                }
1191        }
1192
1193
275
273
        unless(%FORM) {
1194
40
92
                return;
1195        }
1196
1197
235
289
        if($self->{'logger'}) {
1198
235
404
                while(my ($key,$value) = each %FORM) {
1199
1844
33455
                        $self->_debug("$key=$value");
1200                }
1201        }
1202
1203
235
4530
        $self->{paramref} = \%FORM;
1204
1205
235
519
        return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
1206}
1207
1208 - 1256
=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
1257
1258sub param {
1259
88
11581
        my ($self, $field) = @_;
1260
1261
88
128
        if(!defined($field)) {
1262
6
12
                return $self->params();
1263        }
1264        # Is this a permitted argument?
1265
82
175
        if($self->{allow} && !exists($self->{allow}->{$field})) {
1266
12
42
                $self->_warn({
1267                        warning => "param: $field isn't in the allow list"
1268                });
1269
8
29
                return;
1270        }
1271
1272        # Prevent deep recursion which can happen when a validation routine calls param()
1273
70
55
        my $allow;
1274
70
111
        if($self->{in_param} && $self->{allow}) {
1275
4
5
                $allow = delete $self->{allow};
1276        }
1277
70
65
        $self->{in_param} = 1;
1278
1279
70
125
        my $params = $self->params();
1280
1281
70
2001
        $self->{in_param} = 0;
1282
70
81
        $self->{allow} = $allow if($allow);
1283
1284
70
106
        if($params) {
1285
63
98
                return Return::Set::set_return($params->{$field}, { type => 'string' });
1286        }
1287}
1288
1289sub _sanitise_input :Protected {
1290        my $arg = shift;
1291
1292        # Protected function: inline check because the ($) prototype means no $self,
1293        unless($ENV{HARNESS_ACTIVE}) {
1294                my $calling_pkg = (caller)[0];
1295                unless($calling_pkg && ($calling_pkg eq __PACKAGE__ || $calling_pkg->isa(__PACKAGE__))) {
1296                        Carp::croak('_sanitise_input() is a protected function and cannot be called from outside ' . __PACKAGE__);
1297                }
1298        }
1299
1300        return if(!defined($arg));
1301
1302        # Remove hacking attempts and spaces
1303        $arg =~ s/[\r\n]//g;
1304        $arg =~ s/\s+$//;
1305        $arg =~ s/^\s+//;
1306
1307        # Possessive quantifier prevents catastrophic backtracking when input
1308        # contains '<!--' with no matching closing '-->'.
1309        $arg =~ s/<!--[^-]*+(?:-(?!->)[^-]*+)*+-->//g;
1310        # Allow :
1311        # $arg =~ s/[;<>\*|`&\$!?#\(\)\[\]\{\}'"\\\r]//g;
1312
1313        # return $arg;
1314        # return String::EscapeCage->new(convert_XSS($arg))->escapecstring();
1315        return convert_XSS($arg);
1316
57
57
57
115176
86
710
}
1317
1318sub _multipart_data :Protected {
1319        my ($self, $args) = @_;
1320
1321        $self->_trace('Entering _multipart_data');
1322
1323        my $total_bytes = $$args{length};
1324
1325        $self->_debug("_multipart_data: total_bytes = $total_bytes");
1326
1327        if($total_bytes == 0) {
1328                return;
1329        }
1330
1331        unless($stdin_data) {
1332                while(<STDIN>) {
1333                        chop(my $line = $_);
1334                        $line =~ s/[\r\n]//g;
1335                        $stdin_data .= "$line\n";
1336                }
1337                if(!$stdin_data) {
1338                        return;
1339                }
1340        }
1341
1342        my $boundary = $$args{boundary};
1343
1344        my @pairs;
1345        my $writing_file = 0;
1346        my $key;
1347        my $value;
1348        my $in_header = 0;
1349        my $fout;
1350
1351        foreach my $line(split(/\n/, $stdin_data)) {
1352                if($line =~ /^--\Q$boundary\E--$/) {
1353                        last;
1354                }
1355                if($line =~ /^--\Q$boundary\E$/) {
1356                        if($writing_file) {
1357                                close $fout;
1358                                $writing_file = 0;
1359                        } elsif(defined($key)) {
1360                                push(@pairs, "$key=$value");
1361                                $value = undef;
1362                        }
1363                        $in_header = 1;
1364                } elsif($in_header) {
1365                        if(length($line) == 0) {
1366                                $in_header = 0;
1367                        } elsif($line =~ /^Content-Disposition: (.+)/i) {
1368                                my $field = $1;
1369                                if($field =~ /name="(.+?)"/) {
1370                                        $key = $1;
1371                                }
1372                                # [^"]+ instead of .+ : stops at first '"' without backtracking,
1373                                # and cannot accidentally capture across the closing delimiter.
1374                                if($field =~ /filename="([^"]+)?"/) {
1375                                        my $filename = $1;
1376                                        unless(defined($filename)) {
1377                                                $self->_warn('No upload filename given');
1378                                        } elsif($filename =~ /[\\\/\|]/) {
1379                                                $self->_warn("Disallowing invalid filename: $filename");
1380                                        } else {
1381                                                $filename = $self->_create_file_name({
1382                                                        filename => $filename
1383                                                });
1384
1385                                                # Don't do this since it taints the string and I can't work out how to untaint it
1386                                                # my $full_path = Cwd::realpath(File::Spec->catfile($self->{upload_dir}, $filename));
1387                                                # $full_path =~ m/^(\/[\w\.]+)$/;
1388                                                my $full_path = File::Spec->catfile($self->{upload_dir}, $filename);
1389                                                unless(open($fout, '>', $full_path)) {
1390                                                        $self->_warn("Can't open $full_path");
1391                                                }
1392                                                $writing_file = 1;
1393                                                push(@pairs, "$key=$filename");
1394                                        }
1395                                }
1396                        }
1397                        # TODO: handle Content-Type: text/plain, etc.
1398                } else {
1399                        if($writing_file) {
1400                                print $fout "$line\n";
1401                        } else {
1402                                $value .= $line;
1403                        }
1404                }
1405        }
1406
1407        if($writing_file) {
1408                close $fout;
1409        }
1410
1411        $self->_trace('Leaving _multipart_data');
1412
1413        return @pairs;
1414
57
57
57
24415
55
543
}
1415
1416# Robust filename generation (preventing overwriting).
1417# Previously used "! -e $rc" which checked existence in the CURRENT WORKING
1418# DIRECTORY, not the upload directory — a logic bug and a TOCTOU race.
1419# Now checks in the actual upload directory and caps iterations to avoid
1420# an infinite loop if the directory fills up.
1421sub _create_file_name :Protected {
1422        my ($self, $args) = @_;
1423
1424        my $upload_dir = $self->{upload_dir};
1425        my $filename   = $$args{filename} . '_' . time;
1426
1427        my $counter = 0;
1428        my $rc;
1429        do {
1430                $rc = $filename . ($counter ? "_$counter" : '');
1431                $counter++;
1432                # Check in upload_dir when set; otherwise check relative to CWD.
1433                # File::Spec->catfile('', ...) produces an absolute path, so we
1434                # must not pass an empty string as the directory component.
1435        } until(
1436                ! -e ($upload_dir ? File::Spec->catfile($upload_dir, $rc) : $rc)
1437                || $counter > 1000
1438        );
1439        if($counter > 1000) {
1440                Carp::croak('_create_file_name: unable to find a unique filename after 1000 attempts');
1441        }
1442
1443        return $rc;
1444
57
57
57
8461
47
398
}
1445
1446# Untaint a filename. Regex from CGI::Untaint::Filenames
1447sub _untaint_filename :Protected {
1448        my ($self, $args) = @_;
1449
1450        if($$args{filename} =~ /(^[\w\+_\040\#\(\)\{\}\[\]\/\-\^,\.:;&%@\\~]+\$?$)/) {
1451                return $1;
1452        }
1453        return;
1454
57
57
57
10140
48
412
}
1455
1456 - 1464
=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
1465
1466sub is_mobile {
1467
225
2838
        my $self = shift;
1468
1469
225
280
        if(defined($self->{is_mobile})) {
1470
126
95
                return $self->{is_mobile};
1471        }
1472
1473
99
165
        if($ENV{'IS_MOBILE'}) {
1474
4
11
                return $ENV{'IS_MOBILE'}
1475        }
1476
1477        # Support Sec-CH-UA-Mobile
1478
95
156
        if(my $ch_ua_mobile = $ENV{'HTTP_SEC_CH_UA_MOBILE'}) {
1479
12
21
                if($ch_ua_mobile eq '?1') {
1480
5
6
                        $self->{is_mobile} = 1;
1481
5
13
                        return 1;
1482                }
1483        }
1484
1485
90
142
        if($ENV{'HTTP_X_WAP_PROFILE'}) {
1486                # E.g. Blackberry
1487                # TODO: Check the sanity of this variable
1488
3
4
                $self->{is_mobile} = 1;
1489
3
8
                return 1;
1490        }
1491
1492
87
153
        if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
1493                # Was '.+(Android|iPhone).+' — .+ before and after adds no useful
1494                # constraint but causes ReDoS on long UAs without those tokens.
1495
67
249
                if($agent =~ /\b(?:Android|iPhone)\b/) {
1496
18
26
                        $self->{is_mobile} = 1;
1497
18
41
                        return 1;
1498                }
1499
1500                # From http://detectmobilebrowsers.com/
1501
49
2584
                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) {
1502
1
1
                        $self->{is_mobile} = 1;
1503
1
1
                        return 1;
1504                }
1505
1506                # Save loading and calling HTTP::BrowserDetect
1507
48
65
                my $remote = $ENV{'REMOTE_ADDR'};
1508
48
115
                if(defined($remote) && $self->{cache}) {
1509
2
6
                        if(my $type = $self->{cache}->get("$remote/$agent")) {
1510
2
10
                                return $self->{is_mobile} = ($type eq 'mobile');
1511                        }
1512                }
1513
1514
46
66
                unless($self->{browser_detect}) {
1515
37
37
32
4416
                        if(eval { require HTTP::BrowserDetect; }) {
1516
37
72407
                                HTTP::BrowserDetect->import();
1517
37
81
                                $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1518                        }
1519                }
1520
1521
46
4070
                if($self->{browser_detect}) {
1522
46
103
                        my $device = $self->{browser_detect}->device();
1523                        # Without the ?1:0 it will set to the empty string not 0
1524
46
230
                        my $is_mobile = (defined($device) && ($device =~ /blackberry|webos|iphone|ipod|ipad|android/i)) ? 1 : 0;
1525
46
77
                        if($is_mobile && $self->{cache} && defined($remote)) {
1526
0
0
                                $self->{cache}->set("$remote/$agent", 'mobile', $CACHE_TTL_SEARCH);
1527                        }
1528
46
117
                        return $self->{is_mobile} = $is_mobile;
1529                }
1530        }
1531
1532
20
62
        return 0;
1533}
1534
1535 - 1539
=head2 is_tablet

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

=cut
1540
1541sub is_tablet {
1542
20
428
        my $self = shift;
1543
1544
20
36
        if(defined($self->{is_tablet})) {
1545
4
10
                return $self->{is_tablet};
1546        }
1547
1548        # Was '.+(iPad|TabletPC).+' — same ReDoS risk as the mobile pattern above.
1549
16
84
        if($ENV{'HTTP_USER_AGENT'} && ($ENV{'HTTP_USER_AGENT'} =~ /\b(?:iPad|TabletPC)\b/)) {
1550                # TODO: add others when I see some nice user_agents
1551
5
10
                $self->{is_tablet} = 1;
1552        } else {
1553
11
13
                $self->{is_tablet} = 0;
1554        }
1555
1556
16
53
        return $self->{is_tablet};
1557}
1558
1559 - 1585
=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
1586
1587sub as_string
1588{
1589
899
10682
        my $self = shift;
1590
1591
899
1465
        my $args = Params::Validate::Strict::validate_strict({
1592                args => Params::Get::get_params(undef, @_) || {},
1593                schema => {
1594                        raw => {
1595                                'type' => 'boolean',
1596                                'optional' => 1
1597                        }
1598                }
1599        });
1600
1601        # Retrieve object parameters
1602
594
47336
        my $params = $self->params() || return '';
1603
1604
47
295
        my $rc;
1605
1606
47
75
        if($args->{'raw'}) {
1607                # Raw mode: return key=value pairs without escaping
1608                $rc = join '; ', map {
1609
10
25
                        "$_=" . $params->{$_}
1610
7
7
10
10
                } sort keys %{$params};
1611        } else {
1612                # Escaped mode: escape special characters
1613                $rc = join '; ', map {
1614
62
58
                        my $value = $params->{$_};
1615
1616
62
59
                        $value =~ s/\\/\\\\/g;  # Escape backslashes
1617
62
105
                        $value =~ s/(;|=)/\\$1/g;       # Escape semicolons and equals signs
1618
62
98
                        "$_=$value"
1619
40
40
42
64
                } sort keys %{$params};
1620        }
1621
1622
47
64
        $rc ||= '';
1623
1624
47
101
        $self->_trace("as_string: returning '$rc'");
1625
1626
47
791
        return $rc;
1627}
1628
1629 - 1634
=head2 protocol

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

=cut
1635
1636sub protocol {
1637
67
1060
        my $self = shift;
1638
1639        # Cached: ENV is read-only during a CGI request, so the result never changes.
1640        # Use exists (not defined) so we cache undef for the "unknown" case too.
1641        # Guard with ref() because protocol() may be called as a class method.
1642
67
170
        return $self->{'protocol'} if ref($self) && exists $self->{'protocol'};
1643
1644        # RFC 3986 §3.1: scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
1645        # Character-class [^:]+ avoids the O(n) backtracking of the former (.+)://
1646
61
52
        my $result;
1647
61
298
        if($ENV{'SCRIPT_URI'} && ($ENV{'SCRIPT_URI'} =~ /^([a-zA-Z][a-zA-Z0-9+\-.]*):\/\//)) {
1648
9
16
                $result = $1;
1649        } elsif($ENV{'SERVER_PROTOCOL'} && ($ENV{'SERVER_PROTOCOL'} =~ /^HTTP\//)) {
1650
7
8
                $result = 'http';
1651        } elsif(my $port = $ENV{'SERVER_PORT'}) {
1652
22
1688
                if(defined(my $name = getservbyport($port, 'tcp'))) {
1653
18
58
                        if($name =~ /^https?$/) {
1654
14
15
                                $result = $name;
1655                        } elsif($name eq 'www') {
1656                                # e.g. NetBSD and OpenBSD
1657
0
0
                                $result = 'http';
1658                        }
1659                        # else: unrecognised service name — $result stays undef
1660                } elsif($port == 80) {
1661                        # e.g. Solaris
1662
0
0
                        $result = 'http';
1663                } elsif($port == 443) {
1664
0
0
                        $result = 'https';
1665                }
1666        }
1667
1668
61
509
        if(!defined($result) && $ENV{'REMOTE_ADDR'}) {
1669
2
6
                $self->_warn("Can't determine the calling protocol");
1670        }
1671
61
130
        $self->{'protocol'} = $result if ref($self);
1672
61
132
        return $result;
1673}
1674
1675 - 1700
=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
1701
1702sub tmpdir {
1703
48
1644
        my $self = shift;
1704
1705
48
48
        my $name = 'tmp';
1706
48
95
        if($^O eq 'MSWin32') {
1707
0
0
                $name = 'temp';
1708        }
1709
1710
48
35
        my $dir;
1711
1712
48
65
        if(!ref($self)) {
1713
5
6
                $self = __PACKAGE__->new();
1714        }
1715
48
93
        my $params = Params::Get::get_params(undef, @_);
1716
1717
48
573
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1718
9
29
                $dir = File::Spec->catdir($ENV{'C_DOCUMENT_ROOT'}, $name);
1719
9
100
                if((-d $dir) && (-w $dir)) {
1720
3
10
                        return $self->_untaint_filename({ filename => $dir });
1721                }
1722
6
7
                $dir = $ENV{'C_DOCUMENT_ROOT'};
1723
6
45
                if((-d $dir) && (-w $dir)) {
1724
6
19
                        return $self->_untaint_filename({ filename => $dir });
1725                }
1726        }
1727
39
105
        if($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1728
2
20
                $dir = File::Spec->catdir($ENV{'DOCUMENT_ROOT'}, File::Spec->updir(), $name);
1729
2
27
                if((-d $dir) && (-w $dir)) {
1730
1
3
                        return $self->_untaint_filename({ filename => $dir });
1731                }
1732        }
1733
38
75
        if($params->{'default'} && ref($params->{'default'})) {
1734
2
148
                croak(ref($self), ': tmpdir must be given a scalar');
1735        }
1736
36
564
        return $params->{default} ? $params->{default} : File::Spec->tmpdir();
1737}
1738
1739 - 1751
=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
1752
1753sub rootdir {
1754
36
2626
        if($ENV{'C_DOCUMENT_ROOT'} && (-d $ENV{'C_DOCUMENT_ROOT'})) {
1755
15
30
                return $ENV{'C_DOCUMENT_ROOT'};
1756        } elsif($ENV{'DOCUMENT_ROOT'} && (-d $ENV{'DOCUMENT_ROOT'})) {
1757
9
46
                return $ENV{'DOCUMENT_ROOT'};
1758        }
1759        # $0 is tainted under -T; untaint with a permissive but defined capture.
1760        # The leading . before cgi-bin was also a regex bug (matched any char);
1761        # corrected to match a path separator so the kludge fires only on real paths.
1762
12
25
        my ($script_name) = $0 =~ /^(.+)$/;
1763
12
12
        return '' unless defined $script_name;
1764
1765
12
56
        unless(File::Spec->file_name_is_absolute($script_name)) {
1766
12
87
                $script_name = File::Spec->rel2abs($script_name);
1767        }
1768
12
19
        if($script_name =~ /[\/\\]cgi-bin/) {
1769
0
0
                $script_name =~ s/[\/\\]cgi-bin.*//;
1770        }
1771
12
51
        if(-f $script_name) {   # More kludge
1772
12
28
                if($^O eq 'MSWin32') {
1773
0
0
                        if($script_name =~ /(.+)\\.+?$/) {
1774
0
0
                                return $1;
1775                        }
1776                } else {
1777
12
26
                        if($script_name =~ /(.+)\/.+?$/) {
1778
12
19
                                return $1;
1779                        }
1780                }
1781        }
1782
0
0
        return $script_name;
1783}
1784
1785 - 1789
=head2 root_dir

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

=cut
1790
1791sub root_dir
1792{
1793
9
793
        if($_[0] && ref($_[0])) {
1794
4
3
                my $self = shift;
1795
1796
4
9
                return $self->rootdir(@_);
1797        }
1798
5
13
        return __PACKAGE__->rootdir(@_);
1799}
1800
1801 - 1805
=head2 documentroot

Synonym of rootdir(), for compatibility with Apache.

=cut
1806
1807sub documentroot
1808{
1809
8
370
        if($_[0] && ref($_[0])) {
1810
3
4
                my $self = shift;
1811
1812
3
6
                return $self->rootdir(@_);
1813        }
1814
5
7
        return __PACKAGE__->rootdir(@_);
1815}
1816
1817 - 1829
=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
1830
1831sub logdir {
1832
22
1357
        my $self = shift;
1833
22
24
        my $dir = shift;
1834
1835
22
42
        if(!ref($self)) {
1836
1
2
                $self = __PACKAGE__->new();
1837        }
1838
1839
22
37
        if($dir) {
1840
12
124
                if(length($dir) && (-d $dir) && (-w $dir)) {
1841
5
13
                        return $self->{'logdir'} = $dir;
1842                }
1843
7
32
                $self->_warn("Invalid logdir: $dir");
1844
7
336
                Carp::croak("Invalid logdir: $dir");
1845        }
1846
1847
10
87
        foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
1848
26
159
                if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
1849
10
8
                        $dir = $rc;
1850
10
10
                        last;
1851                }
1852        }
1853
10
27
        $self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
1854
10
22
        $self->{logdir} ||= $dir;
1855
1856
10
13
        return $dir;
1857}
1858
1859 - 1874
=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
1875
1876sub is_robot {
1877
103
1696
        my $self = shift;
1878
1879
103
159
        if(defined($self->{is_robot})) {
1880
31
59
                return $self->{is_robot};
1881        }
1882
1883
72
82
        my $agent = $ENV{'HTTP_USER_AGENT'};
1884
72
76
        my $remote = $ENV{'REMOTE_ADDR'};
1885
1886
72
165
        unless($remote && $agent) {
1887                # Probably not running in CGI - assume real person
1888
12
22
                return 0;
1889        }
1890
1891        # SQL injection check MUST run before is_ai(): a WAF block must never be
1892        # bypassed just because the UA also identifies itself as an AI crawler.
1893        # See also params() — patterns here MUST stay in sync with those in params().
1894        # Bounded-lazy .{1,N}? replaces unbounded .+ to prevent O(n²/n³) backtracking
1895        # on long UAs that contain SQL keywords but not the complete injection sequence.
1896        # \b word boundaries prevent false positives on tokens like "SELECTFOO".
1897
60
843
        if(($agent =~ /\bSELECT\b.{1,500}?\bAND\b/i)         ||
1898           ($agent =~ /\bORDER\s+BY\b/i)                      ||
1899           ($agent =~ /\bOR\s+NOT\b/i)                        ||
1900           ($agent =~ /\bAND\b\s+\d+=\d+/)                    ||
1901           ($agent =~ /\bTHEN\b.{1,300}?\bELSE\b.{1,300}?\bEND\b/i) ||
1902           ($agent =~ /\bAND\b.{1,500}?\bSELECT\b/i)         ||
1903           ($agent =~ /\sAND\s.{1,500}?\sAND\s/)) {
1904
11
26
                $self->status(403);
1905
11
13
                $self->{is_robot} = 1;
1906
11
24
                if($ENV{'REMOTE_ADDR'}) {
1907
11
43
                        $self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
1908                } else {
1909
0
0
                        $self->_warn("SQL injection attempt blocked for '$agent'");
1910                }
1911
11
23
                return 1;
1912        }
1913
1914        # is_ai implies is_robot: check AI crawlers before the generic bot regex so
1915        # that UAs like ChatGPT-User or Google-Extended (no "bot"/"spider" token)
1916        # are still caught here.
1917
49
96
        if($self->is_ai()) {
1918
8
20
                return $self->{is_robot} = 1;
1919        }
1920        # '.+bot' was replaced with '\bbot\b' — the leading .+ caused catastrophic
1921        # backtracking on long UAs that contain no 'bot' substring.
1922
41
1012
        if($agent =~ /\bbot\b|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) {
1923
8
10
                $self->{is_robot} = 1;
1924
8
13
                return 1;
1925        }
1926
1927        # TODO:
1928        # Download and use list from
1929        #       https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
1930
1931
33
47
        my $key = "$remote/$agent";
1932
1933        # Check the shared cache BEFORE the referrer scan: the 29-domain referrer
1934        # check is the most expensive path in is_robot() and is unnecessary when a
1935        # prior request already classified this remote/agent pair.
1936        # The SQL injection check above MUST remain before this (security gate).
1937
33
58
        if($self->{cache}) {
1938
2
5
                if(my $type = $self->{cache}->get($key)) {
1939
2
9
                        return $self->{is_robot} = ($type eq 'robot');
1940                }
1941        }
1942
1943
31
46
        if(my $referrer = $ENV{'HTTP_REFERER'}) {
1944                # $CRAWLER_REFERER_RE is compiled once at module load (see top of file):
1945                # replaces List::Util::any { /^\Q$_\E/i } @crawler_lists (29 per-call
1946                # regex compilations + array allocation eliminated).
1947
10
16
                $referrer =~ s/\\/_/g;
1948
10
35
                if(($referrer =~ /\)/) || ($referrer =~ $CRAWLER_REFERER_RE)) {
1949
9
62
                        $self->_debug("is_robot: blocked trawler $referrer");
1950
9
138
                        if($self->{cache}) {
1951
0
0
                                $self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
1952                        }
1953
9
12
                        $self->{is_robot} = 1;
1954
9
20
                        return 1;
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
22
81
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
1961                # Mark Facebook as a search engine, not a robot
1962
1
2
                if($self->{cache}) {
1963
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
1964                }
1965
1
3
                return 0;
1966        }
1967
1968
21
46
        unless($self->{browser_detect}) {
1969
10
10
9
1481
                if(eval { require HTTP::BrowserDetect; }) {
1970
10
23853
                        HTTP::BrowserDetect->import();
1971
10
17
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
1972                }
1973        }
1974
21
1015
        if($self->{browser_detect}) {
1975
21
53
                my $is_robot = $self->{browser_detect}->robot();
1976
21
2417
                if(defined($is_robot)) {
1977
5
18
                        $self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
1978                }
1979
21
188
                $is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
1980
21
78
                $self->_debug("is_robot: $is_robot");
1981
1982
21
508
                if($is_robot) {
1983
5
6
                        if($self->{cache}) {
1984
0
0
                                $self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
1985                        }
1986
5
5
                        $self->{is_robot} = $is_robot;
1987
5
12
                        return $is_robot;
1988                }
1989        }
1990
1991
16
29
        if($self->{cache}) {
1992
0
0
                $self->{cache}->set($key, 'unknown', $CACHE_TTL_ROBOT);
1993        }
1994
16
21
        $self->{is_robot} = 0;
1995
16
32
        return 0;
1996}
1997
1998 - 2010
=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
2011
2012sub is_search_engine
2013{
2014
69
1040
        my $self = shift;
2015
2016
69
124
        if(defined($self->{is_search_engine})) {
2017
11
22
                return $self->{is_search_engine};
2018        }
2019
2020
58
91
        if($ENV{'IS_SEARCH_ENGINE'}) {
2021
5
12
                return $ENV{'IS_SEARCH_ENGINE'}
2022        }
2023
2024
53
52
        my $remote = $ENV{'REMOTE_ADDR'};
2025
53
58
        my $agent = $ENV{'HTTP_USER_AGENT'};
2026
2027
53
122
        unless($remote && $agent) {
2028                # Probably not running in CGI - assume not a search engine
2029
15
26
                return 0;
2030        }
2031
2032        # Build the cache key once; reuse $key for every subsequent set() call.
2033
38
55
        my $key = "$remote/$agent";
2034
2035
38
54
        if($self->{cache}) {
2036
1
3
                if(my $type = $self->{cache}->get($key)) {
2037                        # Write to is_search_engine (not the old is_search typo) so the
2038                        # instance-level guard at the top of this method actually fires on
2039                        # the next call, avoiding a redundant shared-cache round-trip.
2040
1
6
                        return $self->{is_search_engine} = ($type eq 'search');
2041                }
2042        }
2043
2044        # Don't use HTTP_USER_AGENT to detect more than we really have to since
2045        # that is easily spoofed
2046
37
118
        if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
2047                # Mark Facebook as a search engine, not a robot
2048
3
4
                if($self->{cache}) {
2049
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
2050                }
2051
3
5
                return 1;
2052        }
2053
2054
34
51
        unless($self->{browser_detect}) {
2055
16
16
13
514
                if(eval { require HTTP::BrowserDetect; }) {
2056
16
8110
                        HTTP::BrowserDetect->import();
2057
16
34
                        $self->{browser_detect} = HTTP::BrowserDetect->new($agent);
2058                }
2059        }
2060
34
1711
        if(my $browser = $self->{browser_detect}) {
2061
34
70
                my $is_search = ($browser->google() || $browser->msn() || $browser->baidu() || $browser->altavista() || $browser->yahoo() || $browser->bingbot());
2062
34
5585
                if(!$is_search) {
2063
24
131
                        if(($agent =~ /SeznamBot\//) ||
2064                           ($agent =~ /Google-InspectionTool\//) ||
2065                           ($agent =~ /Googlebot\//)) {
2066
3
3
                                $is_search = 1;
2067                        }
2068                }
2069
34
60
                if($is_search && $self->{cache}) {
2070
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
2071                }
2072
34
89
                return $self->{is_search_engine} = $is_search;
2073        }
2074
2075        # Untaint $remote before passing to inet_aton: under -T, tainted data
2076        # causes a fatal "Insecure dependency" in inet_aton.  The strict IPv4
2077        # regex also rejects any non-address garbage that could reach this path.
2078
0
0
        my ($safe_remote) = $remote =~ /^(\d{1,3}(?:\.\d{1,3}){3})$/;
2079
0
0
        unless(defined $safe_remote) {
2080
0
0
                $self->{is_search_engine} = 0;
2081
0
0
                return 0;
2082        }
2083        # TODO: DNS lookup, not gethostbyaddr - though that will be slow
2084
0
0
        my $hostname = gethostbyaddr(inet_aton($safe_remote), AF_INET) || $safe_remote;
2085
2086
0
0
        my @cidr_blocks = ('47.235.0.0/12');    # Alibaba
2087
2088        # \b word boundaries prevent false positives like "notgoogle.example.com".
2089        # /i because DNS hostnames are case-insensitive.
2090
0
0
        if((defined($hostname) && ($hostname =~ /\b(?:google|msnbot|bingbot|amazonbot|GPTBot)\b/i) && ($hostname !~ /^google-proxy/i)) ||
2091           (Net::CIDR::cidrlookup($remote, @cidr_blocks))) {
2092
0
0
                if($self->{cache}) {
2093
0
0
                        $self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
2094                }
2095
0
0
                $self->{is_search_engine} = 1;
2096
0
0
                return 1;
2097        }
2098
2099
0
0
        $self->{is_search_engine} = 0;
2100
0
0
        return 0;
2101}
2102
2103 - 2181
=head2 is_ai

Returns a boolean indicating whether the visitor is a known AI training or
inference crawler (e.g. GPTBot, ClaudeBot, PerplexityBot).

Use this to withhold training data, serve an opt-out notice, or log AI traffic
separately from regular robot traffic.

B<Invariant>: when C<is_ai()> returns true, C<is_robot()> also returns true,
regardless of the order in which the two methods are called.

Can be overridden by the C<IS_AI> environment variable.

=head3 EXAMPLE

    use CGI::Info;

    my $info = CGI::Info->new();
    if ($info->is_ai()) {
        # Decline to serve training data to AI scrapers
        print "Status: 403 Forbidden\r\n\r\n";
        exit;
    }

    # Route AI crawlers to a lightweight page instead of blocking them
    if ($info->is_ai()) {
        serve_ai_summary();
    } else {
        serve_full_page();
    }

=head3 API SPECIFICATION

=head4 Input

No arguments beyond the implicit object reference (C<$self>).

    # Params::Validate::Strict schema -- no parameters
    {}

=head4 Output

    # Return::Set schema
    {
        type    => SCALAR,
        values  => [ 0, 1 ],
    }

Returns C<1> if the visiting client is identified as an AI training or
inference crawler; C<0> otherwise.

=head3 MESSAGES

This method produces no log messages of its own.  Upstream callers such as
C<is_robot()> may emit WAF warnings; see L</is_robot> for that table.

=head3 PSEUDOCODE

    function is_ai(self):
        if self.{is_ai} is defined:
            return self.{is_ai}                    # instance-level cache

        if IS_AI environment variable is set:
            return self.{is_ai} = IS_AI ? 1 : 0   # override; no robot sync needed
                                                   # because is_robot() calls is_ai()

        ua     = HTTP_USER_AGENT
        remote = REMOTE_ADDR

        if not (remote and ua):
            return 0                               # not a CGI request; assume human

        if ua matches any AI_PAT token (case-insensitive):
            self.{is_robot} = 1                    # enforce is_ai => is_robot
            return self.{is_ai} = 1

        return self.{is_ai} = 0

=cut
2182
2183sub is_ai {
2184
166
739
        my $self = shift;
2185
2186        # Return cached result if already determined
2187
166
211
        if(defined($self->{is_ai})) {
2188
40
77
                return $self->{is_ai};
2189        }
2190
2191        # Allow environment variable override for testing or manual classification
2192
126
185
        if(defined(my $override = $ENV{'IS_AI'})) {
2193
14
44
                return $self->{is_ai} = $override ? 1 : 0;
2194        }
2195
2196
112
107
        my $agent = $ENV{'HTTP_USER_AGENT'};
2197
112
105
        my $remote = $ENV{'REMOTE_ADDR'};
2198
2199
112
223
        unless($remote && $agent) {
2200                # Probably not running in CGI - assume not an AI crawler
2201
17
40
                return 0;
2202        }
2203
2204        # Known AI training and inference crawlers, matched against the User-Agent.
2205        # We intentionally do not consult the shared IP/agent cache here: is_robot()
2206        # stores 'robot' for many of the same UAs, and reading 'robot' != 'ai' would
2207        # produce a false negative.  Instance-level caching ($self->{is_ai}) above is
2208        # sufficient to avoid redundant regex evaluation within a single request.
2209        # Sources: vendor documentation and public bot lists.
2210        # Anthropic: ClaudeBot, Claude-Web, anthropic-ai
2211        # OpenAI: GPTBot, ChatGPT-User, OAI-SearchBot
2212        # Google: Google-Extended (AI training opt-out token)
2213        # Meta: meta-externalagent, FacebookBot (AI training)
2214        # Apple: Applebot-Extended (AI training subset)
2215        # Perplexity: PerplexityBot
2216        # Amazon: Amazonbot (Amazon AI / Alexa AI)
2217        # You.com: YouBot
2218        # Diffbot: Diffbot
2219        # Cohere: cohere-ai
2220        # Common Crawl: CCBot (primary data source for many LLM trainers)
2221        # ByteDance: Bytespider (TikTok / AI training)
2222        # Allen AI: AI2Bot
2223        # Timpi: TimpiBot
2224
95
539
        if($agent =~ /ClaudeBot|Claude-Web|anthropic-ai|GPTBot|ChatGPT-User|OAI-SearchBot|Google-Extended|meta-externalagent|FacebookBot|Applebot-Extended|PerplexityBot|Amazonbot|YouBot|Diffbot|cohere-ai|CCBot|Bytespider|AI2Bot|TimpiBot/i) {
2225                # Enforce is_ai => is_robot so callers need not check both
2226
45
55
                $self->{is_robot} = 1;
2227
45
124
                return $self->{is_ai} = 1;
2228        }
2229
2230
50
65
        $self->{is_ai} = 0;
2231
50
75
        return 0;
2232}
2233
2234 - 2269
=head2 browser_type

Returns a string classifying the visitor's client.  The possible values are:

=over 4

=item * C<'mobile'> -- smartphone or tablet (checked first)

=item * C<'ai'> -- known AI training or inference crawler (see L</is_ai>)

=item * C<'search'> -- search-engine crawler

=item * C<'robot'> -- other automated client

=item * C<'web'> -- ordinary desktop or laptop browser

=back

    use Carp;
    use Template;
    use CGI::Info;

    my $info = CGI::Info->new();
    my $dir  = $info->rootdir() . '/templates/' . $info->browser_type();

    my $filename = ref($info);
    $filename =~ s/::/\//g;
    $filename = "$dir/$filename.tmpl";

    (-f $filename && -r $filename)
        or croak "Cannot open template '$filename'";

    my $template = Template->new();
    $template->process($filename, {}) or croak $template->error();

=cut
2270
2271sub browser_type {
2272
57
700
        my $self = shift;
2273
2274
57
100
        if($self->is_mobile()) {
2275
16
36
                return 'mobile';
2276        }
2277
41
70
        if($self->is_ai()) {
2278
11
32
                return 'ai';
2279        }
2280
30
54
        if($self->is_search_engine()) {
2281
9
19
                return 'search';
2282        }
2283
21
37
        if($self->is_robot()) {
2284
7
16
                return 'robot';
2285        }
2286
14
30
        return 'web';
2287}
2288
2289 - 2304
=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
2305
2306sub get_cookie {
2307
17
960
        my $self = shift;
2308
2309
17
28
        return $self->cookie(\@_);
2310}
2311
2312 - 2356
=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
2357
2358sub cookie
2359{
2360
2149
4515
        my $self = shift;
2361
2149
3444
        my $params = Params::Validate::Strict::validate_strict({
2362                args => Params::Get::get_params('cookie_name', @_),
2363                schema => {
2364                        cookie_name => {
2365                                'type' => 'string',
2366                                'min' => 1,
2367                                'matches' => qr/^[!#-'*+\-.\^_`|~0-9A-Za-z]+$/   # RFC6265
2368                        }
2369                }
2370        });
2371
2372
410
41854
        my $field = $params->{'cookie_name'};
2373
2374        # TODO: Unreachable code detected during path analysis. Investigate for removal.
2375        # validate_strict() above enforces type=>string, min=>1 and croaks for undef/ref
2376        # inputs before control ever reaches these two guards.
2377
410
670
        if(!defined($field)) {
2378
93
417
                $self->_error('what cookie do you want?');
2379
93
1120
                Carp::croak('what cookie do you want?');
2380
0
0
                return; # TODO: Unreachable code detected during path analysis. Investigate for removal.
2381        }
2382
317
389
        if(ref($field)) {
2383
0
0
                $self->_error('Cookie name should be a string');
2384
0
0
                Carp::croak('Cookie name should be a string');
2385
0
0
                return; # TODO: Unreachable code detected during path analysis. Investigate for removal.
2386        }
2387
2388        # Load cookies if not already loaded
2389
317
567
        unless($self->{jar}) {
2390
42
58
                if(defined $ENV{'HTTP_COOKIE'}) {
2391                        # Truncate at the first CR or LF before parsing.
2392                        # HTTP header values cannot span lines; anything after a newline is
2393                        # injected content (e.g. "session=abc\r\nSet-Cookie: admin=1").
2394                        # Stripping rather than truncating would leave the injected text
2395                        # concatenated onto a legitimate value, so we discard from \r/\n onward.
2396
36
76
                        (my $raw_cookie = $ENV{'HTTP_COOKIE'}) =~ s/[\r\n].*$//s;
2397
2398                        # grep { /=/ } filters out malformed tokens (empty strings, bare
2399                        # semicolons, entries with no name=value separator) that would
2400                        # otherwise cause split(/=/, $_, 2) to return a single-element list
2401                        # and make the flattened list odd-length, corrupting the hash.
2402                        $self->{jar} = {
2403
62
119
                                map  { split(/=/, $_, 2) }
2404
36
65
87
81
                                grep { /=/ }
2405                                split(/; /, $raw_cookie)
2406                        };
2407                }
2408        }
2409
2410        # Return the cookie value if it exists, otherwise return undef
2411
317
723
        return $self->{jar}{$field};
2412}
2413
2414 - 2429
=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
2430
2431sub status
2432{
2433
394
22700
        my $self = shift;
2434
394
276
        my $status = shift;
2435
2436        # Set status if provided
2437
394
553
        return $self->{status} = $status if(defined($status));
2438
2439        # Determine status based on request method if status is not set
2440
192
300
        unless (defined $self->{status}) {
2441
45
57
                my $method = $ENV{'REQUEST_METHOD'};
2442
2443
45
136
                return 405 if $method && ($method eq 'OPTIONS' || $method eq 'DELETE');
2444
39
81
                return 411 if $method && ($method eq 'POST' && !defined $ENV{'CONTENT_LENGTH'});
2445
2446
35
98
                return 200;
2447        }
2448
2449        # Return current status or 200 by default
2450
147
421
        return $self->{status} || 200;
2451}
2452
2453 - 2465
=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
2466
2467sub messages
2468{
2469
49
5364
        my $self = shift;
2470
2471
49
84
        return $self->{'messages'};
2472}
2473
2474 - 2478
=head2  messages_as_string

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

=cut
2479
2480sub messages_as_string
2481{
2482
10
551
        my $self = shift;
2483
2484
10
36
        if(scalar($self->{'messages'})) {
2485
5
14
5
27
15
7
                my @messages = map { $_->{'message'} } @{$self->{'messages'}};
2486
5
12
                return join('; ', @messages);
2487        }
2488
5
15
        return '';
2489}
2490
2491 - 2510
=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
2511
2512sub cache
2513{
2514
26
221
        my $self = shift;
2515
26
25
        my $cache = shift;
2516
2517
26
48
        if($cache) {
2518
14
162
                croak(ref($self), ':cache($cache) is not an object') if(!Scalar::Util::blessed($cache));
2519
10
144
                croak(ref($self), ':cache($cache) does not support the get() method') if(!$cache->can('get'));
2520
7
91
                croak(ref($self), ':cache($cache) does not support the set() method') if(!$cache->can('set'));
2521
5
10
                $self->{'cache'} = $cache;
2522        }
2523
17
39
        return $self->{'cache'};
2524}
2525
2526 - 2533
=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
2534
2535sub set_logger
2536{
2537
17
127
        my $self = shift;
2538
17
32
        my $params = Params::Get::get_params('logger', @_);
2539
2540
17
251
        if(my $logger = $params->{'logger'}) {
2541
15
30
                if(Scalar::Util::blessed($logger)) {
2542
10
15
                        $self->{'logger'} = $logger;
2543                } else {
2544
5
11
                        $self->{'logger'} = Log::Abstraction->new($logger);
2545                }
2546        } else {
2547
2
5
                $self->{'logger'} = Log::Abstraction->new();
2548        }
2549
17
2152
        return $self;
2550}
2551
2552# Log and remember a message
2553sub _log :Protected {
2554        my ($self, $level, @messages) = @_;
2555
2556        # Filter once; reuse for both the in-memory store and the logger call.
2557        # The former inner scalar(@messages) guard was always true inside this block.
2558        my @defined_msgs = grep { defined } @messages;
2559        return unless @defined_msgs;
2560
2561        # Note: consider adding caller's function name to log messages in a future release
2562        push @{$self->{'messages'}}, { level => $level, message => join(' ', @defined_msgs) };
2563
2564        if(my $logger = $self->{'logger'}) {
2565                $logger->$level(join('', @defined_msgs));
2566        }
2567
57
57
57
164336
60
677
}
2568
2569sub _debug :Protected {
2570        my $self = shift;
2571        $self->_log('debug', @_);
2572
57
57
57
6302
48
393
}
2573
2574sub _info :Protected {
2575        my $self = shift;
2576        $self->_log('info', @_);
2577
57
57
57
4610
124
358
}
2578
2579sub _notice :Protected {
2580        my $self = shift;
2581        $self->_log('notice', @_);
2582
57
57
57
4131
159
302
}
2583
2584sub _trace :Protected {
2585        my $self = shift;
2586        $self->_log('trace', @_);
2587
57
57
57
3955
66
315
}
2588
2589# Emit a warning message somewhere
2590sub _warn :Protected {
2591        my $self = shift;
2592        my $params = Params::Get::get_params('warning', @_);
2593
2594        $self->_log('warn', $params->{'warning'});
2595        if(!defined($self->{'logger'})) {
2596                Carp::carp($params->{'warning'});
2597        }
2598
57
57
57
5427
47
265
}
2599
2600# Emit an error message somewhere
2601sub _error :Protected {
2602        my $self = shift;
2603        my $params = Params::Get::get_params('warning', @_);
2604
2605        $self->_log('error', $params->{'warning'});
2606        if(!defined($self->{'logger'})) {
2607                Carp::croak($params->{'warning'});
2608        }
2609
57
57
57
4886
48
265
}
2610
2611# Ensure all environment variables are sanitized and validated before use.
2612# Use regular expressions to enforce strict input formats.
2613sub _get_env :Protected {
2614        my ($self, $var) = @_;
2615
2616        return unless defined $ENV{$var};
2617
2618        # Strict sanitization: allow alphanumeric and limited special characters
2619        if($ENV{$var} =~ /^[\w\.\-\/:\\]+$/) {
2620                return $ENV{$var};
2621        }
2622        $self->_warn("Invalid value in environment variable: $var");
2623
2624        return;
2625
57
57
57
9900
51
352
}
2626
2627 - 2633
=head2 reset

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

=cut
2634
2635sub reset {
2636
661
1348010
        my $class = shift;
2637
2638
661
959
        unless($class eq __PACKAGE__) {
2639
4
101
                carp('Reset is a class method');
2640
3
321
                return;
2641        }
2642
2643
657
678
        $stdin_data = undef;
2644}
2645
2646sub AUTOLOAD
2647{
2648
1013
276453
        our $AUTOLOAD;
2649
2650
1013
1282
        my $self = shift or return;
2651
2652
1013
1052
        return if(!defined($AUTOLOAD));
2653
2654        # Extract the method name from the AUTOLOAD variable
2655
1012
3311
        my ($method) = $AUTOLOAD =~ /::(\w+)$/;
2656
2657        # Skip if called on destruction
2658
1012
3505
        return if($method eq 'DESTROY');
2659
2660
25
45
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(!ref($self));
2661
2662        # Allow the AUTOLOAD feature to be disabled
2663
24
63
        Carp::croak(__PACKAGE__, ": Unknown method $method") if(exists($self->{'auto_load'}) && boolean($self->{'auto_load'})->isFalse());
2664
2665        # Ensure the method is called on the correct package object or a subclass
2666
19
41
        return unless((ref($self) eq __PACKAGE__) || (UNIVERSAL::isa((caller)[0], __PACKAGE__)));
2667
2668        # TODO: Unreachable code detected during path analysis. Investigate for removal.
2669        # $method is captured via /::(\w+)$/ which only yields \w+ chars; a digit-leading
2670        # name is possible in theory but Perl's method dispatch never generates one.
2671
19
47
        Carp::croak(__PACKAGE__, ": Invalid method name: $method") unless $method =~ /^[a-zA-Z_][a-zA-Z0-9_]*$/;
2672
2673        # Delegate to the param method
2674
19
46
        return $self->param($method);
2675}
2676
2677 - 2839
=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

=encoding utf-8

=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.

=head2 is_ai

    -- is_ai ---------------------------------------------------------
    -- Given CGIInfo state i, returns a boolean result.
    --
    -- AI_PAT is the set of known AI crawler token strings.
    --
    -- ENV denotes the process environment (a partial function from
    -- name to value).
    --
    AI_PAT == {ClaudeBot, Claude-Web, anthropic-ai, GPTBot,
               ChatGPT-User, OAI-SearchBot, Google-Extended,
               meta-externalagent, FacebookBot, Applebot-Extended,
               PerplexityBot, Amazonbot, YouBot, Diffbot,
               cohere-ai, CCBot, Bytespider, AI2Bot, TimpiBot}

    is_ai ≜ λ i : CGIInfo •
      -- Environment override takes absolute priority
      IS_AI ∈ dom ENV ⟹
          (ENV IS_AI ≠ '0' ∧ ENV IS_AI ≠ '')

      -- Without both IP and UA we cannot classify
    âˆ§ IS_AI ∉ dom ENV ∧
      (REMOTE_ADDR ∉ dom ENV ∨ HTTP_USER_AGENT ∉ dom ENV)
          âŸ¹ false

      -- UA-pattern match (case-insensitive substring)
    âˆ§ IS_AI ∉ dom ENV ∧
      REMOTE_ADDR ∈ dom ENV ∧ HTTP_USER_AGENT ∈ dom ENV
          âŸ¹ (∃ p : AI_PAT • p ⊑ᵢ ENV HTTP_USER_AGENT)

      -- Invariant: is_ai ⟹ is_robot
    âˆ§ is_ai i = true ⟹ is_robot i = true
    -- ---------------------------------------------------------------

=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
2840
28411;