![PHP Shield — modular request firewall for PHP](assets/social-preview.png)

# PHP Shield

**Firewall modular de solicitudes para PHP**: detección local de amenazas, reputación de IPv4/IPv6 (AbuseIPDB.com y AbuseIPDB.ru), limitación de tasa, bloqueo de IP con duraciones según la severidad, una bitácora de incidentes y un panel de administración.

[English](README.md) · [Русский](README.ru.md) · [Deutsch](README.de.md) · **Español** · [中文](README.zh.md) · [हिन्दी](README.hi.md)

[![CI](https://github.com/paulmann/PHP-Shield/actions/workflows/ci.yml/badge.svg)](https://github.com/paulmann/PHP-Shield/actions)
[![PHP Version](https://img.shields.io/badge/php-8.1--8.5-blue)](https://php.net)
[![PHPStan](https://img.shields.io/badge/PHPStan-level%20max-brightgreen)](phpstan.neon)
[![Tests](https://img.shields.io/badge/tests-120-brightgreen)](tests)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Wiki](https://img.shields.io/badge/docs-wiki-blue)](https://github.com/paulmann/PHP-Shield/wiki)

## Índice

- [Qué hace](#qué-hace)
- [Cómo se decide una petición](#cómo-se-decide-una-petición)
- [Requisitos](#requisitos)
- [Instalación](#instalación)
- [Inicio rápido](#inicio-rápido)
- [El array de decisión](#el-array-de-decisión)
- [Reputación de IP: AbuseIPDB.com y AbuseIPDB.ru](#reputación-de-ip-abuseipdbcom-y-abuseipdbru)
- [Reglas de detección](#reglas-de-detección)
- [Modos y duración de los bloqueos](#modos-y-duración-de-los-bloqueos)
- [Limitación de tasa](#limitación-de-tasa)
- [API de estado](#api-de-estado)
- [Panel de administración](#panel-de-administración)
- [Proxies inversos e IP del cliente](#proxies-inversos-e-ip-del-cliente)
- [Arquitectura](#arquitectura)
- [Pruebas](#pruebas)
- [Lista de verificación para producción](#lista-de-verificación-para-producción)
- [Documentación](#documentación)
- [Contribución](#contribución)
- [Licencia](#licencia)
- [Créditos](#créditos)

## Qué hace

Un único punto de entrada, `Deynekin\PhpShield\Middleware\Shield::inspect()`, responde a una sola pregunta: **¿puede continuar esta solicitud y, si no, por qué?**

Combina cuatro capas:

| Capa | Clase | Responsabilidad |
|---|---|---|
| Decisión | `Middleware\Shield` | límites de tasa, escaneo de payload y encabezados, reputación de IP, el veredicto |
| Estado | `Middleware\SecurityMiddleware` | bloqueos, bitácora de incidentes, contadores de intentos fallidos, limpieza, estadísticas |
| Detección | `Detection\*` | reglas conectables que puntúan un payload |
| Conocimiento | `ThreatIntel\*` | reputación de AbuseIPDB.com / AbuseIPDB.ru con caché y circuit breaker |

Dos reglas condicionan cada decisión:

- **Un solo hallazgo nunca bloquea a nadie.** Las puntuaciones se acumulan y solo un total superior a `block_threshold` bloquea. Es un operador, no una expresión regular, quien decide un bloqueo permanente.
- **Nada falla en cerrado ante problemas de infraestructura.** Una caída de la base de datos o de la API significa «sin datos», no «denegar»; véase [ADR-006](docs/adr.md).

Lo que el shield **no** es: un sustituto de las consultas preparadas, el escape de salida, la CSP, la autenticación o la seguridad de sesiones. Gana tiempo y visibilidad; no corrige los defectos de la aplicación.

## Cómo se decide una petición

`inspect()` recorre las capas en un orden fijo: primero las comprobaciones baratas, la red al final, y una dirección de confianza sale del camino antes de cualquier trabajo. Toda rama termina en el mismo array de decisión: `blocked`, `status`, `reason`, `detail`, `score` y `findings`.

```mermaid
flowchart TD
    REQ(["Petición HTTP"]) --> INSPECT["Shield::inspect()"]
    INSPECT --> ENABLED{"¿activado?"}
    ENABLED -->|no| ALLOW(["permitir"])
    ENABLED -->|sí| TRUSTED{"¿IP del cliente en trusted_ips?"}
    TRUSTED -->|sí, nunca se cuenta| ALLOW
    TRUSTED -->|no| BLOCKED{"¿dirección bloqueada?"}
    BLOCKED -->|sí| DENY403["403 ip_blocked"]
    BLOCKED -->|no| FAILS{"¿intentos fallidos en el límite?"}
    FAILS -->|sí| BLOCK1["bloqueo único"]
    BLOCK1 --> DENY403B["403 too_many_failures"]
    FAILS -->|no| RATE{"¿límite de tasa superado?"}
    RATE -->|sí| BLOCK2["bloqueo de 300 s"]
    BLOCK2 --> DENY429["429 rate_limited"]
    RATE -->|no| REPUTATION["reputación IP: caché, proveedor de respaldo, cortacircuitos"]
    REPUTATION --> SCORED{"puntuación de abuso"}
    SCORED -->|block_score y superior| BLOCK3["bloqueo e incidencia"]
    BLOCK3 --> DENY403C["403 ip_reputation"]
    SCORED -->|flag_score y superior| JOURNAL1["solo registro"]
    JOURNAL1 --> SCAN["análisis de la carga: 6 reglas sobre GET, POST, REQUEST_URI"]
    SCORED -->|por debajo, sin datos o caída| SCAN
    SCAN --> FINDING{"puntuación"}
    FINDING -->|modo block, umbral alcanzado| BLOCK4["registro y bloqueo"]
    BLOCK4 --> DENY403D["403 threat_detected"]
    FINDING -->|modo challenge, umbral alcanzado| DENY403E["403 challenge_required"]
    FINDING -->|sospechoso| JOURNAL2["registro cada 10 minutos"]
    JOURNAL2 --> HEADERS["análisis de cabeceras: User-Agent"]
    FINDING -->|limpio| HEADERS
    HEADERS -->|escáner| JOURNAL3["registro o bloqueo"]
    HEADERS -->|limpio| ALLOW
    JOURNAL3 --> ALLOW
    ALLOW --> DECISION["array de decisión: blocked, status, reason, detail, score, findings"]
    DENY403 --> DECISION
    DENY403B --> DECISION
    DENY429 --> DECISION
    DENY403C --> DECISION
    DENY403D --> DECISION
    DENY403E --> DECISION
    DECISION --> APP(["aplicación o respuesta de error"])
    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
```

Las ramas de modo están junto a la puntuación, no al principio: `monitor` y `challenge` cambian la reacción ante un hallazgo, no el orden de las comprobaciones.

## Requisitos

- PHP 8.1 – 8.5, `ext-json`, `ext-mbstring`
- Opcional: `ext-mysqli` (almacenamiento MySQL, administración), `ext-redis` (limitador de tasa Redis), `ext-curl` (reputación de IP)

## Instalación

```bash
composer require paulmann/php-shield
```

## Inicio rápido

```php
<?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 hay MySQL? En su lugar, construye el shield a partir de componentes: la detección local, los encabezados y las estadísticas siguen funcionando:

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

## El array de decisión

`inspect()` devuelve:

```php
[
    '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'],
]
```

Motivos: `ip_blocked`, `too_many_failures`, `rate_limited`, `ip_reputation`, `threat_detected`, `challenge_required`, `suspicious_user_agent`.

El orden de las comprobaciones es fijo e intencional: interruptor maestro → dirección de confianza (nunca bloqueada, nunca contabilizada) → bloqueo activo → contador de intentos fallidos → límites de tasa → reputación de IP → escaneo del payload → escaneo de encabezados → permitir.

Si solo se necesita un valor booleano, basta con llamar a `isRequestAllowed()` con los mismos argumentos.

# README i18n es — run 78725121 participant 1

## IP reputation: AbuseIPDB.com and AbuseIPDB.ru

Ambos servicios implementan el mismo contrato de API v2 (`/check`, cabecera `Key:`, envoltorios idénticos), así que un solo cliente sirve para ambos y el verificador los prueba en orden.

La capa de reputación es **asíncrona por defecto**: una petición nunca espera a AbuseIPDB. Una dirección conocida se responde desde la caché, una desconocida se encola con un único `INSERT`, y el worker rellena la caché fuera de banda. Cambie a `'mode' => 'sync'` solo para endpoints de bajo tráfico, pruebas o herramientas CLI.

| Modo | Ruta de la petición | Cuándo se aplica el veredicto |
|---|---|---|
| `async` (predeterminado) | lectura de caché más como máximo un `INSERT` en `php_shield_reputation_queue` | a partir de la siguiente petición, una vez que el worker ha almacenado la respuesta |
| `sync` | una llamada HTTP, acotada por `timeout` | de inmediato |

```bash
# 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)
```

```php
$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'],
    ],
];
```

```bash
export ABUSEIPDB_API_KEY=[redacted]          # or put the key in the config, never in git
php tools/live-abuseipdb.php 8.8.8.8
```

Comportamiento que importa en producción:

- las respuestas se cachean durante `cache_ttl` segundos — una ráfaga cuesta una sola consulta, y solo se pregunta por direcciones públicas;
- el worker respeta `per_run`, así que la cuota diaria se gasta gradualmente en lugar de en una sola ráfaga;
- una consulta fallida permanece en cola con el retroceso `retry_after` — una caída de la API no puede hacer girar el worker;
- la primera visita de una dirección nueva nunca se retrasa y nunca se bloquea *a causa de* AbuseIPDB: la capa de reputación la confirma o la descarta en una petición posterior (ADR-010);
- gana el primer proveedor que responde; si falla, se prueba el siguiente;
- `429` nunca se reintenta dentro del proceso: la ventana anunciada se respeta para todas las consultas siguientes;
- tras `circuit_breaker.failures` errores consecutivos, un proveedor se omite durante `cooldown` segundos;
- una respuesta `null`, un timeout o un error de cuota nunca bloquean una petición;
- en modo `monitor` una puntuación alta se registra en el journal pero no se bloquea.

## Reglas de detección

| Regla | Categoría | Puntuación | Notas |
|---|---|---|---|
| `sql_injection` | inyección | 25–80 | `UNION SELECT`, consultas apiladas, `SLEEP`, tautologías |
| `xss` | xss | 40–90 | etiquetas script/iframe/object, manejadores de eventos, `javascript:` |
| `command_injection` | inyección | 60–85 | familia `system()`, tuberías hacia comandos, acentos graves de comandos |
| `path_traversal` | ruta | 60–95 | `../` repetidos, `/etc/passwd`, `.ssh`, `/proc/self` |
| `file_inclusion` | inclusión | 60–85 | inclusiones remotas, envoltorios `php://` |
| `suspicious_header` | encabezados | 60–70 | User-Agents de escáneres (sqlmap, nikto, nmap, …) |

Los valores se escanean tal como se reciben y después de la decodificación de URL, además del `REQUEST_URI` sin procesar, de modo que `%3Cscript%3E` se detecta. Se registra un hallazgo por regla y por campo, así que un payload con doble codificación no puede inflar su puntuación.

### Reglas personalizadas

```php
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);
```

## Modos y duración de los bloqueos

| Modo | Comportamiento |
|---|---|
| `monitor` | todo se registra, nada se bloquea |
| `challenge` | los hallazgos graves se rechazan con 403 `challenge_required` y no se bloquean |
| `block` | los hallazgos graves bloquean la dirección durante una duración derivada de la severidad |

Duración = `block_duration` × `severity_block_multipliers`:

| Severidad | Multiplicador | Duración predeterminada |
|---|---|---|
| `low` | 0.5 | 30 min |
| `medium` | 1 | 1 h |
| `high` | 4 | 4 h |
| `critical` | 12 | 12 h |

La limitación de tasa tiene su propia ventana corta (`rate_limit_block_duration`, 300 s) para que una ráfaga procedente de una dirección compartida o NAT no deje fuera a una oficina entera. Un bloqueo permanente nunca es automático: es una acción del operador en el panel o mediante `blockPermanently()`.

## Limitación de tasa

| Backend | Cómo cuenta | Cuándo usarlo |
|---|---|---|
| `DatabaseRateLimiter` | dos sentencias (contar, insertar) sobre `php_shield_rate_limit` | tráfico bajo y medio; llamar a `prune()` según una programación |
| `RedisRateLimiter` | `INCR` + `EXPIRE` atómicos | tráfico concurrente; falla en abierto y deja constancia si Redis no está disponible |

Los límites predeterminados son 60/minuto, 1 000/hora y 10 000/día por dirección y endpoint. Ambos backends implementan `RateLimiterInterface`, así que se puede conectar un limitador existente.

## API de estado

```php
$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
```

Cuando un contador de intentos fallidos alcanza `max_failed_attempts` (25), la siguiente solicitud desde esa dirección se bloquea una vez y el contador se restablece.

Tablas: `php_shield_ip_blocks`, `php_shield_incidents`, `php_shield_failed_attempts`, `php_shield_rate_limit`, `php_shield_ip_reputation`. Todas las expiraciones las calcula la base de datos (`NOW()`, `DATE_ADD`), de modo que una diferencia de zona horaria entre PHP y MySQL no puede hacer expirar un bloqueo recién creado.

## Panel de administración

`public/admin/index.php` — estadísticas, bloqueos activos, bloqueo y desbloqueo manuales, configuración enmascarada.

- cada formulario que modifica el estado lleva un token anti-CSRF por sesión;
- el inicio de sesión está limitado por tasa (5 fallos → 5 min de bloqueo) y el id de sesión se regenera al iniciar sesión;
- la cookie de sesión es `HttpOnly`, `SameSite=Strict`, `Secure` en HTTPS;
- el volcado de la configuración enmascara contraseñas, tokens y claves antes de renderizarse.

Genere un hash y proteja también el endpoint a nivel del servidor web:

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

## Proxies inversos e IP del cliente

`X-Forwarded-For` se respeta solo cuando `REMOTE_ADDR` está en `trusted_proxies`, y la cadena se lee **de derecha a izquierda**, omitiendo los saltos de confianza:

```text
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
```

Leer en su lugar la entrada situada más a la izquierda permitiría a cualquier cliente elegir su propia dirección y, con una entrada de `trusted_ips` que coincidiera con ella, eludir todo el shield. Fue un defecto real, corregido en 2.0.0 y documentado en la [revisión de seguridad](docs/security-review.md).

## Arquitectura

```text
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/
```

Las decisiones de diseño, las alternativas y sus consecuencias quedan recogidas en [docs/adr.md](docs/adr.md).

## Pruebas

```bash
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=[redacted] php tools/live-abuseipdb.php 8.8.8.8
```

La CI ejecuta PHP 8.1 – 8.5, PHPUnit, PHPStan en nivel máximo, el estándar de codificación y una comprobación de sintaxis de cada archivo PHP.

## Lista de verificación para producción

1. Ejecute en modo `monitor` durante unos días.
2. Lea `php_shield_incidents` y ajuste `block_threshold` o desactive las reglas ruidosas.
3. Aplique `migrations/schema.sql` y establezca `auto_migrate` en `false`.
4. Cambie el limitador de tasa a Redis para el tráfico concurrente.
5. Complete `trusted_proxies` si está detrás de un proxy inverso: los encabezados reenviados se respetan **solo** desde esas direcciones, y la cadena se lee de derecha a izquierda.
6. Proteja el panel de administración; defina un hash de contraseña robusto.
7. Programe `cleanup()` (retención) y `DatabaseRateLimiter::prune()`.
8. Active `ip_reputation` con AbuseIPDB.com y mantenga AbuseIPDB.ru como alternativa.
9. Añada `SecurityHeaders::send()` al principio de la solicitud.
10. Cambie al modo `block` y observe durante un día la tasa de respuestas 403/429.

## Documentación

- [Wiki](https://github.com/paulmann/PHP-Shield/wiki) — referencia de configuración, recetas, solución de problemas, guía de actualización
- [Registros de decisiones de arquitectura](docs/adr.md)
- [Revisión de seguridad](docs/security-review.md) — hallazgos y sus correcciones
- [Adaptador de referencia](integration/abuseipdb-ru/README.md) — sustituir un middleware de AbuseIPDB.RU escrito a mano por este paquete
- [SECURITY.md](SECURITY.md) — cómo informar de una vulnerabilidad
- [AGENTS.md](AGENTS.md) — instrucciones para agentes de codificación con IA

## Contribución

Lea primero [CONTRIBUTING.md](CONTRIBUTING.md): estándar de codificación, análisis estático, pruebas y la lista de verificación de la revisión. Las incidencias y las solicitudes de cambios son bienvenidas; los informes de seguridad se envían a través de [SECURITY.md](SECURITY.md).

## Licencia

MIT — véase [LICENSE](LICENSE).

## Créditos

Creado por Mikhail Deynekin. La política de severidad y la separación entre estado y detección se conformaron a partir del middleware independiente usado en AbuseIPDB.RU y del WAF de PHP-Form-Spam-Checker.

---

[English](README.md) · [Русский](README.ru.md) · [Deutsch](README.de.md) · **Español** · [中文](README.zh.md) · [हिन्दी](README.hi.md)
