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

+6
View File
@@ -0,0 +1,6 @@
/.idea
/vendor/
composer.lock
.phpunit.cache
.phpunit.result.cache
.DS_Store
+21
View File
@@ -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.
+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
+57
View File
@@ -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
}
+90
View File
@@ -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
View File
@@ -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>
+71
View File
@@ -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();
});
}
}
+197
View File
@@ -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);
}
}
}
+40
View File
@@ -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;
}
}
+26
View File
@@ -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;
}
}
+36
View File
@@ -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;
}
}
+20
View File
@@ -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;
}
}
+28
View File
@@ -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;
}
}
+25
View File
@@ -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;
}
}
+77
View File
@@ -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),
);
}
}
+27
View File
@@ -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 * * *');
});
+53
View File
@@ -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([]);
});
+100
View File
@@ -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();
});
+21
View File
@@ -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();
});
+14
View File
@@ -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);
}
}
+7
View File
@@ -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');
+48
View File
@@ -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);
}
}
+93
View File
@@ -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();
});