| File: | blib/lib/CGI/Info.pm |
| Coverage: | 83.9% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package 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 | # --------------------------------------------------------------------------- | |||||
| 32 | Readonly my $MAX_UPLOAD_SIZE_DEFAULT => 512 * 1024; # 512 KB default upload cap | |||||
| 33 | Readonly my $CACHE_TTL_ROBOT => '1 day'; # TTL for robot-detection cache entries | |||||
| 34 | Readonly 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. | |||||
| 40 | Readonly 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 | ||||||
| 82 | sub _sanitise_input; | |||||
| 83 | ||||||
| 84 - 92 | =head1 NAME CGI::Info - Information about the CGI environment =head1 VERSION Version 1.14 =cut | |||||
| 93 | ||||||
| 94 | our $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 | ||||||
| 244 | our $stdin_data; # Class variable storing STDIN in case the class | |||||
| 245 | # is instantiated more than once | |||||
| 246 | ||||||
| 247 | sub 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 | ||||||
| 352 | sub 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 | ||||||
| 362 | sub _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 | ||||||
| 433 | sub 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 | ||||||
| 458 | sub 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 | ||||||
| 496 | sub 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 | ||||||
| 506 | sub _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 | ||||||
| 557 | sub 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 | ||||||
| 580 | sub 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 | ||||||
| 747 | sub 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 | ||||||
| 1258 | sub 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 | ||||||
| 1289 | sub _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 | ||||||
| 1318 | sub _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. | |||||
| 1421 | sub _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 | |||||
| 1447 | sub _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 | ||||||
| 1466 | sub 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 | ||||||
| 1541 | sub 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 | ||||||
| 1587 | sub 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 | ||||||
| 1636 | sub 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 | ||||||
| 1702 | sub 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 | ||||||
| 1753 | sub 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 | ||||||
| 1791 | sub 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 | ||||||
| 1807 | sub 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 | ||||||
| 1831 | sub 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 | ||||||
| 1876 | sub 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 | ||||||
| 2012 | sub 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 | ||||||
| 2183 | sub 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 | ||||||
| 2271 | sub 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 | ||||||
| 2306 | sub 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 | ||||||
| 2358 | sub 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 | ||||||
| 2431 | sub 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 | ||||||
| 2467 | sub 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 | ||||||
| 2480 | sub 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 | ||||||
| 2512 | sub 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 | ||||||
| 2535 | sub 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 | |||||
| 2553 | sub _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 | ||||||
| 2569 | sub _debug :Protected { | |||||
| 2570 | my $self = shift; | |||||
| 2571 | $self->_log('debug', @_); | |||||
| 2572 | 57 57 57 | 6302 48 393 | } | |||
| 2573 | ||||||
| 2574 | sub _info :Protected { | |||||
| 2575 | my $self = shift; | |||||
| 2576 | $self->_log('info', @_); | |||||
| 2577 | 57 57 57 | 4610 124 358 | } | |||
| 2578 | ||||||
| 2579 | sub _notice :Protected { | |||||
| 2580 | my $self = shift; | |||||
| 2581 | $self->_log('notice', @_); | |||||
| 2582 | 57 57 57 | 4131 159 302 | } | |||
| 2583 | ||||||
| 2584 | sub _trace :Protected { | |||||
| 2585 | my $self = shift; | |||||
| 2586 | $self->_log('trace', @_); | |||||
| 2587 | 57 57 57 | 3955 66 315 | } | |||
| 2588 | ||||||
| 2589 | # Emit a warning message somewhere | |||||
| 2590 | sub _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 | |||||
| 2601 | sub _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. | |||||
| 2613 | sub _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 | ||||||
| 2635 | sub 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 | ||||||
| 2646 | sub 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 | ||||||
| 2841 | 1; | |||||