Init
This commit is contained in:
23 files changed
+1216
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
/.idea
|
||||||
|
/vendor/
|
||||||
|
composer.lock
|
||||||
|
.phpunit.cache
|
||||||
|
.phpunit.result.cache
|
||||||
|
.DS_Store
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Mattitja AB
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "mattitja/bad-ip-blocker",
|
||||||
|
"description": "Block bad IPs in Laravel using a local JSON list with automatic blocking of scanners.",
|
||||||
|
"type": "library",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["laravel", "middleware", "ip", "blocker", "security"],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mattitja AB",
|
||||||
|
"email": "hej@mattitja.se"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"illuminate/cache": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/http": "^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^11.0|^12.0|^13.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"laravel/pint": "^1.24",
|
||||||
|
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
||||||
|
"pestphp/pest": "^3.0|^4.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Mattitja\\BadIpBlocker\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Mattitja\\BadIpBlocker\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vendor/bin/pest",
|
||||||
|
"format": "vendor/bin/pint"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Mattitja\\BadIpBlocker\\BadIpBlockerServiceProvider"
|
||||||
|
],
|
||||||
|
"aliases": {
|
||||||
|
"BadIpBlocker": "Mattitja\\BadIpBlocker\\Facades\\BadIpBlocker"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true,
|
||||||
|
"allow-plugins": {
|
||||||
|
"pestphp/pest-plugin": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Turn the blocker on or off without removing the middleware.
|
||||||
|
*/
|
||||||
|
'enabled' => env('BAD_IP_BLOCKER_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Register the middleware globally automatically. Disable this if you
|
||||||
|
| prefer to add CheckBadIps yourself in bootstrap/app.php.
|
||||||
|
*/
|
||||||
|
'auto_register' => env('BAD_IP_BLOCKER_AUTO_REGISTER', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Schedule `bad-ip-blocker:prune` daily to remove expired blocks.
|
||||||
|
| Requires the Laravel scheduler (`schedule:run`) to be running.
|
||||||
|
*/
|
||||||
|
'schedule_prune' => env('BAD_IP_BLOCKER_SCHEDULE_PRUNE', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Where the local JSON list of blocked IPs is stored.
|
||||||
|
*/
|
||||||
|
'storage_path' => env('BAD_IP_BLOCKER_STORAGE_PATH', storage_path('app/bad-ip-blocker.json')),
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Response returned to blocked clients.
|
||||||
|
*/
|
||||||
|
'response' => [
|
||||||
|
'status' => 418,
|
||||||
|
'message' => 'Blocked.',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| IPs or CIDR ranges that are never blocked. Private and loopback ranges
|
||||||
|
| are included by default so that a misconfigured proxy setup (where
|
||||||
|
| $request->ip() returns the load balancer's address) never blocks
|
||||||
|
| every visitor. Configure TrustProxies so the real client IP is used.
|
||||||
|
*/
|
||||||
|
'whitelist' => [
|
||||||
|
'127.0.0.0/8',
|
||||||
|
'::1',
|
||||||
|
'10.0.0.0/8',
|
||||||
|
'172.16.0.0/12',
|
||||||
|
'192.168.0.0/16',
|
||||||
|
'fc00::/7',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
| Automatically block clients that request paths no normal visitor asks
|
||||||
|
| for. A hit only counts when the response is a 404, so routes that
|
||||||
|
| actually exist in your app (e.g. a published security.txt) are never
|
||||||
|
| affected. Patterns use Request::is() syntax.
|
||||||
|
*/
|
||||||
|
'auto_block' => [
|
||||||
|
'enabled' => env('BAD_IP_BLOCKER_AUTO_BLOCK', true),
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
'.well-known/security.txt',
|
||||||
|
'security.txt',
|
||||||
|
'.env*',
|
||||||
|
'*/.env*',
|
||||||
|
'.git/*',
|
||||||
|
'.aws/*',
|
||||||
|
'wp-login.php',
|
||||||
|
'wp-admin*',
|
||||||
|
'wp-content/*',
|
||||||
|
'wp-includes/*',
|
||||||
|
'xmlrpc.php',
|
||||||
|
'phpmyadmin*',
|
||||||
|
'pma*',
|
||||||
|
'vendor/phpunit/*',
|
||||||
|
'cgi-bin/*',
|
||||||
|
'*.php',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Number of matching 404 hits before the IP is blocked.
|
||||||
|
'max_hits' => 1,
|
||||||
|
|
||||||
|
// Seconds a hit counter is kept.
|
||||||
|
'decay' => 3600,
|
||||||
|
|
||||||
|
// Seconds an automatic block lasts (default 7 days). null = permanent.
|
||||||
|
// Scanners rotate IPs, so permanent blocks mostly grow the list and
|
||||||
|
// risk locking out shared addresses (mobile carriers, NAT) forever.
|
||||||
|
'ttl' => env('BAD_IP_BLOCKER_AUTO_BLOCK_TTL', 60 * 60 * 24 * 7),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Package">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker;
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Contracts\Http\Kernel as HttpKernel;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
|
||||||
|
|
||||||
|
class BadIpBlockerServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->mergeConfigFrom(__DIR__.'/../config/bad-ip-blocker.php', 'bad-ip-blocker');
|
||||||
|
|
||||||
|
$this->app->singleton(BadIpStore::class, fn ($app) => new BadIpStore(
|
||||||
|
$app['config']->get('bad-ip-blocker.storage_path') ?? storage_path('app/bad-ip-blocker.json'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->registerMiddleware();
|
||||||
|
$this->registerSchedule();
|
||||||
|
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__.'/../config/bad-ip-blocker.php' => config_path('bad-ip-blocker.php'),
|
||||||
|
], 'bad-ip-blocker-config');
|
||||||
|
|
||||||
|
$this->commands([
|
||||||
|
Console\AddCommand::class,
|
||||||
|
Console\RemoveCommand::class,
|
||||||
|
Console\ListCommand::class,
|
||||||
|
Console\PruneCommand::class,
|
||||||
|
Console\ClearCommand::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the middleware to the global stack. Runs after bootstrap/app.php's
|
||||||
|
* withMiddleware() has set the global middleware, so it is not overwritten,
|
||||||
|
* and pushMiddleware() skips it if the app already registered it manually.
|
||||||
|
*/
|
||||||
|
protected function registerMiddleware(): void
|
||||||
|
{
|
||||||
|
if (! $this->app['config']->get('bad-ip-blocker.auto_register', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->callAfterResolving(HttpKernel::class, function ($kernel) {
|
||||||
|
if (method_exists($kernel, 'pushMiddleware')) {
|
||||||
|
$kernel->pushMiddleware(CheckBadIps::class);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function registerSchedule(): void
|
||||||
|
{
|
||||||
|
if (! $this->app['config']->get('bad-ip-blocker.schedule_prune', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
|
||||||
|
$schedule->command('bad-ip-blocker:prune')->daily();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker;
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class BadIpStore
|
||||||
|
{
|
||||||
|
/** @var array<string, array{reason: ?string, blocked_at: string, expires_at: ?string}>|null */
|
||||||
|
protected ?array $entries = null;
|
||||||
|
|
||||||
|
protected ?string $loadedSignature = null;
|
||||||
|
|
||||||
|
public function __construct(protected string $path) {}
|
||||||
|
|
||||||
|
public function path(): string
|
||||||
|
{
|
||||||
|
return $this->path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All active (non-expired) entries keyed by IP.
|
||||||
|
*
|
||||||
|
* @return array<string, array{reason: ?string, blocked_at: string, expires_at: ?string}>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_filter($this->load(), fn (array $entry) => ! $this->isExpired($entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isBlocked(string $ip): bool
|
||||||
|
{
|
||||||
|
$entries = $this->load();
|
||||||
|
|
||||||
|
return isset($entries[$ip]) && ! $this->isExpired($entries[$ip]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function block(string $ip, ?string $reason = null, ?int $ttl = null): void
|
||||||
|
{
|
||||||
|
$this->assertValidIp($ip);
|
||||||
|
|
||||||
|
$now = CarbonImmutable::now();
|
||||||
|
|
||||||
|
$this->mutate(function (array $entries) use ($ip, $reason, $ttl, $now) {
|
||||||
|
$entries[$ip] = [
|
||||||
|
'reason' => $reason,
|
||||||
|
'blocked_at' => $now->toIso8601String(),
|
||||||
|
'expires_at' => $ttl !== null ? $now->addSeconds($ttl)->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unblock(string $ip): bool
|
||||||
|
{
|
||||||
|
$removed = false;
|
||||||
|
|
||||||
|
$this->mutate(function (array $entries) use ($ip, &$removed) {
|
||||||
|
$removed = isset($entries[$ip]);
|
||||||
|
unset($entries[$ip]);
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clear(): void
|
||||||
|
{
|
||||||
|
$this->mutate(fn () => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired entries. Returns the number removed.
|
||||||
|
*/
|
||||||
|
public function prune(): int
|
||||||
|
{
|
||||||
|
$removed = 0;
|
||||||
|
|
||||||
|
$this->mutate(function (array $entries) use (&$removed) {
|
||||||
|
$active = array_filter($entries, fn (array $entry) => ! $this->isExpired($entry));
|
||||||
|
$removed = count($entries) - count($active);
|
||||||
|
|
||||||
|
return $active;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertValidIp(string $ip): void
|
||||||
|
{
|
||||||
|
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||||
|
throw new InvalidArgumentException("Invalid IP address [{$ip}].");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isExpired(array $entry): bool
|
||||||
|
{
|
||||||
|
return isset($entry['expires_at'])
|
||||||
|
&& CarbonImmutable::parse($entry['expires_at'])->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load entries, re-reading the file only when it has changed.
|
||||||
|
*/
|
||||||
|
protected function load(): array
|
||||||
|
{
|
||||||
|
clearstatcache(true, $this->path);
|
||||||
|
|
||||||
|
$signature = $this->signature();
|
||||||
|
|
||||||
|
if ($this->entries !== null && $signature === $this->loadedSignature) {
|
||||||
|
return $this->entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadedSignature = $signature;
|
||||||
|
|
||||||
|
return $this->entries = $signature === null ? [] : $this->decode((string) @file_get_contents($this->path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies the current file version. rename() gives a new inode, so
|
||||||
|
* writes from other processes are detected even within the same second.
|
||||||
|
*/
|
||||||
|
protected function signature(): ?string
|
||||||
|
{
|
||||||
|
$stat = @stat($this->path);
|
||||||
|
|
||||||
|
return $stat === false ? null : $stat['ino'].':'.$stat['mtime'].':'.$stat['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function decode(string $contents): array
|
||||||
|
{
|
||||||
|
if (trim($contents) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($contents, true);
|
||||||
|
|
||||||
|
if (! is_array($data) || ! isset($data['ips']) || ! is_array($data['ips'])) {
|
||||||
|
Log::warning('bad-ip-blocker: could not parse IP list, treating it as empty.', ['path' => $this->path]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
$data['ips'],
|
||||||
|
fn ($entry, $ip) => is_array($entry) && filter_var($ip, FILTER_VALIDATE_IP) !== false,
|
||||||
|
ARRAY_FILTER_USE_BOTH,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-modify-write the file under an exclusive lock with an atomic rename.
|
||||||
|
*/
|
||||||
|
protected function mutate(callable $callback): void
|
||||||
|
{
|
||||||
|
$directory = dirname($this->path);
|
||||||
|
|
||||||
|
if (! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory)) {
|
||||||
|
throw new RuntimeException("Unable to create directory [{$directory}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
$lock = fopen($this->path.'.lock', 'c');
|
||||||
|
|
||||||
|
if ($lock === false) {
|
||||||
|
throw new RuntimeException("Unable to open lock file for [{$this->path}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
flock($lock, LOCK_EX);
|
||||||
|
|
||||||
|
$this->entries = null;
|
||||||
|
$entries = $callback($this->load());
|
||||||
|
|
||||||
|
$temp = $this->path.'.'.bin2hex(random_bytes(6)).'.tmp';
|
||||||
|
$json = json_encode(['ips' => (object) $entries], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if (file_put_contents($temp, $json."\n") === false || ! rename($temp, $this->path)) {
|
||||||
|
@unlink($temp);
|
||||||
|
|
||||||
|
throw new RuntimeException("Unable to write [{$this->path}].");
|
||||||
|
}
|
||||||
|
|
||||||
|
clearstatcache(true, $this->path);
|
||||||
|
$this->entries = $entries;
|
||||||
|
$this->loadedSignature = $this->signature();
|
||||||
|
} finally {
|
||||||
|
flock($lock, LOCK_UN);
|
||||||
|
fclose($lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
class AddCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bad-ip-blocker:add
|
||||||
|
{ip : The IP address to block}
|
||||||
|
{--reason= : Why the IP is blocked}
|
||||||
|
{--ttl= : Seconds until the block expires (omit for permanent)}';
|
||||||
|
|
||||||
|
protected $description = 'Block an IP address';
|
||||||
|
|
||||||
|
public function handle(BadIpStore $store): int
|
||||||
|
{
|
||||||
|
$ttl = $this->option('ttl');
|
||||||
|
|
||||||
|
if ($ttl !== null && (! ctype_digit((string) $ttl) || (int) $ttl < 1)) {
|
||||||
|
$this->components->error('The --ttl option must be a positive number of seconds.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->block($this->argument('ip'), $this->option('reason'), $ttl !== null ? (int) $ttl : null);
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
$this->components->error($e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->info("Blocked [{$this->argument('ip')}].");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
class ClearCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bad-ip-blocker:clear {--force : Skip confirmation}';
|
||||||
|
|
||||||
|
protected $description = 'Remove all blocked IP addresses';
|
||||||
|
|
||||||
|
public function handle(BadIpStore $store): int
|
||||||
|
{
|
||||||
|
if (! $this->option('force') && ! $this->confirm('Remove all blocked IP addresses?')) {
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->clear();
|
||||||
|
|
||||||
|
$this->components->info('All blocks removed.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
class ListCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bad-ip-blocker:list';
|
||||||
|
|
||||||
|
protected $description = 'List blocked IP addresses';
|
||||||
|
|
||||||
|
public function handle(BadIpStore $store): int
|
||||||
|
{
|
||||||
|
$entries = $store->all();
|
||||||
|
|
||||||
|
if ($entries === []) {
|
||||||
|
$this->components->info('No blocked IP addresses.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->table(
|
||||||
|
['IP', 'Reason', 'Blocked at', 'Expires at'],
|
||||||
|
collect($entries)->map(fn (array $entry, string $ip) => [
|
||||||
|
$ip,
|
||||||
|
$entry['reason'] ?? '',
|
||||||
|
$entry['blocked_at'] ?? '',
|
||||||
|
$entry['expires_at'] ?? 'never',
|
||||||
|
])->values()->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
class PruneCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bad-ip-blocker:prune';
|
||||||
|
|
||||||
|
protected $description = 'Remove expired IP blocks';
|
||||||
|
|
||||||
|
public function handle(BadIpStore $store): int
|
||||||
|
{
|
||||||
|
$this->components->info("Removed {$store->prune()} expired block(s).");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
class RemoveCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bad-ip-blocker:remove {ip : The IP address to unblock}';
|
||||||
|
|
||||||
|
protected $description = 'Unblock an IP address';
|
||||||
|
|
||||||
|
public function handle(BadIpStore $store): int
|
||||||
|
{
|
||||||
|
$ip = $this->argument('ip');
|
||||||
|
|
||||||
|
if (! $store->unblock($ip)) {
|
||||||
|
$this->components->warn("[{$ip}] was not blocked.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->info("Unblocked [{$ip}].");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Facades;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static array all()
|
||||||
|
* @method static bool isBlocked(string $ip)
|
||||||
|
* @method static void block(string $ip, ?string $reason = null, ?int $ttl = null)
|
||||||
|
* @method static bool unblock(string $ip)
|
||||||
|
* @method static void clear()
|
||||||
|
* @method static int prune()
|
||||||
|
* @method static string path()
|
||||||
|
*
|
||||||
|
* @see BadIpStore
|
||||||
|
*/
|
||||||
|
class BadIpBlocker extends Facade
|
||||||
|
{
|
||||||
|
protected static function getFacadeAccessor(): string
|
||||||
|
{
|
||||||
|
return BadIpStore::class;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
use Symfony\Component\HttpFoundation\IpUtils;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class CheckBadIps
|
||||||
|
{
|
||||||
|
public function __construct(protected BadIpStore $store) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$ip = $request->ip();
|
||||||
|
|
||||||
|
if (! config('bad-ip-blocker.enabled') || $ip === null || $this->isWhitelisted($ip)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->store->isBlocked($ip)) {
|
||||||
|
return $this->blockedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
if ($this->shouldAutoBlock($request, $response, $ip)) {
|
||||||
|
$ttl = config('bad-ip-blocker.auto_block.ttl');
|
||||||
|
|
||||||
|
$this->store->block($ip, 'Auto: /'.ltrim($request->path(), '/'), $ttl !== null ? (int) $ttl : null);
|
||||||
|
|
||||||
|
return $this->blockedResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isWhitelisted(string $ip): bool
|
||||||
|
{
|
||||||
|
return IpUtils::checkIp($ip, (array) config('bad-ip-blocker.whitelist', []));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function shouldAutoBlock(Request $request, Response $response, string $ip): bool
|
||||||
|
{
|
||||||
|
$patterns = (array) config('bad-ip-blocker.auto_block.paths', []);
|
||||||
|
|
||||||
|
if (! config('bad-ip-blocker.auto_block.enabled')
|
||||||
|
|| $response->getStatusCode() !== 404
|
||||||
|
|| $patterns === []
|
||||||
|
|| ! $request->is(...$patterns)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = 'bad-ip-blocker:hits:'.$ip;
|
||||||
|
|
||||||
|
RateLimiter::hit($key, (int) config('bad-ip-blocker.auto_block.decay', 3600));
|
||||||
|
|
||||||
|
if (RateLimiter::attempts($key) < max(1, (int) config('bad-ip-blocker.auto_block.max_hits', 1))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
RateLimiter::clear($key);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function blockedResponse(): Response
|
||||||
|
{
|
||||||
|
return response(
|
||||||
|
config('bad-ip-blocker.response.message', 'Blocked.'),
|
||||||
|
(int) config('bad-ip-blocker.response.status', 418),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
|
||||||
|
|
||||||
|
it('registers the middleware globally without any setup', function () {
|
||||||
|
expect(app(Kernel::class)->hasMiddleware(CheckBadIps::class))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register the middleware twice when the app also adds it', function () {
|
||||||
|
app(Kernel::class)->pushMiddleware(CheckBadIps::class);
|
||||||
|
|
||||||
|
$count = collect(app(Kernel::class)->getGlobalMiddleware())
|
||||||
|
->filter(fn ($middleware) => $middleware === CheckBadIps::class)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
expect($count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules a daily prune', function () {
|
||||||
|
$event = collect(app(Schedule::class)->events())
|
||||||
|
->first(fn ($event) => str_contains($event->command ?? '', 'bad-ip-blocker:prune'));
|
||||||
|
|
||||||
|
expect($event)->not->toBeNull()
|
||||||
|
->and($event->expression)->toBe('0 0 * * *');
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
|
||||||
|
|
||||||
|
it('adds an ip', function () {
|
||||||
|
$this->artisan('bad-ip-blocker:add', ['ip' => '203.0.113.10', '--reason' => 'bot', '--ttl' => 60])
|
||||||
|
->assertSuccessful();
|
||||||
|
|
||||||
|
expect(BadIpBlocker::all()['203.0.113.10'])
|
||||||
|
->reason->toBe('bot')
|
||||||
|
->expires_at->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid ip or ttl', function () {
|
||||||
|
$this->artisan('bad-ip-blocker:add', ['ip' => 'nope'])->assertFailed();
|
||||||
|
$this->artisan('bad-ip-blocker:add', ['ip' => '203.0.113.10', '--ttl' => 'abc'])->assertFailed();
|
||||||
|
|
||||||
|
expect(BadIpBlocker::all())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes an ip', function () {
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->artisan('bad-ip-blocker:remove', ['ip' => '203.0.113.10'])->assertSuccessful();
|
||||||
|
$this->artisan('bad-ip-blocker:remove', ['ip' => '203.0.113.10'])->assertFailed();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists ips', function () {
|
||||||
|
$this->artisan('bad-ip-blocker:list')->expectsOutputToContain('No blocked')->assertSuccessful();
|
||||||
|
|
||||||
|
BadIpBlocker::block('203.0.113.10', 'bot');
|
||||||
|
$blockedAt = BadIpBlocker::all()['203.0.113.10']['blocked_at'];
|
||||||
|
|
||||||
|
$this->artisan('bad-ip-blocker:list')
|
||||||
|
->expectsTable(['IP', 'Reason', 'Blocked at', 'Expires at'], [['203.0.113.10', 'bot', $blockedAt, 'never']])
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes expired blocks', function () {
|
||||||
|
$this->artisan('bad-ip-blocker:prune')->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all blocks after confirmation', function () {
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->artisan('bad-ip-blocker:clear')
|
||||||
|
->expectsConfirmation('Remove all blocked IP addresses?', 'no')
|
||||||
|
->assertFailed();
|
||||||
|
expect(BadIpBlocker::all())->not->toBe([]);
|
||||||
|
|
||||||
|
$this->artisan('bad-ip-blocker:clear', ['--force' => true])->assertSuccessful();
|
||||||
|
expect(BadIpBlocker::all())->toBe([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
|
||||||
|
|
||||||
|
it('lets normal visitors through', function () {
|
||||||
|
$this->getFrom('/', '203.0.113.10')->assertOk()->assertSee('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks ips on the list', function () {
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->getFrom('/', '203.0.113.10')->assertStatus(418)->assertSee('Blocked.');
|
||||||
|
$this->getFrom('/', '192.0.2.30')->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the configured response', function () {
|
||||||
|
config(['bad-ip-blocker.response' => ['status' => 403, 'message' => 'Nope']]);
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->getFrom('/', '203.0.113.10')->assertForbidden()->assertSee('Nope');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when disabled', function () {
|
||||||
|
config(['bad-ip-blocker.enabled' => false]);
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->getFrom('/', '203.0.113.10')->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never blocks whitelisted ranges, including private proxy addresses', function () {
|
||||||
|
BadIpBlocker::block('192.168.1.10');
|
||||||
|
|
||||||
|
$this->getFrom('/', '192.168.1.10')->assertOk();
|
||||||
|
$this->getFrom('/.well-known/security.txt', '192.168.1.20')->assertNotFound();
|
||||||
|
|
||||||
|
expect(BadIpBlocker::isBlocked('192.168.1.20'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-blocks clients probing for a missing security.txt', function (string $path) {
|
||||||
|
$this->getFrom($path, '198.51.100.20')->assertStatus(418);
|
||||||
|
|
||||||
|
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeTrue()
|
||||||
|
->and(BadIpBlocker::all()['198.51.100.20']['reason'])->toBe('Auto: '.$path);
|
||||||
|
|
||||||
|
$this->getFrom('/', '198.51.100.20')->assertStatus(418);
|
||||||
|
})->with(['/.well-known/security.txt', '/security.txt', '/.env', '/wp-login.php']);
|
||||||
|
|
||||||
|
it('does not auto-block when the path exists in the app', function () {
|
||||||
|
Route::get('/.well-known/security.txt', fn () => 'Contact: mailto:security@example.com');
|
||||||
|
|
||||||
|
$this->getFrom('/.well-known/security.txt', '198.51.100.20')->assertOk();
|
||||||
|
|
||||||
|
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not auto-block ordinary 404s', function () {
|
||||||
|
foreach (['/about', '/products', '/missing-page'] as $path) {
|
||||||
|
$this->getFrom($path, '203.0.113.10')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(BadIpBlocker::isBlocked('203.0.113.10'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects max_hits', function () {
|
||||||
|
config(['bad-ip-blocker.auto_block.max_hits' => 2]);
|
||||||
|
|
||||||
|
$this->getFrom('/.well-known/security.txt', '198.51.100.20')->assertNotFound();
|
||||||
|
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
|
||||||
|
|
||||||
|
$this->getFrom('/security.txt', '198.51.100.20')->assertStatus(418);
|
||||||
|
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires auto-blocks after 7 days by default', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
|
||||||
|
|
||||||
|
$this->getFrom('/security.txt', '198.51.100.20');
|
||||||
|
|
||||||
|
expect(BadIpBlocker::all()['198.51.100.20']['expires_at'])->toBe('2026-11-18T20:00:00+00:00');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can make auto-blocks permanent', function () {
|
||||||
|
config(['bad-ip-blocker.auto_block.ttl' => null]);
|
||||||
|
|
||||||
|
$this->getFrom('/security.txt', '198.51.100.20');
|
||||||
|
|
||||||
|
expect(BadIpBlocker::all()['198.51.100.20']['expires_at'])->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can disable auto-blocking', function () {
|
||||||
|
config(['bad-ip-blocker.auto_block.enabled' => false]);
|
||||||
|
|
||||||
|
$this->getFrom('/security.txt', '198.51.100.20')->assertNotFound();
|
||||||
|
|
||||||
|
expect(BadIpBlocker::isBlocked('198.51.100.20'))->toBeFalse();
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
|
use Illuminate\Contracts\Http\Kernel;
|
||||||
|
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
|
||||||
|
use Mattitja\BadIpBlocker\Middleware\CheckBadIps;
|
||||||
|
|
||||||
|
it('does not register the middleware when auto_register is disabled', function () {
|
||||||
|
expect(app(Kernel::class)->hasMiddleware(CheckBadIps::class))->toBeFalse();
|
||||||
|
|
||||||
|
BadIpBlocker::block('203.0.113.10');
|
||||||
|
|
||||||
|
$this->getFrom('/', '203.0.113.10')->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not schedule prune when schedule_prune is disabled', function () {
|
||||||
|
$scheduled = collect(app(Schedule::class)->events())
|
||||||
|
->contains(fn ($event) => str_contains($event->command ?? '', 'bad-ip-blocker:prune'));
|
||||||
|
|
||||||
|
expect($scheduled)->toBeFalse();
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Tests;
|
||||||
|
|
||||||
|
abstract class OptOutTestCase extends TestCase
|
||||||
|
{
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
parent::defineEnvironment($app);
|
||||||
|
|
||||||
|
$app['config']->set('bad-ip-blocker.auto_register', false);
|
||||||
|
$app['config']->set('bad-ip-blocker.schedule_prune', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Mattitja\BadIpBlocker\Tests\OptOutTestCase;
|
||||||
|
use Mattitja\BadIpBlocker\Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class)->in('Feature', 'Unit');
|
||||||
|
uses(OptOutTestCase::class)->in('OptOut');
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Mattitja\BadIpBlocker\Tests;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpBlockerServiceProvider;
|
||||||
|
use Mattitja\BadIpBlocker\Facades\BadIpBlocker;
|
||||||
|
use Orchestra\Testbench\TestCase as Orchestra;
|
||||||
|
|
||||||
|
abstract class TestCase extends Orchestra
|
||||||
|
{
|
||||||
|
protected string $storageDir;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [BadIpBlockerServiceProvider::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPackageAliases($app): array
|
||||||
|
{
|
||||||
|
return ['BadIpBlocker' => BadIpBlocker::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineEnvironment($app): void
|
||||||
|
{
|
||||||
|
$this->storageDir = sys_get_temp_dir().'/bad-ip-blocker-tests-'.bin2hex(random_bytes(4));
|
||||||
|
|
||||||
|
$app['config']->set('bad-ip-blocker.storage_path', $this->storageDir.'/bad-ips.json');
|
||||||
|
$app['config']->set('cache.default', 'array');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defineRoutes($router): void
|
||||||
|
{
|
||||||
|
$router->get('/', fn () => 'ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
File::deleteDirectory($this->storageDir);
|
||||||
|
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFrom(string $uri, string $ip)
|
||||||
|
{
|
||||||
|
return $this->withServerVariables(['REMOTE_ADDR' => $ip])->get($uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Mattitja\BadIpBlocker\BadIpStore;
|
||||||
|
|
||||||
|
beforeEach(fn () => $this->store = app(BadIpStore::class));
|
||||||
|
|
||||||
|
afterEach(fn () => CarbonImmutable::setTestNow());
|
||||||
|
|
||||||
|
it('starts empty when no file exists', function () {
|
||||||
|
expect($this->store->all())->toBe([])
|
||||||
|
->and($this->store->isBlocked('203.0.113.10'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks and unblocks an ip', function () {
|
||||||
|
$this->store->block('203.0.113.10', 'test');
|
||||||
|
|
||||||
|
expect($this->store->isBlocked('203.0.113.10'))->toBeTrue()
|
||||||
|
->and($this->store->all()['203.0.113.10']['reason'])->toBe('test');
|
||||||
|
|
||||||
|
expect($this->store->unblock('203.0.113.10'))->toBeTrue()
|
||||||
|
->and($this->store->unblock('203.0.113.10'))->toBeFalse()
|
||||||
|
->and($this->store->isBlocked('203.0.113.10'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports ipv6', function () {
|
||||||
|
$this->store->block('2001:db8::1');
|
||||||
|
|
||||||
|
expect($this->store->isBlocked('2001:db8::1'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid ips', function () {
|
||||||
|
$this->store->block('not-an-ip');
|
||||||
|
})->throws(InvalidArgumentException::class);
|
||||||
|
|
||||||
|
it('persists to the json file and is visible to other instances', function () {
|
||||||
|
$this->store->block('198.51.100.20');
|
||||||
|
|
||||||
|
$other = new BadIpStore($this->store->path());
|
||||||
|
|
||||||
|
expect($other->isBlocked('198.51.100.20'))->toBeTrue()
|
||||||
|
->and(json_decode(file_get_contents($this->store->path()), true)['ips'])->toHaveKey('198.51.100.20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks up changes made by another process', function () {
|
||||||
|
expect($this->store->isBlocked('192.0.2.30'))->toBeFalse();
|
||||||
|
|
||||||
|
(new BadIpStore($this->store->path()))->block('192.0.2.30');
|
||||||
|
|
||||||
|
expect($this->store->isBlocked('192.0.2.30'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires blocks with a ttl', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
|
||||||
|
$this->store->block('203.0.113.10', null, 60);
|
||||||
|
|
||||||
|
expect($this->store->isBlocked('203.0.113.10'))->toBeTrue();
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow('2026-11-11 20:01:01');
|
||||||
|
|
||||||
|
expect($this->store->isBlocked('203.0.113.10'))->toBeFalse()
|
||||||
|
->and($this->store->all())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prunes expired entries', function () {
|
||||||
|
CarbonImmutable::setTestNow('2026-11-11 20:00:00');
|
||||||
|
$this->store->block('203.0.113.10', null, 60);
|
||||||
|
$this->store->block('198.51.100.20');
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow('2026-11-11 21:00:00');
|
||||||
|
|
||||||
|
expect($this->store->prune())->toBe(1)
|
||||||
|
->and(array_keys($this->store->all()))->toBe(['198.51.100.20']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all entries', function () {
|
||||||
|
$this->store->block('203.0.113.10');
|
||||||
|
$this->store->clear();
|
||||||
|
|
||||||
|
expect($this->store->all())->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a corrupt file as empty and logs a warning', function () {
|
||||||
|
Log::spy();
|
||||||
|
|
||||||
|
@mkdir(dirname($this->store->path()), 0755, true);
|
||||||
|
file_put_contents($this->store->path(), '{not json');
|
||||||
|
|
||||||
|
expect($this->store->all())->toBe([]);
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')->once();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user