laikait / laika-shield
A powerful firewall package for the Laika PHP Framework - IP filtering, rate limiting, SQL injection & XSS detection, and request filtering.
Requires
- php: >=8.1
- ext-json: *
- geoip2/geoip2: ^3
Requires (Dev)
- dealerdirect/phpcodesniffer-composer-installer: ^1.1
- phpcompatibility/php-compatibility: dev-develop
- phpunit/phpunit: ^10.0
- squizlabs/php_codesniffer: ^4.0.2
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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.
โจ 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\ShieldMiddlewarehas been removed. It fell through its own catch block and let blocked requests reach the application. Replace it withShieldPipelineas 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-IPare believed only when the connecting peer is listed intrusted.proxies. A direct client can set these headers just as easily as a proxy can.X-Forwarded-Foris walked right to left, discarding hops that matchtrusted.proxies, and the first remaining address wins. The leftmost entry โ the one a client fully controls โ is never trusted.- With
trusted.proxiesempty, the rightmostX-Forwarded-Forentry 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.