Revision history for CGI-ACL
0.10 Wed Aug 5 10:12:35 AM EDT 2026
[ Enhancements ]
- Add deny_all_countries() convenience method: sugar for deny_country('*'),
switches all_denied() into default-deny mode for country checks
- Fix $@ not cleared after capturing DNS eval error and lingua->country()
eval error; callers were seeing stale $@ state
- Fix `my $ip = $_[0]` → `my ($ip) = @_` in _is_cloud_host and
_verified_rdns (idiomatic Perl list-assignment style)
- Remove vacuous /o modifier from literal `if($ip =~ /:/)` pattern (/o is
a no-op when there are no interpolated variables)
- Fix misleading alarm comment: "cancel the alarm" → "restore the previous
alarm that was active before we started"
- Fix $PRIVATE_IP_RE fc00::/7 branch: ^f[cd][0-9a-f]*: accepted 2- and
3-char first groups (e.g. fc:, fc0:) which decode to public unicast
addresses, causing those IPs to bypass cloud DNS checks; fixed to {2}
(exactly 4 hex chars, matching all genuine fc00::/7 first groups)
- Fix $PRIVATE_IP_RE fe80::/10 branch: same false-positive bug; fe8: decodes
to 0x0fe8 (public unicast), not link-local; fixed to bare [0-9a-f] (no
quantifier = exactly 1 additional char = 4 hex chars total)
- Fix @CLOUD_PATTERNS OVH entry: ^ip-\d+-\d+-\d+-\d+\.eu$ had four
unbounded \d+ groups with literal separators, producing O(n^4) backtracking
on hostile hostnames that match structure but have wrong suffix; IPv4
octets are 1-3 digits, so \d{1,3} is both semantically correct and limits
worst-case backtracking to O(1) per group
[ Documentation ]
- Move =encoding utf-8 to be the first POD directive (before =head1 NAME) so
the parser handles Z-calculus Unicode symbols correctly throughout the file
- Add =head1 LIMITATIONS section documenting: VPN bypass, GeoIP inaccuracy,
cloud detection gaps, DNS blocking latency, cache not shared between
processes, Sub::Private white-box exemption, Windows alarm limitation,
deferred rate-limiter
- Add PSEUDOCODE to all_denied() (public method > 15 lines)
- Add FORMAL SPECIFICATION (Z-calculus) inline per public method,
replacing the old monolithic =head2 FORMAL SPECIFICATION bulk section
- Rename =head3 USAGE → =head3 EXAMPLE in all public-method POD blocks
- Expand =head1 SYNOPSIS from one example to six labelled real-world patterns
covering: cloud-only block, IP allowlist, country deny-list, country
allowlist (deny_all_countries + allow_country), combined production-grade
policy, and ACL cloning
- Add =head1 COMMON PITFALLS section documenting eight common mistakes:
allow_country alone has no effect, deny_cloud overrides allow_ip, localhost
not automatically allowed, missing lingua causes deny, VPN/proxy bypass,
country code case handling, DNS cache not shared between CGI requests
- Add =head1 VERSION HISTORY section with per-release summaries from 0.01
through 0.10 in plain English
[ Performance ]
- _rdns_forward(): use gethostbyname() instead of inet_aton() for the IPv4
path so all A records are returned rather than just the first; false-negative
forward confirmation was possible when a cloud provider PTR resolves to a
multi-A hostname and the confirming IP is not the first record returned.
Falls back to inet_aton() on resolvers that return no results for a
dotted-quad string (resolver-configuration-dependent behaviour)
[ Bug Fixes ]
- Correct minimum Perl version to 5.014 (was 5.006_001); Socket::getaddrinfo
and Socket::getnameinfo require Socket 2.000, first shipped with Perl 5.14
- Correct MIN_PERL_VERSION to '5.014' in Makefile.PL accordingly
- Fix all_denied() early-return guard: allow_countries was incorrectly
included in the guard, causing allow_country()-only ACLs to fall through
to the country-check code. The country check then denied requests when
lingua->country() returned undef (e.g. during RIPE WHOIS rate-limiting),
contradicting the documented "allow_country alone has no effect" behaviour.
allow_countries is now intentionally absent from the guard so ACLs with
only allow_country() set return 0 (allow all) immediately.
[ Tests ]
- Add 5 subtests for deny_all_countries() in t/function.t
- Fix t/locales.t, t/country.t, t/integration.t: add CGI::Lingua result
caching (lingua_for() with local $_ to prevent $_ clobbering by WHOIS
modules inside map/grep), RIPE rate-limit detection, and SKIP blocks so
tests that depend on RIPE WHOIS resolve gracefully when the server is
rate-limiting. Reduces WHOIS round-trips per test run from O(N subtests)
to O(distinct IPs) (338 tests total)
- Fix all_denied() country check entry condition: replace
"if(deny_countries || allow_countries)" with "if(deny_countries)".
Premise: allow_countries alone always produces 0 in non-wildcard mode.
Conclusion: it is vacuous in the condition and triggers an unnecessary
lingua lookup; removing it is a strict boolean reduction. Transitive
reduction also removes the now-redundant inner "$self->{deny_countries} &&"
guards (proved non-nil by the block entry condition)
- Apply fail-fast guard-clause style to allow_ip(), deny_country(),
allow_country(): each error path is a guard at the top with explicit
return; the happy path is at the bottom with no nesting
- Add t/logic_reducer.t: equivalence-partition proofs for all_denied() boolean
logic; each subtest targets one decision boundary. Includes the key proofs
that deny_cloud()->allow_country('X') without lingua allows non-cloud IPs,
and that allow_ip() + allow_country() (no deny_country) correctly denies
unlisted IPs without carping about a missing lingua (339 tests total)
- Fix stale comment in t/extended_tests.t "deny_cloud + allow_country" subtest:
the country check no longer runs in that case; the cloud fast-path returns 0
- Add t/function.t: 32 new white-box subtests covering all dispatch
paths, new() clone isolation and cache-clearing, deny_country/allow_country empty-
arrayref no-op and undef-element filtering, _set_countries() undef filtering,
all_denied() absent-REMOTE_ADDR default, DNS exception fail-safe, $@ clearing
after DNS/country exceptions, per-object cloud cache hit and isolation, non-blessed
lingua deny, dying country() deny, deny_cloud+allow_country fast-path optimization,
invalid CIDR eval guard, and all 8 _is_cloud_host() private-IP short-circuit paths
verified with killing mocks
[ Security ]
- Fix all_denied() IP validator: replace ^ / $ anchors with \A / \z so that
a trailing-newline REMOTE_ADDR ("1.2.3.4\n") cannot slip past the regex check;
Perl's $ matches before a terminating \n, \z never does
- Fix all_denied(): add capture-based detaint step after IP validation so that $addr
is never a tainted value downstream; any future caller of _is_cloud_host() or the
CIDR lookup that adds an exec/system operation inherits a clean value
- Fix new() clone path: filter _* (private/cache) keys from caller-supplied params
before merging; accepting _cloud_cache allowed a caller to pre-seed DNS cache
entries to permanently suppress cloud detection for a targeted IP address
- Fix new() class path: strip _* keys from Object::Configure::configure() output;
CGI__ACL__* env vars could previously inject _cloud_cache or _cidrlist state
- Fix allow_ip(): validate IP/CIDR format before storage; inject strings such as
'"; DROP TABLE; --' were previously stored and silently discarded by the eval-
wrapped Net::CIDR calls — now rejected early with a carp, preventing memory
accumulation in persistent processes and O(n) cidradd overhead per request;
allowed_ips is initialised to {} so the early-return guard treats the ACL as
"has IP restrictions" even when all entries were invalid (fail-closed, not
fail-open)
- Fix _is_cloud_host(): add RFC 1035 §3.1 hostname length check (>253 chars) before
running cloud patterns; protocol-invalid hostnames from DNS are now rejected before
any regex matching
- Fix t/function.t: add `use Carp` to prevent Test::Carp glob aliasing from clearing
Carp::carp; the missing use caused subsequent calls to Carp::carp from allow_ip()
to die with "Undefined subroutine" inside subtests that follow does_carp_that_matches
- Add t/edge_cases.t: two new security subtests — private cache key injection via
new() (clone and class paths), and allow_ip() injection string behaviour change
(carp+fail-closed instead of silent-store+deny) (373 tests total)
- Add t/unit.t: 51 new black-box subtests covering every public method's
documented API contracts via a %ledger tracking hash; each entry represents
a documented carp message or return state from the POD, deleted as conditions
are triggered, asserted empty at the end; file-level DNS mock prevents real
DNS calls; covers all three argument forms (positional, named key-value,
hashref), $@ hygiene after DNS/lingua exceptions, and all method-chaining
return-$self paths
- Extend t/integration.t: 9 new end-to-end subtests — deny_all_countries()
workflow with real CGI::Lingua, clone cloud-cache isolation, concurrent
country ACLs, SYNOPSIS §5 combined production policy (cloud + IP + country),
SYNOPSIS §6 base→admin clone, deny_cloud+allow_country fast-path (no lingua
consulted), Object::Configure _cloud_cache env-var injection stripping,
CIDR cache invalidation after allow_ip(), and Test::Without::Module
verification that IP-only ACLs work without CGI::Lingua installed
- Extend t/edge_cases.t: 20 new destructive/boundary/security subtests —
REMOTE_ADDR \z anchor regression ("1.2.3.4\n" must be denied; $ passed it),
leading/trailing whitespace in REMOTE_ADDR, typeglob as allow_ip argument
(carps "not a valid", returns $self), hashref {ip=>addr} positive path,
allow_ip with impossible CIDR prefix /33 (no carp, eval guard, fail-closed),
IPv6 CIDR allow_ip (2001:db8::/32), 100-entry allow-list stress test, dying
lingua->country() caught by eval (treated as unknown → deny), 64 KiB lingua
return (no crash), typeglob as lingua argument (carps, returns 1), cloud
cache TTL expiry forces re-query (DNS call count verified), 127.0.0.1
bypasses _verified_rdns entirely (private IP short-circuit), CRLF-contaminated
PTR hostname (no crash, no false cloud match), $@ cleared after DNS exception,
$@ cleared after lingua->country() exception, $@ clean after normal call,
injection string in deny_country (stored as literal lowercase, no execution),
allow_country("*") alone does not deny (wildcard is inert without deny_country),
multi-level clone chain (three levels, independent state), typeglob value in
constructor argument (no crash, object still functional) (524 tests total)
[ Distribution ]
- Fix =encoding utf-8 placement: was after =head1 NAME / =head1 VERSION; moved
to be the very first POD directive so parsers see it before any Unicode content
- Add Readonly to PREREQ_PM and cpanfile requires: used at runtime for every
module constant ($PRIVATE_IP_RE, @CLOUD_PATTERNS, $DNS_TIMEOUT, etc.) but was
previously undeclared, causing installation failure on clean systems
- Add Scalar::Util to PREREQ_PM and cpanfile requires: used at runtime for
blessed() in the lingua type check; was undeclared
- Add Socket >= 2.000 to PREREQ_PM and cpanfile requires: getaddrinfo,
getnameinfo, inet_pton, inet_ntop, AF_INET6, and NI_NUMERICHOST all require
Socket 2.000; MIN_PERL_VERSION 5.014 implies it but the explicit pin is
clearer and guards against a manually downgraded Socket on a 5.14 system
- Remove File::Spec from PREREQ_PM: the module itself does not use File::Spec;
it was only needed by Makefile.PL (which is not installed); it was also
listed twice with conflicting versions (3.4 in PREREQ_PM, 0 in TEST_REQUIRES)
- Add Test::Warn to TEST_REQUIRES and cpanfile test block: used by t/edge_cases.t
for warning_is{} but was undeclared, causing test failures on clean installers
- Add Readonly to TEST_REQUIRES and cpanfile test block: used in 8 test files
- Remove Data::Dumper from TEST_REQUIRES: confirmed unused in all test files
- Remove autodie, strict, warnings from TEST_REQUIRES: core pragmas always
available at MIN_PERL_VERSION 5.014; listing them adds install noise
- Move WWW::RT::CPAN from TEST_REQUIRES to cpanfile develop block: only used
by t/noopentickets.t which is gated by Test::DescribeMe qw(author) and skips
gracefully when the module is absent; not a burden for normal installers
0.09 Sun Aug 2 08:54:20 PM EDT 2026
[ Performance ]
- Skip DNS lookup entirely in _is_cloud_host() for private, loopback, and
link-local addresses (IPv4: 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16;
IPv6: ::1, fc00::/7, fe80::/10) — these can never resolve to cloud-provider
hostnames
- Cache per-IP cloud-lookup results in all_denied() with a 300-second TTL;
repeated requests from the same IP skip both DNS round-trips entirely.
DNS errors and timeouts are not cached so the next request retries.
Cache is cleared when an object is cloned via new()
[ Testing ]
- Bump minimum version of Test::Mockingbird
0.08 Sat Jun 6 21:26 EDT 2026
[ Bug Fixes ]
- Fixed REMOTE_ADDR || DEFAULT_ADDR using || instead of //, which silently
substituted 127.0.0.1 for falsy values "0" and "" — a security bypass if
loopback was in the allow-list
- Fixed deny_country(country => []) and allow_country(country => []) creating
an empty hashref instead of being a no-op, which tripped the early-return
guard and caused all traffic to require a lingua argument
- Fixed _set_countries() storing "" as a country key when an arrayref contained
undef elements; undef elements are now silently skipped
- Fixed Net::CIDR::cidradd/cidrlookup dying on non-IP strings in the allow-list
(e.g. injection attempts); CIDR operations are now wrapped in eval
- Fixed all_denied() crashing with "Can't call method on non-ref" when a
plain string was passed as the lingua argument; now carps and denies
- Fixed all_denied() dying when the lingua object lacks a country() method;
the country() call is now wrapped in eval and missing-method is treated as
unknown country (deny)
- Fixed all_denied() propagating exceptions from _is_cloud_host() / DNS
failures to the caller; cloud check now wraps _is_cloud_host in eval and
fails safe (treat as non-cloud) when DNS throws
- Fixed deny_cloud() being bypassed when used without allow_ip or deny_country
- Fixed deny_country('*') with no allow_country incorrectly allowing all traffic
- Fixed allow_ip() emitting duplicate warnings when passed a reference
- Fixed allow_countries not included in all_denied() early-exit guard, causing
allow_country-only ACLs to bypass all country checks
- Fixed auto-vivification of deny_countries when only deny_cloud and allow_country
are set, which corrupted object state on subsequent calls
- Fixed SIGALRM race in _verified_rdns() where alarm(0) called outside eval could
let a late-firing alarm kill the CGI process; alarm(0) now also called inside eval
- Fixed new() to restore croak on bad arguments; soft-carp+undef caused opaque
method-chain crashes for all callers
- Fixed new() plain-function call (CGI::ACL::new()) to always carp and return undef;
previously the no-argument case silently constructed an object instead of warning
- Removed unreachable duplicate AWS hostname pattern in _is_cloud_host()
- Fixed _verified_rdns() ignoring IPv6 addresses due to inet_aton being IPv4-only;
IPv6 clients were bypassing deny_cloud entirely
- Fixed new() on an existing object (clone mode) using a shallow copy of nested
hashrefs, causing mutations to deny_countries/allow_countries/allowed_ips on
a clone to also modify the original object
[ Enhancements ]
- Cache CIDR list in all_denied() to avoid rebuilding it on every call
- Add 10-second timeout on DNS lookups in _verified_rdns() on non-Windows platforms
- Added Test Dashboard at https://nigelhorne.github.io/CGI-ACL/coverage/
[ Documentation ]
- Document that deny_cloud() takes precedence over allow_ip()
[ Critique refactoring ]
- Fixed deny_country() returning undef instead of $self on bad-ref argument (broke method chaining)
- Fixed deny_country() carp message incorrectly referencing $ip_address instead of $country
- Fixed allow_ip() and allow_country() returning undef instead of $self on bad-ref argument
- Replaced magic number 10 (DNS timeout), '*' wildcard, and '127.0.0.1' with Readonly named constants
- Replaced ten individual cloud-pattern return statements in _is_cloud_host() with a Readonly @CLOUD_PATTERNS list
- Extracted _set_countries() private helper to eliminate duplicated code between deny_country/allow_country
- Added use autodie qw(:all) for safer built-in error handling
- Added comprehensive POD for all public methods: FORMAL SPECIFICATION (Z calculus), API SPECIFICATION, MESSAGES table
- Added purpose/entry/exit/side-effects comments for all private routines
[ Tests ]
- Migrated t/deny_cloud.t from Test::MockModule to Test::Mockingbird
- Added IPv6 deny_cloud tests to t/deny_cloud.t
- Added chaining tests: verify deny_country/allow_country/allow_ip return $self on bad-ref args
- Added t/function.t: white-box function-level tests for all public and private functions,
including mocked DNS helpers, $_ clobber checks, and memory cycle checks
- Fixed new() to carp and return undef for all plain-function calls (CGI::ACL::new()),
including the no-argument case that previously fell through to create an object silently
0.07 Thu Apr 16 19:38:57 EDT 2026
Fixed call to _verified_rdns
Allow an object to be configured at runtime via Object::Configure
0.06 Wed Mar 4 06:33:13 EST 2026
Added t/30-basics.t
Use Test::Needs
Use Test::DescribeMe to simplify tests
Use gtar to create a distribution on Macs
Check that REMOTE_ADDR is a sane IP address
Added deny_cloud
0.05 Tue Apr 2 16:26:14 EDT 2024
Calling new on an object now returns a clone rather than setting the defaults in the new object
0.04 Fri May 21 14:54:04 EDT 2021
Do something sensible if the remote country can't be determined
By default, localhost is not allowed access
0.03 Fri Dec 7 11:19:53 EST 2018
Added allow_country and deny_country('*')
0.02 Tue Feb 21 11:11:32 EST 2017
Fixed t/country.t
0.01 Wed Feb 15 15:52:08 EST 2017
First draft