143 lines
4.7 KiB
Markdown
143 lines
4.7 KiB
Markdown
# 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
|