CGI-ACL

 view release on metacpan or  search on metacpan

lib/CGI/ACL.pm  view on Meta::CPAN


    my $lingua = CGI::Lingua->new(supported => ['en']);

    my $acl = CGI::ACL->new()
        ->deny_cloud()                  # block AWS, GCP, Azure, etc.
        ->allow_ip('127.0.0.1')         # always allow localhost
        ->allow_ip('198.51.100.0/24')   # corporate network
        ->deny_all_countries()          # default-deny all countries...
        ->allow_country('GB')           # ...except UK
        ->allow_country('US');          # ...and US

    if ($acl->all_denied(lingua => $lingua)) {
        print "Content-Type: text/plain\n\n";
        print "Access denied.\n";
        exit;
    }

=head2 Sharing a base ACL across routes with cloning

Call C<new()> on an existing object to get an independent copy.
Changing the copy does not affect the original.

    use CGI::ACL;

    # Shared base: block cloud for all routes
    my $base_acl = CGI::ACL->new()->deny_cloud();

    # Admin route: additionally restrict to a single IP
    my $admin_acl = $base_acl->new()->allow_ip('198.51.100.1');

    if ($admin_acl->all_denied()) {
        print "Content-Type: text/plain\n\n";
        print "Admin access denied.\n";
        exit;
    }

The module optionally integrates with L<CGI::Lingua> for country detection.
Runtime configuration is supported via L<Object::Configure>.

=head1 COMMON PITFALLS

The following mistakes are easy to make.  Read this section before filing
a bug report.

=head2 allow_country alone has no effect

C<allow_country()> only restricts access when default-deny mode is active.
Default-deny mode is activated by C<deny_country('*')> or
C<deny_all_countries()>.  Without it, C<allow_country()> is silently
ignored and everyone is still allowed.

    # WRONG -- this allows everyone; allow_country is ignored
    my $acl = CGI::ACL->new()->allow_country('US');

    # RIGHT -- deny all countries first, then add permitted ones
    my $acl = CGI::ACL->new()->deny_all_countries()->allow_country('US');

=head2 deny_cloud overrides allow_ip

Cloud detection has the highest priority.  An IP address that is listed
in C<allow_ip()> is still blocked if its reverse DNS resolves to a cloud
provider hostname.  This is intentional: cloud IPs can be reassigned, so
the rDNS check is more reliable than the IP address alone.

    # This STILL blocks the IP if it is a cloud host
    my $acl = CGI::ACL->new()
        ->deny_cloud()
        ->allow_ip('198.51.100.5');   # blocked if rDNS says EC2

=head2 Localhost is not automatically allowed

Once any restriction is set, C<127.0.0.1> is subject to the same rules
as any other address.  If you need to allow local access (for example,
a health-check endpoint), add it explicitly.

    my $acl = CGI::ACL->new()
        ->allow_ip('127.0.0.1')   # must be explicit
        ->deny_all_countries()
        ->allow_country('US');

=head2 Forgetting the lingua argument

When country restrictions are active and C<all_denied()> is called without
a C<lingua> argument, the module emits a C<carp> warning and denies the
request.  Always pass a C<CGI::Lingua> object when country rules are in use.

    # WRONG -- will carp and deny every request
    my $acl = CGI::ACL->new()->deny_all_countries()->allow_country('US');
    $acl->all_denied();

    # RIGHT
    my $lingua = CGI::Lingua->new(supported => ['en']);
    $acl->all_denied(lingua => $lingua);

=head2 VPN and proxy users bypass IP and country checks

A visitor who connects through a VPN, Tor exit node, or anonymous proxy
will appear to come from the proxy's IP address and country, not their
own.  CGI::ACL has no way to detect this.  Cloud blocking provides some
mitigation for VPS-based proxies.

=head2 Country codes are case-insensitive but stored lowercase

C<deny_country('BR')> and C<deny_country('br')> are equivalent.  All
country codes are stored in lowercase.  C<CGI::Lingua::country()> may
return either case; C<all_denied()> normalises it with C<lc()> before
comparing.

=head2 The DNS result cache is not shared between CGI requests

In traditional CGI (one process per request), the per-object DNS cache
is destroyed at the end of every request.  The cache is only useful in
persistent-process setups such as FastCGI, mod_perl, or Plack servers,
where the same C<CGI::ACL> object survives across many requests.

=head1 SUBROUTINES/METHODS

=head2 new

Creates and returns a new CGI::ACL object.

When called on an existing object it returns a deep clone of that object,
optionally overriding public fields with the supplied arguments.  The public
data hashes (C<allowed_ips>, C<deny_countries>, C<allow_countries>) are
copied so that mutations to the clone do not affect the original.
Derived/private keys (C<_cidrlist>, C<_cloud_cache>) are always cleared;
they are rebuilt from the cloned public state on the next C<all_denied()>
call.



( run in 1.252 second using v1.01-cache-2.11-cpan-800906f7e73 )