From 3ed6eb9d19d6d208e991b624a51f9c7c3194f9b7 Mon Sep 17 00:00:00 2001 From: Mathias Palmqvist Date: Wed, 11 Nov 2026 23:19:20 +0100 Subject: [PATCH] Init --- .gitignore | 6 + LICENSE.md | 21 +++ README.md | 142 ++++++++++++++++++++ composer.json | 57 ++++++++ config/bad-ip-blocker.php | 90 +++++++++++++ phpunit.xml | 17 +++ src/BadIpBlockerServiceProvider.php | 71 ++++++++++ src/BadIpStore.php | 197 ++++++++++++++++++++++++++++ src/Console/AddCommand.php | 40 ++++++ src/Console/ClearCommand.php | 26 ++++ src/Console/ListCommand.php | 36 +++++ src/Console/PruneCommand.php | 20 +++ src/Console/RemoveCommand.php | 28 ++++ src/Facades/BadIpBlocker.php | 25 ++++ src/Middleware/CheckBadIps.php | 77 +++++++++++ tests/Feature/AutoRegisterTest.php | 27 ++++ tests/Feature/CommandsTest.php | 53 ++++++++ tests/Feature/MiddlewareTest.php | 100 ++++++++++++++ tests/OptOut/OptOutTest.php | 21 +++ tests/OptOutTestCase.php | 14 ++ tests/Pest.php | 7 + tests/TestCase.php | 48 +++++++ tests/Unit/BadIpStoreTest.php | 93 +++++++++++++ 23 files changed, 1216 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/bad-ip-blocker.php create mode 100644 phpunit.xml create mode 100644 src/BadIpBlockerServiceProvider.php create mode 100644 src/BadIpStore.php create mode 100644 src/Console/AddCommand.php create mode 100644 src/Console/ClearCommand.php create mode 100644 src/Console/ListCommand.php create mode 100644 src/Console/PruneCommand.php create mode 100644 src/Console/RemoveCommand.php create mode 100644 src/Facades/BadIpBlocker.php create mode 100644 src/Middleware/CheckBadIps.php create mode 100644 tests/Feature/AutoRegisterTest.php create mode 100644 tests/Feature/CommandsTest.php create mode 100644 tests/Feature/MiddlewareTest.php create mode 100644 tests/OptOut/OptOutTest.php create mode 100644 tests/OptOutTestCase.php create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/BadIpStoreTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1ba4a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.idea +/vendor/ +composer.lock +.phpunit.cache +.phpunit.result.cache +.DS_Store diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..96acc46 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0a1d05 --- /dev/null +++ b/README.md @@ -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 diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..df1ee6f --- /dev/null +++ b/composer.json @@ -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 +} diff --git a/config/bad-ip-blocker.php b/config/bad-ip-blocker.php new file mode 100644 index 0000000..f2d400f --- /dev/null +++ b/config/bad-ip-blocker.php @@ -0,0 +1,90 @@ + 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), + ], + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..08bf782 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/BadIpBlockerServiceProvider.php b/src/BadIpBlockerServiceProvider.php new file mode 100644 index 0000000..abb75ce --- /dev/null +++ b/src/BadIpBlockerServiceProvider.php @@ -0,0 +1,71 @@ +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(); + }); + } +} diff --git a/src/BadIpStore.php b/src/BadIpStore.php new file mode 100644 index 0000000..45e2295 --- /dev/null +++ b/src/BadIpStore.php @@ -0,0 +1,197 @@ +|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 + */ + 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); + } + } +} diff --git a/src/Console/AddCommand.php b/src/Console/AddCommand.php new file mode 100644 index 0000000..77f90cf --- /dev/null +++ b/src/Console/AddCommand.php @@ -0,0 +1,40 @@ +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; + } +} diff --git a/src/Console/ClearCommand.php b/src/Console/ClearCommand.php new file mode 100644 index 0000000..94206db --- /dev/null +++ b/src/Console/ClearCommand.php @@ -0,0 +1,26 @@ +option('force') && ! $this->confirm('Remove all blocked IP addresses?')) { + return self::FAILURE; + } + + $store->clear(); + + $this->components->info('All blocks removed.'); + + return self::SUCCESS; + } +} diff --git a/src/Console/ListCommand.php b/src/Console/ListCommand.php new file mode 100644 index 0000000..7b14681 --- /dev/null +++ b/src/Console/ListCommand.php @@ -0,0 +1,36 @@ +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; + } +} diff --git a/src/Console/PruneCommand.php b/src/Console/PruneCommand.php new file mode 100644 index 0000000..c273842 --- /dev/null +++ b/src/Console/PruneCommand.php @@ -0,0 +1,20 @@ +components->info("Removed {$store->prune()} expired block(s)."); + + return self::SUCCESS; + } +} diff --git a/src/Console/RemoveCommand.php b/src/Console/RemoveCommand.php new file mode 100644 index 0000000..d4ec932 --- /dev/null +++ b/src/Console/RemoveCommand.php @@ -0,0 +1,28 @@ +argument('ip'); + + if (! $store->unblock($ip)) { + $this->components->warn("[{$ip}] was not blocked."); + + return self::FAILURE; + } + + $this->components->info("Unblocked [{$ip}]."); + + return self::SUCCESS; + } +} diff --git a/src/Facades/BadIpBlocker.php b/src/Facades/BadIpBlocker.php new file mode 100644 index 0000000..b86c2a1 --- /dev/null +++ b/src/Facades/BadIpBlocker.php @@ -0,0 +1,25 @@ +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), + ); + } +} diff --git a/tests/Feature/AutoRegisterTest.php b/tests/Feature/AutoRegisterTest.php new file mode 100644 index 0000000..c21bb48 --- /dev/null +++ b/tests/Feature/AutoRegisterTest.php @@ -0,0 +1,27 @@ +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 * * *'); +}); diff --git a/tests/Feature/CommandsTest.php b/tests/Feature/CommandsTest.php new file mode 100644 index 0000000..b05be60 --- /dev/null +++ b/tests/Feature/CommandsTest.php @@ -0,0 +1,53 @@ +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([]); +}); diff --git a/tests/Feature/MiddlewareTest.php b/tests/Feature/MiddlewareTest.php new file mode 100644 index 0000000..02c02bc --- /dev/null +++ b/tests/Feature/MiddlewareTest.php @@ -0,0 +1,100 @@ +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(); +}); diff --git a/tests/OptOut/OptOutTest.php b/tests/OptOut/OptOutTest.php new file mode 100644 index 0000000..5eceaa9 --- /dev/null +++ b/tests/OptOut/OptOutTest.php @@ -0,0 +1,21 @@ +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(); +}); diff --git a/tests/OptOutTestCase.php b/tests/OptOutTestCase.php new file mode 100644 index 0000000..d8b1862 --- /dev/null +++ b/tests/OptOutTestCase.php @@ -0,0 +1,14 @@ +set('bad-ip-blocker.auto_register', false); + $app['config']->set('bad-ip-blocker.schedule_prune', false); + } +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..1d58f16 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +in('Feature', 'Unit'); +uses(OptOutTestCase::class)->in('OptOut'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..c3ed3bb --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,48 @@ + 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); + } +} diff --git a/tests/Unit/BadIpStoreTest.php b/tests/Unit/BadIpStoreTest.php new file mode 100644 index 0000000..74ed461 --- /dev/null +++ b/tests/Unit/BadIpStoreTest.php @@ -0,0 +1,93 @@ + $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(); +});