This commit is contained in:
mathiaspalmqvist committed 2026-11-11 23:19:20 +01:00
commit 3ed6eb9d19
23 files changed
+1216

No files matched your search

+142
View File
@@ -0,0 +1,142 @@
# Bad IP Blocker for Laravel
Block requests from bad IP addresses using a local JSON list, and automatically block clients that
probe for paths no normal visitor requests (`/.well-known/security.txt`, `/.env`, `/wp-login.php`, …).
## Features
- Local JSON list – no external service, no database
- Automatic blocking of scanners (only when the requested path returns 404 in your app)
- Artisan commands and a facade to manage the list
- Optional expiry (TTL) per block
- Whitelist with CIDR support (private and loopback ranges by default)
- Laravel 11, 12 and 13
## Installation
```bash
composer require mattitja/bad-ip-blocker
```
Publish the config (optional):
```bash
php artisan vendor:publish --tag=bad-ip-blocker-config
```
## Usage
That's it – after `composer require` the middleware is registered globally and starts blocking.
Expired blocks are pruned daily via the Laravel scheduler (make sure `schedule:run` is running).
If you'd rather register the middleware yourself, set `BAD_IP_BLOCKER_AUTO_REGISTER=false` and add it in
`bootstrap/app.php`:
```php
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
->withMiddleware(function (Middleware $middleware) {
$middleware->append(CheckBadIps::class);
})
```
It must run globally (not only in a route group) so it also sees requests for routes that don't exist.
Registering it both ways is harmless – it only runs once.
### Behind a proxy or load balancer – important
This is the one thing you may need to configure. The middleware uses `$request->ip()`. Behind a reverse
proxy, load balancer or Cloudflare this returns the proxy's address unless you trust the proxy. Configure it
so the real client IP from `X-Forwarded-For` is used:
```php
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['10.0.0.0/8']); // your proxy addresses
})
```
Private ranges are whitelisted by default, so a missing proxy config never blocks every visitor. But until
you configure trusted proxies, **nothing is blocked** behind the proxy either – it fails silently.
## Managing the list
### Artisan
```bash
php artisan bad-ip-blocker:add 203.0.113.10 --reason="Scraper" --ttl=86400
php artisan bad-ip-blocker:remove 203.0.113.10
php artisan bad-ip-blocker:list
php artisan bad-ip-blocker:prune # remove expired blocks
php artisan bad-ip-blocker:clear --force # remove all blocks
```
### Facade
```php
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
BadIpBlocker::block('203.0.113.10', 'Scraper', ttl: 3600); // ttl in seconds, null = permanent
BadIpBlocker::unblock('203.0.113.10');
BadIpBlocker::isBlocked('203.0.113.10');
BadIpBlocker::all();
```
### Pruning
Expired blocks are ignored automatically and removed from the file by a daily scheduled
`bad-ip-blocker:prune`. Disable it with `BAD_IP_BLOCKER_SCHEDULE_PRUNE=false`.
## Automatic blocking
When a request matches a pattern in `auto_block.paths` **and your app responds with 404**, the IP is blocked.
Routes that exist in your app are never affected – if you publish your own `security.txt`, reading it is fine.
> If your app has a `Route::fallback()` that returns 200 (e.g. an SPA), matching requests never return 404,
> so automatic blocking won't trigger for them.
Default patterns include `.well-known/security.txt`, `security.txt`, `.env*`, `.git/*`, `wp-login.php`,
`wp-admin*`, `xmlrpc.php`, `phpmyadmin*`, `vendor/phpunit/*` and `*.php`. Ordinary 404s
(e.g. `/missing-page`) never trigger a block.
Tune it in `config/bad-ip-blocker.php`:
| Key | Default | Description |
| --- | --- | --- |
| `auto_block.enabled` | `true` | Env `BAD_IP_BLOCKER_AUTO_BLOCK` |
| `auto_block.paths` | see above | `Request::is()` patterns |
| `auto_block.max_hits` | `1` | Matching 404s before blocking |
| `auto_block.decay` | `3600` | Seconds a hit counter is kept |
| `auto_block.ttl` | `604800` (7 days) | Env `BAD_IP_BLOCKER_AUTO_BLOCK_TTL`, `null` = permanent |
## Configuration
| Key | Default |
| --- | --- |
| `enabled` | `true` (env `BAD_IP_BLOCKER_ENABLED`) |
| `auto_register` | `true` (env `BAD_IP_BLOCKER_AUTO_REGISTER`) |
| `schedule_prune` | `true` (env `BAD_IP_BLOCKER_SCHEDULE_PRUNE`) |
| `storage_path` | `storage/app/bad-ip-blocker.json` (env `BAD_IP_BLOCKER_STORAGE_PATH`) |
| `response.status` / `response.message` | `418` / `Blocked.` |
| `whitelist` | loopback + private ranges (IPs or CIDR) |
The list is a plain JSON file per server. If you run several servers or containers, point `storage_path` at
persistent, shared storage – otherwise each instance has its own list and a container's list is lost on redeploy.
## Example response when blocked
```
HTTP/1.1 418 I'm a teapot
Content-Type: text/html; charset=UTF-8
Blocked.
```
## Testing
```bash
composer test
```
## License
MIT License © Mattitja AB