← Files
PHP Shield — modular request firewall for PHP

PHP Shield#

Modular request firewall for PHP: local threat detection, IPv4/IPv6 reputation (AbuseIPDB.com and AbuseIPDB.ru), rate limiting, IP blocking with severity-based lifetimes, an incident journal and an admin dashboard.

English · Русский · Deutsch · Español · 中文 · हिन्दी

Table of contents#

What it does#

One entry point, Deynekin\PhpShield\Middleware\Shield::inspect(), answers a single question: may this request proceed, and if not, why?

It combines four layers:

Layer Class Responsibility
Decision Middleware\Shield rate limits, payload/header scanning, IP reputation, the verdict
State Middleware\SecurityMiddleware blocks, incident journal, failed-attempt counters, cleanup, statistics
Detection Detection\* pluggable rules that score a payload
Knowledge ThreatIntel\* AbuseIPDB.com / AbuseIPDB.ru reputation with cache and circuit breaker

Two rules shape every decision:

What the shield is not: a replacement for prepared statements, output escaping, CSP, authentication or session security. It buys time and visibility; it does not fix application defects.

How a request is decided#

inspect() walks the layers in a fixed order: cheap checks first, the network last, and a trusted address leaves before any work is done. Every branch ends in the same decision array: blocked, status, reason, detail, score and findings.

flowchart TD
    REQ(["HTTP request"]) --> INSPECT["Shield::inspect()"]
    INSPECT --> ENABLED{"enabled?"}
    ENABLED -->|no| ALLOW(["allow"])
    ENABLED -->|yes| TRUSTED{"client IP in trusted_ips?"}
    TRUSTED -->|yes, never counted| ALLOW
    TRUSTED -->|no| BLOCKED{"address blocked?"}
    BLOCKED -->|yes| DENY403["403 ip_blocked"]
    BLOCKED -->|no| FAILS{"failed attempts at limit?"}
    FAILS -->|yes| BLOCK1["block once"]
    BLOCK1 --> DENY403B["403 too_many_failures"]
    FAILS -->|no| RATE{"rate limit exceeded?"}
    RATE -->|yes| BLOCK2["block 300 s"]
    BLOCK2 --> DENY429["429 rate_limited"]
    RATE -->|no| REPUTATION["IP reputation: cache, provider fallback, breaker"]
    REPUTATION --> SCORED{"abuse score"}
    SCORED -->|block_score and above| BLOCK3["block + incident"]
    BLOCK3 --> DENY403C["403 ip_reputation"]
    SCORED -->|flag_score and above| JOURNAL1["journal only"]
    JOURNAL1 --> SCAN["payload scan: 6 rules over GET, POST, REQUEST_URI"]
    SCORED -->|below, no data or outage| SCAN
    SCAN --> FINDING{"score"}
    FINDING -->|block mode, threshold reached| BLOCK4["journal + block"]
    BLOCK4 --> DENY403D["403 threat_detected"]
    FINDING -->|challenge mode, threshold reached| DENY403E["403 challenge_required"]
    FINDING -->|suspicious| JOURNAL2["journal once per 10 min"]
    JOURNAL2 --> HEADERS["header scan: User-Agent"]
    FINDING -->|clean| HEADERS
    HEADERS -->|scanner| JOURNAL3["journal or block"]
    HEADERS -->|clean| ALLOW
    JOURNAL3 --> ALLOW
    ALLOW --> DECISION["decision array: blocked, status, reason, detail, score, findings"]
    DENY403 --> DECISION
    DENY403B --> DECISION
    DENY429 --> DECISION
    DENY403C --> DECISION
    DENY403D --> DECISION
    DENY403E --> DECISION
    DECISION --> APP(["application or error response"])
    classDef allow fill:#d3f9d8,stroke:#2f9e44,color:#000000
    classDef deny fill:#ffe3e3,stroke:#c92a2a,color:#000000
    classDef step fill:#e7f5ff,stroke:#1c7ed6,color:#000000
    class ALLOW,APP allow
    class DENY403,DENY403B,DENY429,DENY403C,DENY403D,DENY403E deny
    class INSPECT,SCAN,DECISION step

The mode branches sit next to the score, not at the top, because monitor and challenge change the reaction to a finding, not the order of the checks.

Requirements#

Install#

composer require paulmann/php-shield

Quick start#

<?php
require 'vendor/autoload.php';

use Deynekin\PhpShield\ShieldFactory;

$config = require 'vendor/paulmann/php-shield/config/default.php';
$config['mode'] = 'monitor';              // start safe, switch to "block" after tuning

$mysqli = new mysqli('127.0.0.1', 'user', 'pass', 'dbname');
$shield = ShieldFactory::create($config, $mysqli);

$decision = $shield->inspect(array_merge($_GET, $_POST), $_SERVER, $_SERVER['REQUEST_URI'] ?? '/');

if ($decision['blocked']) {
    http_response_code($decision['status']);
    header('Content-Type: application/json');
    exit(json_encode(['error' => $decision['reason'], 'detail' => $decision['detail']]));
}

// Application logic continues...

No MySQL? Build the shield from components instead — local detection, headers and statistics keep working:

$shield = ShieldFactory::createWithComponents(
    $blockStore,                                  // your BlockStoreInterface implementation
    new \Deynekin\PhpShield\RateLimiting\RedisRateLimiter($redis),
    $config
);

The decision array#

inspect() returns:

[
    'blocked'  => true,                 // may the request proceed?
    'status'   => 403,                  // HTTP status to send
    'reason'   => 'threat_detected',    // machine-readable code
    'detail'   => '...',                // human-readable text
    'score'    => 90,                   // accumulated score
    'findings' => ['xss', 'sql_injection'],
]

Reasons: ip_blocked, too_many_failures, rate_limited, ip_reputation, threat_detected, challenge_required, suspicious_user_agent.

The order of checks is fixed and intentional: master switch → trusted address (never blocked, never counted) → active block → failed-attempt counter → rate limits → IP reputation → payload scan → header scan → allow.

If you only need a boolean, call isRequestAllowed() with the same arguments.

IP reputation: AbuseIPDB.com and AbuseIPDB.ru#

Both services implement the same API v2 contract (/check, Key: header, identical envelopes), so one client serves both and the checker tries them in order.

The reputation layer is asynchronous by default: a request never waits for AbuseIPDB. A known address is answered from the cache, an unknown one is queued with a single INSERT, and the worker fills the cache out of band. Switch to 'mode' => 'sync' only for low-traffic endpoints, tests or CLI tools.

Mode Request path When the verdict applies
async (default) cache read plus at most one INSERT into php_shield_reputation_queue from the next request after the worker has stored the answer
sync one HTTP call, bounded by timeout immediately
# cron: fill the reputation cache every minute
* * * * * php /path/to/PHP-Shield/tools/reputation-worker.php >> /var/log/php-shield-reputation.log 2>&1

php tools/reputation-worker.php --status         # queue size and providers
php tools/reputation-worker.php --ip=8.8.8.8     # one address now (debugging)
$config['ip_reputation'] = [
    'enabled' => true,
    'block_score' => 75,   // abuseConfidenceScore that blocks
    'flag_score' => 25,    // score that is only journaled
    'cache_ttl' => 86400,
    'max_age_days' => 90,
    'timeout' => 2,        // sync mode only
    'queue_batch' => 50,   // addresses claimed per worker run
    'per_run' => 100,      // lookups per worker run (quota guard)
    'retry_after' => 300,  // retry delay after a failed lookup
    'providers' => [
        ['name' => 'abuseipdb.com', 'base_url' => 'https://api.abuseipdb.com/api/v2',
         'api_key' => null, 'api_key_env' => 'ABUSEIPDB_API_KEY'],
        ['name' => 'abuseipdb.ru',  'base_url' => 'https://abuseipdb.ru/api/v2',
         'api_key' => null, 'api_key_env' => 'ABUSEIPDB_RU_API_KEY'],
    ],
];
export ABUSEIPDB_API_KEY=your-key          # or put the key in the config, never in git
php tools/live-abuseipdb.php 8.8.8.8

Behaviour that matters in production:

Detection rules#

Rule Category Score Notes
sql_injection injection 25–80 UNION SELECT, stacked queries, SLEEP, tautologies
xss xss 40–90 script/iframe/object tags, event handlers, javascript:
command_injection injection 60–85 system() family, pipes to commands, command backticks
path_traversal path 60–95 repeated ../, /etc/passwd, .ssh, /proc/self
file_inclusion inclusion 60–85 remote includes, php:// wrappers
suspicious_header headers 60–70 scanner User-Agents (sqlmap, nikto, nmap, …)

Values are scanned both as received and after URL decoding, plus the raw REQUEST_URI, so %3Cscript%3E is caught. One finding per rule and field is recorded, so a twice-encoded payload cannot inflate its score.

Custom rules#

use Deynekin\PhpShield\Detection\AbstractRule;
use Deynekin\PhpShield\Detection\RuleFactory;

final class MyRule extends AbstractRule
{
    public function getName(): string { return 'my_rule'; }
    public function getCategory(): string { return 'custom'; }

    public function check(string $value, string $fieldName): array
    {
        return $this->match('/evil/i', $value, $fieldName, 45, 'Custom evil pattern');
    }
}

RuleFactory::register('my_rule', MyRule::class);

Modes and block lifetimes#

Mode Behaviour
monitor everything is journaled, nothing is blocked
challenge hard findings are refused with 403 challenge_required and not blocked
block hard findings block the address for a severity-derived lifetime

Lifetime = block_duration × severity_block_multipliers:

Severity Multiplier Default lifetime
low 0.5 30 min
medium 1 1 h
high 4 4 h
critical 12 12 h

Rate limiting has its own short window (rate_limit_block_duration, 300 s) so a burst from a shared/NAT address cannot lock out a whole office. A permanent block is never automatic: it is an operator action in the dashboard or blockPermanently().

Rate limiting#

Backend How it counts Use it when
DatabaseRateLimiter two statements (count, insert) on php_shield_rate_limit low and medium traffic; call prune() on a schedule
RedisRateLimiter atomic INCR + EXPIRE concurrent traffic; fails open and logs if Redis is unreachable

Default limits are 60/minute, 1 000/hour and 10 000/day per address and endpoint. Both backends implement RateLimiterInterface, so an existing limiter can be plugged in.

State API#

$state = $shield->middleware();

$state->isIpBlocked('203.0.113.5');
$state->blockIp('203.0.113.5', 'scanner', SecurityMiddleware::SEVERITY_HIGH);
$state->blockPermanently('203.0.113.5', 'manual decision');   // operator only
$state->unblockIp('203.0.113.5');

$state->recordFailedAttempt('203.0.113.5');   // e.g. from your login handler
$state->failedAttempts('203.0.113.5');
$state->clearFailedAttempts('203.0.113.5');

$state->recordIncident('203.0.113.5', 'login_bruteforce', 'Ten failed logins', 'high');
$state->recentIncidentCount('203.0.113.5', 600);
$state->activeBlocks(50);
$state->stats();
$state->cleanup(30);        // retention: expired blocks, old incidents, stale counters

When a failed-attempt counter reaches max_failed_attempts (25), the next request from that address is blocked once and the counter is cleared.

Tables: php_shield_ip_blocks, php_shield_incidents, php_shield_failed_attempts, php_shield_rate_limit, php_shield_ip_reputation. All expiries are computed by the database (NOW(), DATE_ADD), so a PHP/MySQL time-zone difference cannot expire a fresh block.

Admin dashboard#

public/admin/index.php — statistics, active blocks, manual block/unblock, masked configuration.

$config['admin']['allowed_ips'] = [
    '203.0.113.0/24',   // office
    '198.51.100.7',     // jump host, /32
    '2001:db8::/32',    // IPv6
];
// empty array = no restriction (default)

Entries are IPv4/IPv6 CIDR; a bare address means /32. When the application sits behind a proxy, list
that proxy in trusted_proxies — otherwise the forwarded header is ignored and the gate sees the proxy
address. A string such as '203.0.113.0/24, 198.51.100.7' is accepted as well, which keeps
environment-driven configuration simple.

Generate a hash and protect the endpoint at the web-server level too:

php -r "echo password_hash('your-secret', PASSWORD_DEFAULT), PHP_EOL;"

Reverse proxies and client IP#

X-Forwarded-For is honoured only when REMOTE_ADDR is in trusted_proxies, and the chain is read from right to left, skipping trusted hops:

X-Forwarded-For: 1.2.3.4, 203.0.113.9      (client-supplied, added by nginx)
REMOTE_ADDR:     10.0.0.5                  (trusted proxy)
→ client = 203.0.113.9

Reading the left-most entry instead would let any client choose its own address — and, with a trusted_ips entry matching it, bypass the whole shield. This was a real defect, fixed in 2.0.0 and documented in the security review.

Architecture#

src/
├── ShieldFactory.php              ← wiring from configuration
├── Middleware/
│   ├── Shield.php                 ← decision: inspect() / isRequestAllowed()
│   └── SecurityMiddleware.php     ← state: blocks, incidents, counters, cleanup
├── Detection/                     ← rules + registry
├── ThreatIntel/                   ← AbuseIPDB.com / .ru, cache, circuit breaker
├── Ip/                            ← safe client IP resolution, CIDR
├── RateLimiting/                  ← database + Redis limiters
├── Storage/                       ← block store interfaces + MySQL
├── Admin/                         ← dashboard logic, sessions, CSRF
├── Support/                       ← typed config, redaction, headers
└── Exception/

Design decisions, alternatives and their consequences are recorded in docs/adr.md.

Testing#

composer test          # PHPUnit, 120 tests, no database needed
composer phpstan       # level max, zero errors
composer cs-check      # coding standard

# real MySQL (creates its own tables, never drops a database)
MYSQL_PWD=... php -d extension=mysqli tools/db-selftest.php php_shield_test

# live AbuseIPDB check (key from the environment)
ABUSEIPDB_API_KEY=your-key php tools/live-abuseipdb.php 8.8.8.8

CI runs PHP 8.1 – 8.5, PHPUnit, PHPStan level max, the coding standard and a syntax check of every PHP file.

Production checklist#

  1. Run in monitor mode for a few days.

  2. Read php_shield_incidents and tune block_threshold / disable noisy rules.

  3. Apply migrations/schema.sql and set auto_migrate to false.

  4. Switch the rate limiter to Redis for concurrent traffic.

  5. Fill trusted_proxies if you are behind a reverse proxy — forwarded headers are honoured only from those addresses, and the chain is read right to left.

  6. Protect the admin dashboard; set a strong password hash.

  7. Schedule cleanup() (retention) and DatabaseRateLimiter::prune().

  8. Enable ip_reputation with AbuseIPDB.com, keep AbuseIPDB.ru as the fallback.

  9. Add SecurityHeaders::send() early in the request.

  10. Switch to block mode and watch the 403/429 rate for a day.

Documentation#

Contributing#

Read CONTRIBUTING.md first: coding standard, static analysis, tests and the review checklist. Issues and pull requests are welcome; security reports go through SECURITY.md.

License#

MIT — see LICENSE.

Credits#

Built by Mikhail Deynekin. The severity policy and the state/detection split were shaped by the stand-alone middleware used on AbuseIPDB.RU and by the WAF of PHP-Form-Spam-Checker.


English · Русский · Deutsch · Español · 中文 · हिन्दी