NAME

CGI::ACL - Decide whether to allow a client to run a CGI script

VERSION

Version 0.08

SYNOPSIS

Provides access control for CGI scripts based on IP address, CIDR block, geographic country, and cloud-provider origin.

use CGI::Lingua;
use CGI::ACL;

# Allow only UK visitors from a specific subnet
my $acl = CGI::ACL->new()
    ->deny_country('*')
    ->allow_country('GB')
    ->allow_ip('192.0.2.0/24');

if ($acl->all_denied(lingua => CGI::Lingua->new(supported => ['en']))) {
    print "Access denied.\n";
    exit;
}

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

SUBROUTINES/METHODS

new

Creates and returns a new CGI::ACL object.

When called on an existing object it returns a shallow clone of that object, optionally overriding fields with the supplied arguments.

Constructor arguments may also be supplied via environment variables of the form CGI__ACL__<field> or via a config file; see Object::Configure for details.

USAGE

# No restrictions (allow all by default)
my $acl = CGI::ACL->new();

# Pre-seeded allow list
my $acl = CGI::ACL->new(allowed_ips => { '127.0.0.1' => 1 });

# Clone an existing ACL and add a restriction
my $acl2 = $acl->new(deny_cloud => 1);

API SPECIFICATION

Input

# Compatible with Params::Validate::Strict:
{
    allowed_ips     => { type => 'hashref',  optional => 1 },
    deny_countries  => { type => 'hashref',  optional => 1 },
    allow_countries => { type => 'hashref',  optional => 1 },
    deny_cloud      => { type => 'boolean',  optional => 1 },
}

Output

# Compatible with Return::Set:
{ type => 'object', isa => 'CGI::ACL' }
# or undef when called as CGI::ACL::new() instead of CGI::ACL->new()

MESSAGES

CGI::ACL use ->new() not ::new() to instantiate

Severity: carp (warning). Cause: CGI::ACL::new(...) was called as a plain function instead of as a class method. Action: Change the call to CGI::ACL->new(...).

allow_ip

Adds an IPv4/IPv6 address or CIDR block to the set of explicitly permitted clients. When allowed_ips is non-empty, any client address not matched by an entry in the set is denied (subject to deny_cloud taking precedence).

USAGE

use CGI::ACL;

# Single address
my $acl = CGI::ACL->new()->allow_ip('203.0.113.5');

# Named parameter
my $acl = CGI::ACL->new()->allow_ip(ip => '203.0.113.5');

# CIDR block
my $acl = CGI::ACL->new()->allow_ip(ip => '192.0.2.0/24');

# Method chaining
my $acl = CGI::ACL->new()
    ->allow_ip('192.0.2.1')
    ->allow_ip('10.0.0.0/8');

ARGUMENTS

ip (required)

A string containing an IPv4 address, an IPv6 address, or a CIDR block (e.g. 10.0.0.0/8). The value is stored verbatim; invalid addresses will be silently ignored during lookup.

RETURNS

The object itself, to allow method chaining.

SIDE EFFECTS

Invalidates the internal CIDR lookup cache so the next call to all_denied() will rebuild it with the new entry included.

API SPECIFICATION

Input

# Compatible with Params::Validate::Strict:
{
    ip => { type => 'string', regex => qr/\S+/, required => 1 },
}

Output

# Compatible with Return::Set:
{ type => 'object', isa => 'CGI::ACL' }

MESSAGES

Usage: allow_ip($ip_address)

Severity: carp (warning). Cause: Called with no argument, with a non-hash reference, or without supplying the ip key. Action: Pass a scalar IP/CIDR string: allow_ip('192.0.2.1') or allow_ip(ip => '192.0.2.1').

deny_country

Adds one or more countries to the deny list. Countries are identified by their ISO 3166-1 alpha-2 codes (case-insensitive).

Passing the special value '*' (wildcard) switches to default-deny mode: all countries are denied unless they also appear in the allow list set by allow_country().

USAGE

use CGI::ACL;

# Deny a single country
my $acl = CGI::ACL->new()->deny_country('BR');

# Deny a list of countries
my $acl = CGI::ACL->new()->deny_country(country => ['BR', 'CN', 'RU']);

# Default-deny all countries (use with allow_country to whitelist)
my $acl = CGI::ACL->new()->deny_country('*')->allow_country('US');

ARGUMENTS

country (required)

A scalar ISO code, the wildcard '*', or an array reference of ISO codes.

RETURNS

The object itself, to allow method chaining.

SIDE EFFECTS

Updates $self->{deny_countries}.

NOTES

allow_country() has no effect unless deny_country('*') has been called first. Calling allow_country() alone (without the wildcard deny) does not restrict access.

API SPECIFICATION

Input

# Compatible with Params::Validate::Strict:
{
    country => {
        type     => 'string' | 'arrayref',
        required => 1,
    },
}

Output

# Compatible with Return::Set:
{ type => 'object', isa => 'CGI::ACL' }

MESSAGES

Usage: deny_country($country)

Severity: carp (warning). Cause: Called with no argument, with a non-hash/non-array reference, or without supplying the country key. Action: Pass a scalar ISO code or arrayref: deny_country('BR') or deny_country(country => ['BR','CN']).

allow_country

Adds one or more countries to the explicit permit list. This is meaningful only when deny_country('*') has been called first; without the wildcard deny, this method has no observable effect on access decisions.

USAGE

use CGI::ACL;

# Allow only the UK and US
my $acl = CGI::ACL->new()
    ->deny_country('*')
    ->allow_country(country => ['GB', 'US']);

# Single country as positional argument
my $acl = CGI::ACL->new()->deny_country('*')->allow_country('US');

ARGUMENTS

country (required)

A scalar ISO code or an array reference of ISO codes.

RETURNS

The object itself, to allow method chaining.

SIDE EFFECTS

Updates $self->{allow_countries}.

NOTES

Call deny_country('*') before this method; otherwise all traffic is already allowed by the default-allow rule and the permit list is never consulted.

API SPECIFICATION

Input

# Compatible with Params::Validate::Strict:
{
    country => {
        type     => 'string' | 'arrayref',
        required => 1,
    },
}

Output

# Compatible with Return::Set:
{ type => 'object', isa => 'CGI::ACL' }

MESSAGES

Usage: allow_country($country)

Severity: carp (warning). Cause: Called with no argument, with a non-hash/non-array reference, or without supplying the country key. Action: Pass a scalar ISO code or arrayref: allow_country('US') or allow_country(country => ['GB','US']).

deny_cloud

Enables blocking of requests that originate from major cloud-hosting providers. Detection is performed via verified reverse DNS: the client IP is looked up, the resulting hostname is forward-confirmed to prevent spoofing, and the confirmed hostname is matched against a list of provider-specific patterns.

Covered providers (as of this release): AWS EC2, Google Cloud Compute, Microsoft Azure, DigitalOcean, Linode/Akamai, Hetzner, OVH.

Important: deny_cloud takes precedence over allow_ip. An IP that is explicitly permitted via allow_ip() is still denied if its reverse DNS resolves to a cloud provider hostname.

USAGE

use CGI::ACL;

my $acl = CGI::ACL->new()->deny_cloud();

if ($acl->all_denied()) {
    print "Cloud-hosted clients are not permitted.\n";
    exit;
}

ARGUMENTS

None.

RETURNS

The object itself, to allow method chaining.

SIDE EFFECTS

Sets $self->{deny_cloud} to 1.

NOTES

IPv4 and IPv6 clients are both subject to the cloud check. A client with no reverse DNS record, or whose forward confirmation fails, is treated as a non-cloud host and allowed through the cloud check (though it may still be denied by other rules).

DNS lookups are performed synchronously. On non-Windows platforms a $DNS_TIMEOUT-second alarm is used to prevent indefinite blocking.

API SPECIFICATION

Input

# No parameters accepted.
{}

Output

# Compatible with Return::Set:
{ type => 'object', isa => 'CGI::ACL' }

MESSAGES

This method emits no messages.

all_denied

Evaluates every active restriction against the current client and returns 1 (deny) or 0 (allow).

The evaluation order is:

  1. If no restrictions are configured at all, return 0 (allow).

  2. Validate REMOTE_ADDR as a syntactically correct IPv4 or IPv6 address. If it is missing or malformed, return 1 (deny).

  3. If deny_cloud is set, perform a verified reverse-DNS lookup. If the hostname matches a cloud provider, return 1 (deny) immediately, regardless of allowed_ips. If the IP is not a cloud host and no other restrictions are active, return 0 (allow).

  4. If allowed_ips is set, check the client address against the exact-match hash and then the CIDR list. Return 0 (allow) on a match.

  5. If country restrictions are set, resolve the client's country via the lingua argument. Apply default-deny or default-allow country logic. If no lingua is provided, emit a warning and return 1 (deny).

Note that localhost (127.0.0.1) is not automatically allowed once any restriction is configured; call allow_ip('127.0.0.1') explicitly.

USAGE

use CGI::Lingua;
use CGI::ACL;

my $acl = CGI::ACL->new()->allow_ip('8.35.80.39');

if ($acl->all_denied()) {
    print "You are not allowed to view this site.\n";
    exit;
}

# Country check
my $acl2 = CGI::ACL->new()
    ->deny_country('*')
    ->allow_country('US');

if ($acl2->all_denied(lingua => CGI::Lingua->new(supported => ['en']))) {
    print "US-only site.\n";
    exit;
}

ARGUMENTS

lingua (optional)

A CGI::Lingua object (or any object with a country() method returning an ISO 3166-1 alpha-2 code or undef). Required when country restrictions are active; ignored otherwise.

RETURNS

1 if access is denied, 0 if access is allowed.

SIDE EFFECTS

May populate or update $self->{_cidrlist} (the memoised CIDR lookup structure) as a performance optimisation.

API SPECIFICATION

Input

# Compatible with Params::Validate::Strict:
{
    lingua => { type => 'object', optional => 1 },
}

Output

# Compatible with Return::Set:
{ type => 'string', regex => qr/^[01]$/ }

MESSAGES

Usage: all_denied($lingua)

Severity: carp (warning). Cause: Country restrictions are active (deny_country or allow_country was called) but no lingua argument was supplied. Action: Pass a CGI::Lingua object: all_denied(lingua => $lingua).

AUTHOR

Nigel Horne, <njh at nigelhorne.com>

BUGS

Please report any bugs or feature requests to bug-cgi-acl at rt.cpan.org, or through the web interface at http://rt.cpan.org/NoAuth/ReportBug.html?Queue=CGI-ACL.

A VPN or proxy will most likely bypass IP-based access control.

SEE ALSO

SUPPORT

perldoc CGI::ACL

FORMAL SPECIFICATION

new

──────────────── ACLState ────────────────────────────────────────
  allowed_ips    : IP_Str ⇸ Bool
  deny_countries : Country ⇸ Bool
  allow_countries: Country ⇸ Bool
  deny_cloud     : Bool
  _cidrlist      : [CIDR_Str]?        -- memoised; cleared on allow_ip
──────────────────────────────────────────────────────────────────

─────────────── New ──────────────────────────────────────────────
  class  : ClassName ∪ ACLState
  params : ACLState?
  ─────────────────────────────────────────────────────────────────
  blessed(class) ⟹
    result! = bless( class ∪ params, ref(class) )   -- clone
  ¬blessed(class) ⟹
    result! = bless( configure(class, params), class )
──────────────────────────────────────────────────────────────────

allow_ip

─────────────── AllowIP ──────────────────────────────────────────
  ΔACL
  ip? : IP_Str
  ─────────────────────────────────────────────────────────────────
  allowed_ips' = allowed_ips ∪ { ip? ↦ 1 }
  _cidrlist'   = ∅          -- cache invalidated
  deny_countries' = deny_countries
  allow_countries' = allow_countries
  deny_cloud'     = deny_cloud
──────────────────────────────────────────────────────────────────

deny_country

─────────────── DenyCountry ─────────────────────────────────────
  ΔACL
  country? : ISO_Code ∪ {'*'} ∪ seq ISO_Code
  ─────────────────────────────────────────────────────────────────
  country? ∈ seq ISO_Code ⟹
    deny_countries' = deny_countries ∪
                      { lc(c) ↦ 1 | c ∈ country? }
  country? ¬in; seq ISO_Code ⟹
    deny_countries' = deny_countries ∪ { lc(country?) ↦ 1 }
  allow_countries' = allow_countries
  allowed_ips'     = allowed_ips
  deny_cloud'      = deny_cloud
──────────────────────────────────────────────────────────────────

allow_country

─────────────── AllowCountry ────────────────────────────────────
  ΔACL
  country? : ISO_Code ∪ seq ISO_Code
  ─────────────────────────────────────────────────────────────────
  country? ∈ seq ISO_Code ⟹
    allow_countries' = allow_countries ∪
                       { lc(c) ↦ 1 | c ∈ country? }
  country? ¬in; seq ISO_Code ⟹
    allow_countries' = allow_countries ∪ { lc(country?) ↦ 1 }
  deny_countries' = deny_countries
  allowed_ips'    = allowed_ips
  deny_cloud'     = deny_cloud
──────────────────────────────────────────────────────────────────

deny_cloud

─────────────── DenyCloud ───────────────────────────────────────
  ΔACL
  ─────────────────────────────────────────────────────────────────
  deny_cloud'     = 1
  allowed_ips'    = allowed_ips
  deny_countries' = deny_countries
  allow_countries'= allow_countries
  _cidrlist'      = _cidrlist
──────────────────────────────────────────────────────────────────

all_denied

──────────────────────── AllDenied ──────────────────────────────
  ΞACL                          -- state unchanged (modulo cache)
  addr    : IPv4 ∪ IPv6         -- REMOTE_ADDR or DEFAULT_ADDR
  lingua? : Lingua              -- country resolver (optional)
  result! : {0, 1}              -- 0 = allow, 1 = deny
  ─────────────────────────────────────────────────────────────────
  no_restrictions(self) ⟹ result! = 0

  ¬valid_ip(addr) ⟹ result! = 1

  deny_cloud = 1 ∧ is_cloud(addr) ⟹ result! = 1
  deny_cloud = 1 ∧ ¬is_cloud(addr)
    ∧ allowed_ips = ∅ ∧ deny_countries = ∅
    ∧ allow_countries = ∅            ⟹ result! = 0

  addr ∈ dom(allowed_ips) ⟹ result! = 0
  cidr_match(addr, allowed_ips) ⟹ result! = 0

  (deny_countries ≠ ∅ ∨ allow_countries ≠ ∅)
    ∧ lingua? = ∅ ⟹ result! = 1      -- no lingua supplied
  lingua?.country() = undef ⟹ result! = 1   -- unknown country

  deny_countries($WILDCARD) = 1
    ∧ allow_countries(lc(lingua?.country())) = 1 ⟹ result! = 0
  deny_countries($WILDCARD) = 1
    ∧ allow_countries(lc(lingua?.country())) ≠ 1 ⟹ result! = 1

  deny_countries($WILDCARD) ≠ 1
    ∧ deny_countries(lc(lingua?.country())) = 1 ⟹ result! = 1
  deny_countries($WILDCARD) ≠ 1
    ∧ deny_countries(lc(lingua?.country())) ≠ 1 ⟹ result! = 0
──────────────────────────────────────────────────────────────────

LICENSE AND COPYRIGHT

Copyright 2017-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.