CGI-Lingua
view release on metacpan or search on metacpan
t/cgi_security.t view on Meta::CPAN
#!/usr/bin/env perl
# t/cgi_security.t -- Penetration / security regression tests for CGI::Lingua.
#
# Simulates hostile CGI environments by injecting weaponised payloads into
# the five env vars the module reads:
#
# HTTP_ACCEPT_LANGUAGE REMOTE_ADDR GEOIP_COUNTRY_CODE
# HTTP_CF_IPCOUNTRY LANG HTTP_USER_AGENT
#
# Each subtest documents the specific exploit mechanism being attempted and
# asserts that the module fails securely (rejects, warns, returns undef)
# rather than passing hostile data downstream.
#
# Attack categories covered:
# 1. Shell metacharacter / command injection via env vars
# 2. CRLF / header injection via env vars
# 3. Null-byte injection
# 4. Overlength header (DoS / buffer edge)
# 5. Country-code format violations (length, non-alpha, embedded control chars)
# 6. IP address format violations (path traversal, shell meta in REMOTE_ADDR)
# 7. Whois response injection (CRLF, trailing-comment bypass, MITM data)
# 8. JSON API response injection (XSS payload in country/timezone fields)
# 9. Cache key poisoning (namespace prefix collision, split() separator abuse)
# 10. HTTP_USER_AGENT CRLF and XSS injection (locale() path)
# 11. Accept-Language q-value boundary and wildcard edge cases
# 12. IPv4-mapped IPv6 normalisation correctness
# 13. Translation-file extension path-traversal guard
# 14. Country-code output validation (final result must be 2 lowercase alpha)
use strict;
use warnings;
use CHI;
use File::Temp qw(tempdir);
use Readonly;
use Scalar::Util qw(blessed);
use Test::Most;
use Test::Mockingbird;
use Test::Returns qw(returns_ok);
# Pre-require LWP::Simple::WithCache and JSON::Parse before any mocks are
# installed. Their BEGIN blocks run on first require and clobber any existing
# symbol-table entry, including mocks. Loading them here makes all subsequent
# eval { require ... } calls inside country() and time_zone() no-ops that
# leave Test::Mockingbird mocks intact.
my $HAS_LWP = eval { require LWP::Simple::WithCache; 1 } ? 1 : 0;
my $HAS_JSON = eval { require JSON::Parse; 1 } ? 1 : 0;
BEGIN { use_ok('CGI::Lingua') }
# ââ Constants âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Maximum header length accepted by the module (matches $ACCEPT_LANG_MAX).
Readonly my $MAX_HEADER_LEN => 256;
# A public IP that is neither private nor loopback, used when a real IP is needed.
Readonly my $PUBLIC_IP => '8.8.8.8';
# Shell metacharacter payloads; each must be rejected at the env-var layer.
# NOTE: ';' and whitespace ARE valid in Accept-Language (used for q-values),
# so 'en; ls -la' and 'en\tls' are intentionally omitted â they pass the
# untaint regex legitimately and never reach a shell.
Readonly my @SHELL_PAYLOADS => (
'en|cat /etc/passwd',
'en$(id)',
'en`id`',
'en && evil',
);
# CRLF injection payloads; none must survive into returned data.
Readonly my @CRLF_PAYLOADS => (
"en\r\nX-Injected: evil",
"en\nX-Injected: evil",
"en\rX-Injected: evil",
);
# ââ Network block âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Block all real network I/O for the entire file. Subtests that need specific
# whois/geoplugin behaviour override locally before restoring.
Test::Mockingbird::mock('CGI::Lingua', '_resolve_country_via_whois', sub { });
Test::Mockingbird::mock('LWP::Simple::WithCache', 'get', sub { undef }) if $HAS_LWP;
# Restore the global network block after restore_all() in any subtest.
t/cgi_security.t view on Meta::CPAN
subtest 'GEOIP_COUNTRY_CODE: wrong-length codes are rejected' => sub {
for my $code ('G', 'GBR', 'GBGB', '', '1U', 'gb') {
# Code must be exactly 2 UPPERCASE ASCII letters to pass
local %ENV = (GEOIP_COUNTRY_CODE => $code, REMOTE_ADDR => '127.0.0.1');
my @warnings;
Test::Mockingbird::mock('CGI::Lingua', '_warn',
sub { push @warnings, (ref($_[1]) ? $_[1]->{warning} : $_[1]) });
my $l = _obj();
my $cc = $l->country();
# Valid 2-uppercase result must not come back if the code is wrong format
if(defined $cc) {
unlike($cc, qr/[^a-z]/, "Non-alpha chars not in returned code for: $code");
} else {
pass("GEOIP_COUNTRY_CODE '$code' correctly rejected");
}
Test::Mockingbird::restore_all();
_block_network();
}
};
# ââ 8. HTTP_CF_IPCOUNTRY format violations ââââââââââââââââââââââââââââââââââââ
# Same /^([A-Z]{2})$/a validation as GEOIP_COUNTRY_CODE; same attack surface.
subtest 'HTTP_CF_IPCOUNTRY: CRLF injection is rejected' => sub {
local %ENV = (HTTP_CF_IPCOUNTRY => "FR\r\nX-Inject: malicious");
my @warnings;
Test::Mockingbird::mock('CGI::Lingua', '_warn',
sub { push @warnings, (ref($_[1]) ? $_[1]->{warning} : $_[1]) });
my $l = _obj();
$l->country();
ok((grep { /invalid country code/ } @warnings),
'CRLF in HTTP_CF_IPCOUNTRY triggers warning');
Test::Mockingbird::restore_all();
_block_network();
};
subtest 'HTTP_CF_IPCOUNTRY: Cloudflare XX sentinel not returned as country code' => sub {
# 'XX' is a documented special value meaning "Cloudflare could not determine
# country". It must not be returned to the caller as a country code.
local %ENV = (HTTP_CF_IPCOUNTRY => 'XX', REMOTE_ADDR => '127.0.0.1');
my $l = _obj();
my $cc = $l->country();
ok(!defined $cc || $cc ne 'xx',
'Cloudflare XX sentinel not returned as country');
};
# ââ 9. LANG env-var injection âââââââââââââââââââââââââââââââââââââââââââââââââ
# LANG is only consulted when HTTP_ACCEPT_LANGUAGE and the CGI::Info lang param
# are both absent (local/debug mode). It is untainted by a similar regex.
subtest 'LANG: shell metacharacters are ignored' => sub {
for my $payload ('en_US; rm -rf /', "en_US\r\nX-Header: evil", "en_US\x00") {
local %ENV = (LANG => $payload, REMOTE_ADDR => '127.0.0.1');
my $l = _obj();
# language() must not expose any fragment of the payload
my $lang = $l->language();
unlike($lang // '', qr/[|;&\x60\$\r\n\x00]/, "LANG payload not reflected: " . _abbrev($payload));
}
};
# ââ 10. HTTP_USER_AGENT CRLF injection (locale() path) âââââââââââââââââââââââ
# locale() parses the User-Agent parenthetical for a language tag. A crafted
# UA with CRLF could attempt to split the match and inject data.
subtest 'HTTP_USER_AGENT: CRLF in parenthetical does not leak into locale()' => sub {
# The regex /\((.+)\)/ uses `.` which does NOT match \n in default mode,
# so CRLF terminates the match before the injected header.
local %ENV = (
HTTP_USER_AGENT => "Mozilla/5.0 (en-GB\r\nX-Injected: evil)",
REMOTE_ADDR => '127.0.0.1',
);
my $l = _obj();
my $locale = $l->locale();
# If locale() returned anything, its name() must not contain CRLF sequences
if(defined $locale && blessed $locale) {
my $name = $locale->name() // '';
unlike($name, qr/[\r\n]/, 'CRLF not in locale name from UA');
} else {
pass('locale() returned undef for CRLF UA â safe degradation');
}
};
subtest 'HTTP_USER_AGENT: XSS payload in parenthetical does not match lang-tag regex' => sub {
# The lang-tag check requires /^[a-zA-Z]{2}-([a-zA-Z]{2})$/, so an XSS
# payload like "<script>alert(1)</script>" will never pass.
local %ENV = (
HTTP_USER_AGENT => 'Mozilla/5.0 (<script>alert(1)</script>)',
REMOTE_ADDR => '127.0.0.1',
);
my $l = _obj();
my $locale = $l->locale();
if(defined $locale && blessed $locale) {
unlike($locale->name() // '', qr/<script>/i,
'XSS payload not reflected in locale name');
} else {
pass('locale() returned undef for XSS UA â safe');
}
};
subtest 'HTTP_USER_AGENT: null byte in parenthetical does not match lang-tag' => sub {
local %ENV = (
HTTP_USER_AGENT => "Mozilla/5.0 (en-G\x00B)",
REMOTE_ADDR => '127.0.0.1',
);
my $l = _obj();
my $locale = $l->locale();
# A null byte breaks the 2-letter country code match; locale() must return undef
# or a locale that came from a different detection path (IP or GEOIP_COUNTRY_CODE).
pass('locale() did not crash on null byte in UA');
};
# ââ 11. Whois response injection (_clean_country_code) âââââââââââââââââââââââ
# If an attacker controls the upstream Whois server (MITM), they can inject
# arbitrary bytes into the country field of the response. _clean_country_code()
# strips CRLFs and trailing comments; the final result must be safe.
subtest '_clean_country_code: strips carriage returns from whois response' => sub {
# Simulate a whois response with an embedded carriage return.
# Real example: some servers return "GB\r" in the Country field.
Test::Mockingbird::unmock('CGI::Lingua', '_resolve_country_via_whois');
Test::Mockingbird::mock('Net::Whois::IP', 'whoisip_query', sub {
return { Country => "GB\r" };
});
local %ENV = (REMOTE_ADDR => $PUBLIC_IP);
my $l = _obj();
$l->{_have_ipcountry} = 0;
$l->{_have_geoip} = 0;
$l->{_have_geoipfree} = 0;
my $cc = $l->country();
# The \r must be stripped; country must be a clean 2-char code or undef
if(defined $cc) {
unlike($cc, qr/[\r\n]/, 'Carriage return stripped from whois country');
is(length($cc), 2, 'Country code is exactly 2 chars after CR strip');
} else {
pass('country() returned undef after CR in whois (acceptable fallback)');
}
Test::Mockingbird::restore_all();
_block_network();
};
subtest '_clean_country_code: strips trailing # comment from whois response' => sub {
# Some whois servers append a comment after the code: "US # United States"
Test::Mockingbird::unmock('CGI::Lingua', '_resolve_country_via_whois');
Test::Mockingbird::mock('Net::Whois::IP', 'whoisip_query', sub {
return { Country => 'US # United States via ARIN' };
});
local %ENV = (REMOTE_ADDR => $PUBLIC_IP);
my $l = _obj();
$l->{_have_ipcountry} = 0;
$l->{_have_geoip} = 0;
$l->{_have_geoipfree} = 0;
my $cc = $l->country();
if(defined $cc) {
is($cc, 'us', 'Trailing # comment stripped; code is lowercase us');
} else {
pass('country() returned undef (geoplugin mock suppressed data)');
}
Test::Mockingbird::restore_all();
_block_network();
};
subtest '_clean_country_code: CRLF + injected header does not propagate' => sub {
# MITM whois server injects "GB\nX-Header: evil" as Country field.
# After s/[\r\n]//g the value becomes "GBX-Header: evil" â too long and
# containing non-alpha chars. country() MUST NOT return this to the caller.
Test::Mockingbird::unmock('CGI::Lingua', '_resolve_country_via_whois');
Test::Mockingbird::mock('Net::Whois::IP', 'whoisip_query', sub {
return { Country => "GB\nX-Header: evil" };
});
( run in 0.700 second using v1.01-cache-2.11-cpan-a5162978ef8 )