CGI-Info
view release on metacpan or search on metacpan
lib/CGI/Info.pm view on Meta::CPAN
args => { $key => $value },
unknown_parameter_handler => 'die',
logger => $self->{'logger'}
});
};
if($@) {
$self->_info("Block $key = $value: $@");
$self->status(422);
next; # Skip to the next parameter
}
if(scalar keys %{$value}) {
$value = $value->{$key};
} else {
$self->_info("Block $key = $value");
$self->status(422);
next; # Skip to the next parameter
}
}
}
}
# if($self->{expect} && (List::Util::none { $_ eq $key } @{$self->{expect}})) {
# next;
# }
my $orig_value = $value;
$value = _sanitise_input($value);
# WAF: inspect all methods (GET and POST) for injection patterns.
# Previously gated on GET only, which allowed POST to bypass all checks.
{
# ($value =~ /\/AND\/.++\(SELECT\//) || # United/**/States)/**/AND/**/(SELECT/**/6734/**/FROM/**/(SELECT(SLEEP(5)))lRNi)/**/AND/**/(8984=8984
# From http://www.symantec.com/connect/articles/detection-sql-injection-and-cross-site-scripting-attacks
# Facebook FBCLID can have "--"
# Pre-filter: only run quote-based regexes if value contains injection chars.
# Compute pre-filter flags from orig_value so quotes stripped by
# convert_XSS don't cause injection patterns to be missed
my $has_quote = index($orig_value, "'") >= 0 || index($orig_value, '%27') >= 0;
my $has_hash = index($orig_value, '#') >= 0 || index($orig_value, '%23') >= 0;
my $has_equals = index($orig_value, '=') >= 0 || index($orig_value, '%3D') >= 0;
my $has_semi = index($orig_value, ';') >= 0 || index($orig_value, '%3B') >= 0;
my $has_dash = index($orig_value, '--') >= 0;
# All WAF patterns run on $orig_value (pre-XSS-sanitisation)
# convert_XSS encodes ', =, < etc. as HTML entities, which would hide
# injection patterns from the WAF if we checked $value instead.
if($has_quote || $has_hash || ($has_equals && $has_dash)) {
if(($orig_value =~ /(?:%27|'|%23|#)/i) ||
(($has_equals && ($has_quote || $has_semi || $has_dash)) &&
$orig_value =~ /(?:%3D|=)[^-]*+(?:%27|'|--|%3B|;)/i) ||
($has_quote &&
# Detect 'or'-style injection: word + quote + url-encoded or literal 'or' + SQL keyword.
# (?:%6F|o|%4F) = 'o', (?:%72|r|%52) = 'r', both case-folded via /i.
$orig_value =~ /\w*(?:%27|')(?:%6F|o|%4F)(?:%72|r|%52)\s*(?:OR|AND|UNION|SELECT|--)/ix) ||
($has_quote &&
$orig_value =~ /(?:%27|')union/ix)) {
$self->status(403);
if($ENV{'REMOTE_ADDR'}) {
$self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
} else {
$self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
}
return;
}
}
my $has_select = index($orig_value, 'SELECT') >= 0 || index($orig_value, 'select') >= 0;
my $has_dump = index($orig_value, 'var_dump') >= 0;
my $has_exec = index($orig_value, 'exec') >= 0;
my $has_or = index($orig_value, ' OR ') >= 0;
my $has_and = index($orig_value, ' AND ') >= 0;
my $has_slash = index($orig_value, '/**/') >= 0 || index($orig_value, '/AND/') >= 0;
if(# \b anchors prevent matching inside longer words.
# {1,500}? is lazy+bounded: avoids catastrophic backtracking on
# "SELECT aaaa...aaaa" (no FROM) while still catching real queries.
($has_select && $orig_value =~ /\bselect\b.{1,500}?\bfrom\b/is) ||
($has_and && $orig_value =~ /\sAND\s1=1/ix) ||
# Numeric tautology without quotes: OR 1=1, OR 2=2, etc.
($has_or && $orig_value =~ /\bOR\s+\d+\s*=\s*\d+/i) ||
# Bounded lazy .{1,500}? avoids backtracking on "OR aaaa..." with no AND.
($has_or && $has_and && $orig_value =~ /\sOR\s.{1,500}?\sAND\s/) ||
($has_slash && $orig_value =~ /\/\*\*\/ORDER\/\*\*\/BY\/\*\*/ix) ||
($has_dump && $orig_value =~ /var_dump[^m]*+md5/) ||
($has_slash && $has_select && $orig_value =~ /\/AND\/[^(]*+\(SELECT\//) ||
($has_exec && $orig_value =~ /exec[\s+]++[sx]p\w+/ix)) {
$self->status(403);
if($ENV{'REMOTE_ADDR'}) {
$self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$key=$orig_value'");
} else {
$self->_warn("SQL injection attempt blocked for '$key=$orig_value'");
}
return;
}
if(my $agent = $ENV{'HTTP_USER_AGENT'}) {
# Bounded lazy .{1,500}? separates SQL keyword pairs without catastrophic backtracking.
# Possessive .++ would consume the trailing anchor â never match. Unbounded .+ risks ReDoS.
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...
$self->status(403);
if($ENV{'REMOTE_ADDR'}) {
$self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
} else {
$self->_warn("SQL injection attempt blocked for '$agent'");
}
return;
}
}
# XSS detection using [^>]+ instead of .+ or .++ :
# - [^>]+ stops naturally at '>' â no backtracking, no ReDoS.
# - [^>] also matches '\n', so multi-line payloads like
# "<img\nsrc=x\nonerror=alert(1)>" are caught without /s.
# - Replaces both the old [^\n]+ (stopped at newline â bypass)
# and the broken .++ (possessive consumed '>' â never matched).
if(($value =~ /(?:%3C|<)(?:%2F|\/)*[a-z0-9%]+(?:%3E|>)/ix) ||
($value =~ /(?:%3C|<)[^>]+(?:%3E|>)/i) ||
($orig_value =~ /(?:%3C|<)(?:%2F|\/)*[a-z0-9%]+(?:%3E|>)/ix) ||
($orig_value =~ /(?:%3C|<)[^>]+(?:%3E|>)/i)) {
$self->status(403);
$self->_warn("XSS injection attempt blocked for '$value'");
return;
}
# Block javascript: URI scheme â no angle brackets, but still executes
# script when used in href or src attributes.
if($orig_value =~ /\bjavascript\s*:/i) {
$self->status(403);
$self->_warn("XSS injection attempt blocked for '$value'");
return;
}
if($value =~ /mustleak\.com\//) {
$self->status(403);
$self->_warn("Blocked mustleak attack for '$key'");
return;
}
if($value =~ /\.\.\//) {
$self->status(403);
$self->_warn("Blocked directory traversal attack for '$key'");
return;
}
}
if(length($value) > 0) {
# Don't add if it's already there
if($FORM{$key} && ($FORM{$key} ne $value)) {
$FORM{$key} .= ",$value";
} else {
$FORM{$key} = $value;
}
}
}
unless(%FORM) {
return;
}
if($self->{'logger'}) {
while(my ($key,$value) = each %FORM) {
$self->_debug("$key=$value");
}
}
$self->{paramref} = \%FORM;
return Return::Set::set_return(\%FORM, { type => 'hashref', min => 1 });
}
=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 },
lib/CGI/Info.pm view on Meta::CPAN
foreach my $rc($self->{logdir}, $ENV{'LOGDIR'}, Sys::Path->logdir(), $self->tmpdir()) {
if(defined($rc) && length($rc) && (-d $rc) && (-w $rc)) {
$dir = $rc;
last;
}
}
$self->_warn("Can't determine logdir") if((!defined($dir)) || (length($dir) == 0));
$self->{logdir} ||= $dir;
return $dir;
}
=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
sub is_robot {
my $self = shift;
if(defined($self->{is_robot})) {
return $self->{is_robot};
}
my $agent = $ENV{'HTTP_USER_AGENT'};
my $remote = $ENV{'REMOTE_ADDR'};
unless($remote && $agent) {
# Probably not running in CGI - assume real person
return 0;
}
# SQL injection check MUST run before is_ai(): a WAF block must never be
# bypassed just because the UA also identifies itself as an AI crawler.
# See also params() â patterns here MUST stay in sync with those in params().
# Bounded-lazy .{1,N}? replaces unbounded .+ to prevent O(n²/n³) backtracking
# on long UAs that contain SQL keywords but not the complete injection sequence.
# \b word boundaries prevent false positives on tokens like "SELECTFOO".
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/)) {
$self->status(403);
$self->{is_robot} = 1;
if($ENV{'REMOTE_ADDR'}) {
$self->_warn($ENV{'REMOTE_ADDR'} . ": SQL injection attempt blocked for '$agent'");
} else {
$self->_warn("SQL injection attempt blocked for '$agent'");
}
return 1;
}
# is_ai implies is_robot: check AI crawlers before the generic bot regex so
# that UAs like ChatGPT-User or Google-Extended (no "bot"/"spider" token)
# are still caught here.
if($self->is_ai()) {
return $self->{is_robot} = 1;
}
# '.+bot' was replaced with '\bbot\b' â the leading .+ caused catastrophic
# backtracking on long UAs that contain no 'bot' substring.
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|...
$self->{is_robot} = 1;
return 1;
}
# TODO:
# Download and use list from
# https://raw.githubusercontent.com/mitchellkrogza/apache-ultimate-bad-bot-blocker/refs/heads/master/_generator_lists/bad-user-agents.list
my $key = "$remote/$agent";
# Check the shared cache BEFORE the referrer scan: the 29-domain referrer
# check is the most expensive path in is_robot() and is unnecessary when a
# prior request already classified this remote/agent pair.
# The SQL injection check above MUST remain before this (security gate).
if($self->{cache}) {
if(my $type = $self->{cache}->get($key)) {
return $self->{is_robot} = ($type eq 'robot');
}
}
if(my $referrer = $ENV{'HTTP_REFERER'}) {
# $CRAWLER_REFERER_RE is compiled once at module load (see top of file):
# replaces List::Util::any { /^\Q$_\E/i } @crawler_lists (29 per-call
# regex compilations + array allocation eliminated).
$referrer =~ s/\\/_/g;
if(($referrer =~ /\)/) || ($referrer =~ $CRAWLER_REFERER_RE)) {
$self->_debug("is_robot: blocked trawler $referrer");
if($self->{cache}) {
$self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
}
$self->{is_robot} = 1;
return 1;
}
}
# Don't use HTTP_USER_AGENT to detect more than we really have to since
# that is easily spoofed
if($agent =~ /www\.majestic12\.co\.uk|facebookexternal/) {
# Mark Facebook as a search engine, not a robot
if($self->{cache}) {
$self->{cache}->set($key, 'search', $CACHE_TTL_SEARCH);
}
return 0;
}
unless($self->{browser_detect}) {
if(eval { require HTTP::BrowserDetect; }) {
HTTP::BrowserDetect->import();
$self->{browser_detect} = HTTP::BrowserDetect->new($agent);
}
}
if($self->{browser_detect}) {
my $is_robot = $self->{browser_detect}->robot();
if(defined($is_robot)) {
$self->_debug("HTTP::BrowserDetect '$ENV{HTTP_USER_AGENT}' returns $is_robot");
}
$is_robot = (defined($is_robot) && ($is_robot)) ? 1 : 0;
$self->_debug("is_robot: $is_robot");
if($is_robot) {
if($self->{cache}) {
$self->{cache}->set($key, 'robot', $CACHE_TTL_ROBOT);
}
$self->{is_robot} = $is_robot;
return $is_robot;
}
}
if($self->{cache}) {
$self->{cache}->set($key, 'unknown', $CACHE_TTL_ROBOT);
}
$self->{is_robot} = 0;
return 0;
}
=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
( run in 1.460 second using v1.01-cache-2.11-cpan-c221a9de4ec )