Search by

laikait / laika-shield

riyadhtayf

A powerful firewall package for the Laika PHP Framework - IP filtering, rate limiting, SQL injection & XSS detection, and request filtering.

Package info

github.com/laikait/laika-shield

pkg:composer/laikait/laika-shield

Statistics

Installs: 2 106

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

v2.0.3 2026-08-25 13:00 UTC

This package is auto-updated.

Last update: 2026-08-25 13:02:08 UTC


README

Laika Shield is the firewall layer for the Laika PHP Framework โ€” IP and country filtering, rate limiting, SQL injection and XSS detection, and request filtering.

Tests PHP License: MIT

โœจ Features

Feature Description
๐ŸŒ Country Blocking Block or allowlist entire countries via MaxMind GeoLite2
๐Ÿšซ IP Blocking Block individual IPs or CIDR ranges
โœ… IP Allowlisting Restrict access to specific IPs/ranges only
๐Ÿ”ข IP Version Filtering Allow only IPv4 or only IPv6 connections
โฑ๏ธ Rate Limiting Limit requests per IP per time window
๐Ÿ’‰ SQL Injection Detection Block common SQLi attack payloads
๐Ÿ› XSS Detection Block cross-site scripting attempts
๐Ÿ” Request Filtering Filter by HTTP method, URI, User-Agent, headers, and body size

๐Ÿ“ฆ Installation

Laika Shield ships as part of the framework โ€” laikait/laika-framework already requires it, so there is usually nothing to install.

To pull it into a project directly:

composer require laikait/laika-shield

Requires PHP 8.1+ and geoip2/geoip2. Country blocking additionally needs a MaxMind GeoLite2-Country database, which is not bundled โ€” see Country Blocking.

๐Ÿš€ Quick Start

1. Register the pipeline

Add ShieldPipeline to your route pipeline stack โ€” first, ahead of everything else. It is only a firewall if nothing has run yet.

use Laika\Shield\Pipeline\ShieldPipeline;

// In your pipeline registration
ShieldPipeline::class,

There is no config file to publish โ€” the defaults are already complete, so the line above gives you a working firewall.

2. Adjust the configuration

use Laika\Shield\ShieldConfig;
use Laika\Shield\Pipeline\ShieldPipeline;

// ShieldConfig is a singleton โ€” configure it once and everything
// (ShieldPipeline, Shield::boot()) picks it up.
ShieldConfig::ip()->blocklist(['1.2.3.4', '10.0.0.0/8']);
ShieldConfig::rateLimit()->maxHits(30)->window(120);
ShieldConfig::xss()->skipKeys(['post_body']);
ShieldConfig::requestFilter()->requiredHeaders(['x-api-key']);

// The three top-level scalars live on the instance:
ShieldConfig::instance()->trustProxy(true)->trustedProxies(['10.0.0.0/8']);

new ShieldPipeline();

A blocked request emits its JSON body and terminates. It never reaches the rest of the chain, so no downstream pipeline โ€” auth, logging, database writes โ€” runs for a request the firewall rejected.

Upgrading from 1.2.x: Laika\Shield\Http\ShieldMiddleware has been removed. It fell through its own catch block and let blocked requests reach the application. Replace it with ShieldPipeline as above.

3. Or use the static API

use Laika\Shield\Shield;
use Laika\Shield\ShieldConfig;

// Shield::boot() takes no arguments โ€” it reads the shared ShieldConfig instance.
ShieldConfig::add('ip', ['blocklist' => ['1.2.3.4']]);
ShieldConfig::add('rate.limit', 'max.hits', 30);

Shield::boot();

// To run a DETACHED configuration instead โ€” ShieldConfig::make() is not the
// shared instance, so boot() will not see it:
$config = ShieldConfig::make();
$config->ip->blocklist(['1.2.3.4']);

Shield::fromConfig($config)->run();

4. Or use the fluent builder

use Laika\Shield\Shield;

(new Shield())
    ->trustProxy(true, trustedProxies: ['10.0.0.0/8'])
    ->blockCountries('/path/to/GeoLite2-Country.mmdb', blocklist: ['CN', 'RU'])
    ->blockIps(['1.2.3.4', '10.10.0.0/16'])
    ->allowIps(['203.0.113.0/24'])
    ->requireIpVersion(4) // IPv4 only
    ->rateLimit(maxHits: 100, windowSecs: 60)
    ->detectSqlInjection(skipKeys: ['password'])   // strict: false by default
    ->detectXss(skipKeys: ['html_content'])
    ->filterRequests(
        blockedMethods: ['TRACE', 'CONNECT'],
        blockedUserAgentPatterns: ['/sqlmap/i', '/nikto/i'],
    )
    ->run();

โš™๏ธ Configuration Reference

Every option lives on a typed object with a fluent accessor: call it with no argument to read, with one to write. Defaults are declared on the properties themselves.

// โ”€โ”€ Top level (instance only โ€” see the note below) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::instance()->trustProxy(false);      // consult proxy headers at all
ShieldConfig::instance()->trustedProxies([]);     // CIDRs of YOUR proxies
ShieldConfig::instance()->ipVersion(null);        // 4, 6, or null for both

// โ”€โ”€ IP filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::ip()->blocklist([]);           // denied IPs / CIDR ranges
ShieldConfig::ip()->allowlist([]);           // when non-empty, ONLY these are permitted

// โ”€โ”€ Rate limiting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::rateLimit()->maxHits(60);      // requests per window, per IP
ShieldConfig::rateLimit()->window(60);       // window size in seconds
ShieldConfig::rateLimit()->storageDir(null); // null = system temp directory

// โ”€โ”€ SQL injection detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::sqlInjection()->skipKeys([]);  // input keys never scanned
ShieldConfig::sqlInjection()->scanBody(true);
ShieldConfig::sqlInjection()->strict(false); // see Tuning The Detectors

// โ”€โ”€ XSS detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::xss()->skipKeys([]);
ShieldConfig::xss()->scanBody(true);
ShieldConfig::xss()->scanHeaders(false);

// โ”€โ”€ Request filtering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::requestFilter()->blockedMethods(['TRACE', 'CONNECT']);
ShieldConfig::requestFilter()->blockedUriPatterns([]);
ShieldConfig::requestFilter()->blockedUserAgents(['/sqlmap/i', '/nikto/i', ...]);
ShieldConfig::requestFilter()->requiredHeaders([]);
ShieldConfig::requestFilter()->blockedHeaderValues([]);
ShieldConfig::requestFilter()->contentLengthMax(null);
ShieldConfig::requestFilter()->contentLengthMin(null);

// โ”€โ”€ Country blocking โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
ShieldConfig::country()->db('');             // path to GeoLite2-Country.mmdb
ShieldConfig::country()->blocklist([]);
ShieldConfig::country()->allowlist([]);

Accessors chain, and reading is the same method without an argument:

ShieldConfig::rateLimit()->maxHits(30)->window(120);

$hits = ShieldConfig::rateLimit()->maxHits();   // 30

The six section accessors โ€” ip(), rateLimit(), sqlInjection(), xss(), requestFilter(), country() โ€” are static because there is only one configuration. The three top-level scalars (trustProxy, trustedProxies, ipVersion) stay on the instance: they are declared as instance methods, and PHP will not let a method be both static and non-static under one name.

Nullable options can be set back to null โ€” storageDir, contentLengthMax, contentLengthMin and ipVersion all accept it as a real value.

Arrays still work

ShieldPipeline and Shield::fromConfig() accept a plain array, which is applied over the defaults rather than replacing them. Supplying one option in a section leaves the rest of that section alone:

Shield::fromConfig([
    'request.filter' => ['content.length.max' => 2048],
])->run();
// blocked.methods is still ['TRACE', 'CONNECT']

Shield::boot() takes no arguments at all โ€” it always reads the shared ShieldConfig instance, so configure that first with ShieldConfig::add() or ShieldConfig::instance().

Note the difference in where the defaults come from: fromConfig() layers an array over a fresh set of defaults and never touches global state, which keeps it predictable and testable. ShieldPipeline layers an array over the shared instance, so pipeline options combine with whatever the application configured.

ShieldConfig is a singleton

ShieldConfig has no public constructor. There is exactly one shared configuration, and that is what Shield::boot() and ShieldPipeline read:

ShieldConfig::rateLimit()->maxHits(30);
Shield::boot();                      // sees it

The static section accessors are shortcuts onto that same shared instance โ€” ShieldConfig::rateLimit() is exactly ShieldConfig::instance()->rateLimit.

If you need a throwaway configuration โ€” for a test, or to run one request under different rules โ€” ShieldConfig::make() gives you a detached object. boot() will not see it, so run it explicitly:

$config = ShieldConfig::make();
$config->rateLimit->maxHits(1);       // note: property, not ShieldConfig::rateLimit()

Shield::fromConfig($config)->run();   // only this sees it

โš ๏ธ The static accessors always resolve the shared instance. If you are holding a detached config, reach its sections through the object ($config->rateLimit) โ€” ShieldConfig::rateLimit() would configure the shared one instead.

Call Returns Seen by Shield::boot()
ShieldConfig::instance() the shared configuration โœ… yes
ShieldConfig::make() a new detached configuration โŒ no โ€” use fromConfig()
ShieldConfig::fromArray([...]) a detached configuration from an array โŒ no โ€” use fromConfig()
ShieldConfig::rateLimit() etc. a section of the shared configuration โœ… yes
new ShieldConfig() โ€” Error: constructor is not public

๐Ÿ”ง ShieldConfig Class

ShieldConfig also exposes a static, array-keyed API over one shared instance. It is kept for compatibility โ€” the ShieldConfig relay is bound to it โ€” and remains handy for one-off tweaks during bootstrap. New code should prefer the object API above.

use Laika\Shield\ShieldConfig;
use Laika\Shield\Shield;

// Top-level scalar
ShieldConfig::add('trust.proxy', true);

// Top-level array merge
ShieldConfig::add('ip', ['blocklist' => ['1.2.3.4', '10.0.0.0/8']]);

// Sub-key update (simplest way to change a nested value)
ShieldConfig::add('rate.limit', 'max.hits', 30);
ShieldConfig::add('sql.injection', 'skip.keys', ['password', 'token']);
ShieldConfig::add('xss', 'skip.keys', ['content', 'body']);
ShieldConfig::add('request.filter', 'content.length.max', 2048);

// Shield::boot() reads this shared instance
Shield::boot();

ShieldConfig API

Method Description
ShieldConfig::add(string $key, mixed $value) Set or merge a top-level config key
ShieldConfig::add(string $key, string $subKey, mixed $value) Set or merge a specific sub-key
ShieldConfig::get() Return the full config array
ShieldConfig::get(string $key) Return the value of a single key
ShieldConfig::has(string $key) Check if a key exists
ShieldConfig::keys() Return all top-level config keys
ShieldConfig::reset() Reset the shared instance back to defaults
ShieldConfig::instance() The shared ShieldConfig object behind the static API

๐Ÿ—๏ธ Architecture

src/
โ”œโ”€โ”€ Shield.php                          # Main firewall engine (static + fluent API)
โ”œโ”€โ”€ ShieldConfig.php                          # Configuration object + static facade
โ”œโ”€โ”€ Contract/
โ”‚   โ”œโ”€โ”€ RuleInterface.php              # Individual Rule Interface
โ”‚   โ””โ”€โ”€ DetectorInterface.php          # Value inspector / classifier contract
โ”œโ”€โ”€ Rules/
โ”‚   โ”œโ”€โ”€ IpRule.php                     # IP blocking / allowlisting
โ”‚   โ”œโ”€โ”€ IpVersionRule.php              # IPv4 / IPv6 enforcement
โ”‚   โ”œโ”€โ”€ RateLimitRule.php              # Rate limiting
โ”‚   โ”œโ”€โ”€ CountryRule.php                # Country blocking / allowlisting
โ”‚   โ”œโ”€โ”€ SqlInjectionRule.php           # SQL injection protection
โ”‚   โ”œโ”€โ”€ XssRule.php                    # XSS protection
โ”‚   โ””โ”€โ”€ RequestFilterRule.php          # General request filtering
โ”œโ”€โ”€ Detectors/
โ”‚   โ”œโ”€โ”€ GeoIpDetector.php              # MaxMind GeoLite2 country resolver
โ”‚   โ”œโ”€โ”€ SqlInjectionDetector.php       # SQLi regex patterns engine
โ”‚   โ””โ”€โ”€ XssDetector.php                # XSS regex patterns engine
โ”œโ”€โ”€ Pipeline/
โ”‚   โ””โ”€โ”€ ShieldPipeline.php             # Laika route pipeline integration
โ”œโ”€โ”€ Support/
โ”‚   โ”œโ”€โ”€ IpHelper.php                   # IP validation, CIDR, version detection
โ”‚   โ”œโ”€โ”€ RateLimiter.php                # File-based rate limit store
โ”‚   โ””โ”€โ”€ RequestHelper.php              # Request data extraction helpers
โ”œโ”€โ”€ Exceptions/
โ”‚   โ”œโ”€โ”€ FirewallException.php          # Base firewall exception (HTTP 403)
โ”‚   โ””โ”€โ”€ RateLimitExceededException.php # Rate limit exception (HTTP 429)
โ””โ”€โ”€ ShieldConfig/
    โ”œโ”€โ”€ SectionConfig.php              # Fluent accessor base for the sections
    โ”œโ”€โ”€ IpConfig.php                   # ip.blocklist / ip.allowlist
    โ”œโ”€โ”€ RateLimitConfig.php            # rate.limit.*
    โ”œโ”€โ”€ SqlInjectionConfig.php         # sql.injection.*
    โ”œโ”€โ”€ XssConfig.php                  # xss.*
    โ”œโ”€โ”€ RequestFilterConfig.php        # request.filter.*
    โ””โ”€โ”€ CountryConfig.php              # country.*

๐Ÿ”Œ Writing Custom Rules

Implement RuleInterface to create your own firewall rules:

use Laika\Shield\Contract\RuleInterface;

class CountryBlockRule implements RuleInterface
{
    public function passes(): bool
    {
        // Your logic here
        return true;
    }

    public function message(): string
    {
        return 'Access Denied From Your Country.';
    }

    public function statusCode(): int
    {
        return 403;
    }

    public function additionalHeader(): void
    {
        return;
    }
}

// Register it
(new Shield())
    ->addRule(new CountryBlockRule())
    ->run();

๐Ÿงช Running Tests

composer install
vendor/bin/phpunit

๐ŸŒ IP Version Detection

Shield exposes IpHelper for standalone IP utilities:

use Laika\Shield\Support\IpHelper;

IpHelper::version('8.8.8.8');          // 4
IpHelper::version('2001:db8::1');      // 6
IpHelper::version('invalid');          // null

IpHelper::isV4('192.168.1.1');         // true
IpHelper::isV6('::1');                 // true
IpHelper::isPrivate('10.0.0.1');       // true
IpHelper::isLoopback('127.0.0.1');     // true
IpHelper::inCidr('192.168.1.5', '192.168.1.0/24'); // true

// Resolve real client IP (proxy-aware)
$ip = IpHelper::resolve(trustProxy: true, trustedProxies: ['10.0.0.0/8']);

๐Ÿ” Trusting Proxies

Forwarded headers are attacker-controlled. Anyone can send X-Forwarded-For: 8.8.8.8, so believing the wrong one turns every IP rule into a suggestion.

Shield only consults them when trust.proxy is on, and:

  • CF-Connecting-IP / X-Real-IP are believed only when the connecting peer is listed in trusted.proxies. A direct client can set these headers just as easily as a proxy can.
  • X-Forwarded-For is walked right to left, discarding hops that match trusted.proxies, and the first remaining address wins. The leftmost entry โ€” the one a client fully controls โ€” is never trusted.
  • With trusted.proxies empty, the rightmost X-Forwarded-For entry is used, since that is the only entry your own proxy wrote.
'trust.proxy'     => true,
'trusted.proxies' => ['10.0.0.0/8', '173.245.48.0/20'],

If your app is not behind a proxy, leave trust.proxy => false.

๐ŸŒ Country Blocking

The GeoLite2 database is not distributed with this package โ€” it is MaxMind-licensed and roughly 9.5 MB. Fetch it with your own licence key:

# https://github.com/maxmind/geoipupdate
geoipupdate -f GeoIP.conf -d /var/lib/GeoIP

Then point the config at it:

'country' => [
    'db'        => '/var/lib/GeoIP/GeoLite2-Country.mmdb',
    'blocklist' => ['CN', 'RU'],
    'allowlist' => [],
],

A missing or unreadable database does not block anyone and does not raise an error โ€” requests simply pass the country check.

๐ŸŽฏ Tuning The Detectors

The detectors match SQL and HTML syntax, not vocabulary. Words like select, sleep, drop table or #42 in ordinary prose are not treated as attacks.

strict mode adds keyword-only SQL patterns. It will flag normal sentences, so enable it only for fields that never carry free text:

'sql.injection' => [
    'skip.keys' => ['bio', 'comment'],  // never scanned
    'strict'    => false,               // recommended
],

For rich-text or markup-bearing fields, use skip.keys rather than weakening the patterns for everything.

๐Ÿ“„ License

MIT ยฉ Laika IT

GeoLite2 data, if you use it, is ยฉ MaxMind and governed by the GeoLite2 End User Licence Agreement.