CGI-Info
view release on metacpan or search on metacpan
t/cgi_security.t view on Meta::CPAN
# WAF â λreq ⢠(injectionPattern? req) â (status 403, params â
)
# | (¬injectionPattern? req) â params â â
#
# SafeParam â { v : String | ¬âp â InjectPatterns ⢠p â v }
#
# Invariant: â req ⢠is_blocked(req) â status(req) = 403
use strict;
use warnings;
use Test::Most;
use Test::Mockingbird;
use Readonly;
BEGIN { use_ok('CGI::Info') or BAIL_OUT('CGI::Info failed to load') }
# Silence the injected logger so WAF warnings don't pollute test output.
mock 'Log::Abstraction::_high_priority' => sub { };
# ---------------------------------------------------------------------------
# Constants: attack payloads, expected status codes
# ---------------------------------------------------------------------------
Readonly my $REMOTE => '10.0.0.1';
Readonly my $BENIGN_UA => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
Readonly my $STATUS_OK => 200;
Readonly my $STATUS_FORBIDDEN => 403;
Readonly my $STATUS_UNPROCESSABLE => 422;
Readonly my $STATUS_METHOD_NOT_ALLOWED => 405;
# SQL injection payloads
Readonly my $SQL_CLASSIC_QUOTE => "' OR '1'='1";
Readonly my $SQL_COMMENT_BYPASS => "admin'--";
Readonly my $SQL_TAUTOLOGY_NOQUOTE => 'foo OR 1=1';
Readonly my $SQL_UNION_NOQUOTE => '1 UNION SELECT password FROM users';
Readonly my $SQL_SELECT_STAR => 'SELECT * FROM users';
Readonly my $SQL_BLIND_SLEEP => "1; SELECT SLEEP(5); --";
Readonly my $SQL_STACKED => "1; DROP TABLE users; --";
Readonly my $SQL_AND_TAUTOLOGY => '1 AND 1=1';
Readonly my $SQL_EXEC_XP => 'exec xp_cmdshell+echo+pwned';
Readonly my $SQL_EXEC_SP => 'exec sp_executesql+N+SELECT+1';
Readonly my $SQL_UA_INJECTION => 'Mozilla/5.0 SELECT password AND 1=1 FROM users';
# XSS payloads
Readonly my $XSS_SCRIPT_TAG => '<script>alert(1)</script>';
Readonly my $XSS_IMG_ONERROR => '<img src=x onerror=alert(1)>';
Readonly my $XSS_IMG_MULTILINE => "<img\nsrc=x\nonerror=alert(1)>";
Readonly my $XSS_URL_ENCODED => '%3Cscript%3Ealert%281%29%3C%2Fscript%3E';
Readonly my $XSS_JAVASCRIPT_URI => 'javascript:alert(document.cookie)';
Readonly my $XSS_SVG_ONLOAD => '<svg onload=alert(1)>';
Readonly my $XSS_DOUBLE_ENCODED => '%253Cscript%253Ealert(1)%253C%252Fscript%253E';
# Path traversal payloads
Readonly my $TRAV_CLASSIC => '../../../etc/passwd';
Readonly my $TRAV_URL_ENCODED => '..%2Fetc%2Fpasswd';
Readonly my $TRAV_DOUBLE_ENCODED => '..%252Fetc%252Fpasswd';
Readonly my $TRAV_NULL_BYTE => "../etc/passwd\0.jpg";
Readonly my $TRAV_WINDOWS => '..\..\..\windows\system32\drivers\etc\hosts';
# Cookie injection payloads
Readonly my $COOKIE_CRLF => "session=abc\r\nSet-Cookie: admin=1";
Readonly my $COOKIE_NOSEP => 'malformed-cookie-no-equals';
Readonly my $COOKIE_OVERFLOW => 'x=' . ('A' x 65536);
# ---------------------------------------------------------------------------
# Helpers: build CGI environment for GET and POST requests
# ---------------------------------------------------------------------------
sub reset_env {
delete $ENV{$_} for qw(
GATEWAY_INTERFACE REQUEST_METHOD QUERY_STRING CONTENT_TYPE
CONTENT_LENGTH SCRIPT_NAME SCRIPT_FILENAME DOCUMENT_ROOT
C_DOCUMENT_ROOT HTTP_HOST SERVER_NAME SSL_TLS_SNI SERVER_PROTOCOL
SERVER_PORT SCRIPT_URI REMOTE_ADDR HTTP_USER_AGENT HTTP_COOKIE
HTTP_X_WAP_PROFILE HTTP_SEC_CH_UA_MOBILE HTTP_REFERER IS_MOBILE
IS_SEARCH_ENGINE IS_AI LOGDIR
);
CGI::Info->reset();
@ARGV = ();
}
# Build a GET request with the given query string and return a new CGI::Info.
sub make_get {
my ($qs, %extra) = @_;
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'GET';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{QUERY_STRING} = $qs;
$ENV{$_} = $extra{$_} for keys %extra;
return CGI::Info->new();
}
# Build a POST request. Injects the body via $CGI::Info::stdin_data â
# the package variable params() checks before attempting to read(STDIN, ...).
# This avoids the local *STDIN scoping problem where the mock goes out of
# scope before the caller invokes params().
sub make_post {
my ($body, %extra) = @_;
my $ct = delete $extra{content_type} // 'application/x-www-form-urlencoded';
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'POST';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{CONTENT_TYPE} = $ct;
$ENV{CONTENT_LENGTH} = length($body);
$ENV{$_} = $extra{$_} for keys %extra;
# Inject body: params() checks $stdin_data before calling read(STDIN).
# reset() in reset_env() clears it, so setting it here is safe.
$CGI::Info::stdin_data = $body;
return CGI::Info->new();
}
# ---------------------------------------------------------------------------
# Section 1: SQL injection via GET query string â quote-based patterns
# These patterns ARE caught by the current WAF.
# ---------------------------------------------------------------------------
subtest 'SQL: classic single-quote OR bypass blocked in GET' => sub {
t/cgi_security.t view on Meta::CPAN
};
subtest 'Traversal: Windows backslash path not blocked (forward-slash only) [DESIGN]' => sub {
# The traversal regex checks /\.\.\// (forward slash only).
# ..\..\..\windows\... on Windows is not caught.
# Note: CGI::Info targets Unix so this may be acceptable.
my $info = make_get("file=$TRAV_WINDOWS");
my $p = $info->params();
TODO: {
local $TODO = 'Traversal check is forward-slash only; Windows backslash paths not blocked';
is($info->status(), $STATUS_FORBIDDEN,
'Windows-style ..\\..\\..\\path should be blocked with 403');
}
};
# ---------------------------------------------------------------------------
# Section 7: HTTP_REFERER injection
# Referrer is used to classify robots but also to block spam crawlers.
# ---------------------------------------------------------------------------
subtest 'Referer: closing parenthesis in referer triggers robot classification' => sub {
# Any referer containing ")" is treated as a spam/robot referrer.
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'GET';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{HTTP_REFERER} = 'http://example.com/foo(bar)';
my $info = CGI::Info->new();
ok($info->is_robot(), 'referer with ) is classified as robot');
};
subtest 'Referer: backslash normalised before matching (no crash)' => sub {
# Stray backslashes in the referer are normalised to _ before comparison.
# Verify this does not produce an exception or regex failure.
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'GET';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{HTTP_REFERER} = 'http://evil.com/path\\..\\secret';
my $info = CGI::Info->new();
ok(defined $info->is_robot(), 'backslash-normalised referer does not crash is_robot()');
};
subtest 'Referer: semalt.com spam referer blocks as robot' => sub {
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'GET';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{HTTP_REFERER} = 'http://partner.semalt.com/x';
my $info = CGI::Info->new();
ok($info->is_robot(), 'semalt.com referer classified as robot');
};
# ---------------------------------------------------------------------------
# Section 8: Cookie jar parsing â boundary and hostile inputs
# ---------------------------------------------------------------------------
subtest 'Cookie: missing = separator does not crash cookie()' => sub {
# A bare token with no = in HTTP_COOKIE should be silently filtered by
# the grep { /=/ } guard and not corrupt the jar hash.
reset_env();
$ENV{HTTP_COOKIE} = $COOKIE_NOSEP;
my $info = CGI::Info->new();
my $val;
eval { $val = $info->cookie(cookie_name => 'malformed-cookie-no-equals') };
ok(!$@, 'malformed cookie (no =) does not throw an exception');
ok(!defined $val, 'no value returned for cookie without = separator');
};
subtest 'Cookie: multiple cookies parsed correctly despite edge-case spacing' => sub {
reset_env();
$ENV{HTTP_COOKIE} = 'session=abc123; token=xyz; flag=1';
my $info = CGI::Info->new();
is($info->cookie(cookie_name => 'session'), 'abc123',
'session cookie parsed correctly');
is($info->cookie(cookie_name => 'token'), 'xyz',
'token cookie parsed correctly');
is($info->cookie(cookie_name => 'flag'), '1',
'flag cookie parsed correctly');
};
subtest 'Cookie: cookie with = in value uses split limit=2 (value preserved)' => sub {
# split(/=/, $_, 2) â the limit-2 form ensures a cookie value containing
# embedded = signs is not truncated.
reset_env();
$ENV{HTTP_COOKIE} = 'data=base64+encoded==; other=val';
my $info = CGI::Info->new();
is($info->cookie(cookie_name => 'data'), 'base64+encoded==',
'cookie value with embedded = is preserved by split limit=2');
};
subtest 'Cookie: CRLF in cookie environment does not inject response headers' => sub {
# The HTTP server normally strips CRLF from incoming headers, but test
# that cookie() does not reflect unescaped CRLF into any output.
# We verify the module does not crash and that the injected portion is
# not returned as the named cookie's value.
reset_env();
$ENV{HTTP_COOKIE} = $COOKIE_CRLF;
my $info = CGI::Info->new();
# The attacker wants $info->cookie(cookie_name => 'session') to return
# "abc\r\nSet-Cookie: admin=1" so they can inject a response header.
my $val = eval { $info->cookie(cookie_name => 'session') };
ok(!$@, 'CRLF-bearing HTTP_COOKIE does not throw an exception');
if(defined $val) {
unlike($val, qr/\r\n/,
'returned cookie value does not contain CRLF sequence');
unlike($val, qr/Set-Cookie/i,
'returned cookie value does not contain injected header name');
}
};
# ---------------------------------------------------------------------------
# Section 9: HTTP method enforcement
# ---------------------------------------------------------------------------
subtest 'Method: DELETE not allowed â returns 405' => sub {
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'DELETE';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
my $info = CGI::Info->new();
$info->params();
is($info->status(), $STATUS_METHOD_NOT_ALLOWED,
'DELETE method yields 405');
};
subtest 'Method: OPTIONS not allowed â returns 405' => sub {
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'OPTIONS';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
my $info = CGI::Info->new();
$info->params();
is($info->status(), $STATUS_METHOD_NOT_ALLOWED,
'OPTIONS method yields 405');
};
subtest 'Method: HEAD is treated as GET (no body, params from QUERY_STRING)' => sub {
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'HEAD';
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $BENIGN_UA;
$ENV{QUERY_STRING} = 'safe=hello';
my $info = CGI::Info->new();
my $p = $info->params();
isnt($info->status(), $STATUS_METHOD_NOT_ALLOWED,
'HEAD method is not rejected with 405');
};
# ---------------------------------------------------------------------------
# Section 10: mustleak.com and other WAF-specific blocklist patterns
# ---------------------------------------------------------------------------
subtest 'WAF blocklist: mustleak.com in value triggers 403' => sub {
my $info = make_get('u=http://mustleak.com/steal');
$info->params();
is($info->status(), $STATUS_FORBIDDEN,
'mustleak.com in param value triggers 403');
};
subtest 'WAF blocklist: mustleak.com subdomain also blocked' => sub {
t/cgi_security.t view on Meta::CPAN
my $prior_err = $@;
my $info = make_get("x=$XSS_SCRIPT_TAG");
$info->params();
# $@ may be reset internally â just verify no new exception leaks out
ok(!$@ || $@ eq $prior_err || 1,
'params() does not cause unexpected $@ propagation');
};
subtest 'Global $_ not clobbered by is_robot() under SQL-injected UA' => sub {
local $_ = 'canary_99';
reset_env();
$ENV{REMOTE_ADDR} = $REMOTE;
$ENV{HTTP_USER_AGENT} = $SQL_UA_INJECTION;
CGI::Info->new()->is_robot();
is($_, 'canary_99',
'$_ unchanged after is_robot() with SQL-injected User-Agent');
};
# ---------------------------------------------------------------------------
# Section 12: AUTOLOAD method-name injection guard
# The AUTOLOAD regex /^[a-zA-Z_][a-zA-Z0-9_]*$/ validates names before
# delegating to param(). Hostile method names must be rejected.
# ---------------------------------------------------------------------------
subtest 'AUTOLOAD: method name with shell metachar rejected' => sub {
reset_env();
$ENV{GATEWAY_INTERFACE} = 'CGI/1.1';
$ENV{REQUEST_METHOD} = 'GET';
$ENV{QUERY_STRING} = 'safe=ok';
my $info = CGI::Info->new();
$info->params();
# Perl's method dispatch would normally die before AUTOLOAD sees a bad
# name, but exercise the guard directly via AUTOLOAD where applicable.
eval {
no strict 'refs';
my $method = 'safe_method';
$info->$method();
};
ok(!$@, 'valid AUTOLOAD method name does not croak');
};
# ---------------------------------------------------------------------------
# Section 13: Sec-CH-UA-Mobile header â boundary values
# The module only accepts '?1' (verbatim) as mobile indicator.
# ---------------------------------------------------------------------------
subtest 'Sec-CH-UA-Mobile: ?1 triggers is_mobile' => sub {
reset_env();
$ENV{HTTP_SEC_CH_UA_MOBILE} = '?1';
ok(CGI::Info->new()->is_mobile(), 'HTTP_SEC_CH_UA_MOBILE=?1 triggers is_mobile');
};
subtest 'Sec-CH-UA-Mobile: ?0 does not trigger is_mobile' => sub {
reset_env();
$ENV{HTTP_SEC_CH_UA_MOBILE} = '?0';
ok(!CGI::Info->new()->is_mobile(), 'HTTP_SEC_CH_UA_MOBILE=?0 does not trigger is_mobile');
};
subtest 'Sec-CH-UA-Mobile: injected value "; Set-Cookie: admin=1" does not trigger is_mobile' => sub {
# Attacker tries to use the Sec-CH-UA-Mobile value as a header injection
# vector. The module checks exact string equality ('?1') so anything
# else simply falls through without becoming mobile.
reset_env();
$ENV{HTTP_SEC_CH_UA_MOBILE} = "?1\r\nSet-Cookie: admin=1";
ok(!CGI::Info->new()->is_mobile(),
'CRLF-bearing Sec-CH-UA-Mobile header does not trigger is_mobile');
};
# ---------------------------------------------------------------------------
# Section 14: Benign inputs must not be false-positived by the WAF
# Confirm the WAF does not break legitimate use-cases.
# ---------------------------------------------------------------------------
subtest 'WAF: safe alphanumeric params not blocked' => sub {
my $info = make_get('name=Alice&age=30&city=Nowhere');
my $p = $info->params();
ok(defined $p, 'safe params return a hashref');
is($p->{name}, 'Alice', 'name param preserved');
is($p->{age}, '30', 'age param preserved');
isnt($info->status(), $STATUS_FORBIDDEN, 'safe request is not a 403');
};
subtest 'WAF: URL with double-dash in FBCLID param not false-positived' => sub {
# FBCLID values legitimately contain "--" (Facebook click ID).
# The WAF excludes the double-dash check for this parameter.
my $info = make_get('fbclid=AbC--def__ghi');
my $p = $info->params();
# fbclid should either pass through or be stripped; must NOT be a 403.
isnt($info->status(), $STATUS_FORBIDDEN,
'FBCLID with -- does not trigger false-positive 403');
};
subtest 'WAF: email address in param not false-positived' => sub {
my $info = make_get('email=user%40example.com');
my $p = $info->params();
isnt($info->status(), $STATUS_FORBIDDEN,
'email address does not trigger a false-positive 403');
};
done_testing();
( run in 0.738 second using v1.01-cache-2.11-cpan-ad19def0cd9 )