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
