Skip to content

Commit ae4903f

Browse files
committed
Implement ban on malicious IPs on Cloudflare
1 parent 62e96c6 commit ae4903f

7 files changed

Lines changed: 368 additions & 0 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,8 @@ VITE_APP_NAME="${APP_NAME}"
6666
GITHUB_TOKEN=
6767

6868
TRANSISTOR_API_KEY=
69+
70+
CLOUDFLARE_ZONE=
71+
CLOUDFLARE_RULESET=
72+
CLOUDFLARE_RULE=
73+
CLOUDFLARE_TOKEN=
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use App\Jobs\BanIpOnCloudflare;
6+
use Closure;
7+
use Illuminate\Http\Request;
8+
use Illuminate\Support\Facades\App;
9+
use Illuminate\Support\Facades\Cache;
10+
use Symfony\Component\HttpFoundation\Response;
11+
12+
class BanMaliciousIpMiddleware
13+
{
14+
private const int MAX_404_COUNT = 5;
15+
16+
private const int WINDOW_SECONDS = 120;
17+
18+
/**
19+
* @param Closure(Request): Response $next
20+
*/
21+
public function handle(Request $request, Closure $next): Response
22+
{
23+
if (! App::isProduction()) {
24+
return $next($request);
25+
}
26+
27+
if ($this->isWordPressProbe($request)) {
28+
$this->banIp((string) $request->ip(), 'WordPress probe detected');
29+
30+
abort(403);
31+
}
32+
33+
$response = $next($request);
34+
35+
if ($response->isNotFound()) {
36+
$this->trackNotFound($request);
37+
}
38+
39+
return $response;
40+
}
41+
42+
private function isWordPressProbe(Request $request): bool
43+
{
44+
return str_contains($request->path(), 'wp-admin')
45+
|| str_contains($request->path(), 'wp-login')
46+
|| str_contains($request->path(), 'wp-includes')
47+
|| str_contains($request->path(), 'xmlrpc.php');
48+
}
49+
50+
private function trackNotFound(Request $request): void
51+
{
52+
$ip = (string) $request->ip();
53+
$key = "ip_404_count:{$ip}";
54+
55+
$count = Cache::increment($key);
56+
57+
if ($count === 1) {
58+
Cache::put($key, 1, self::WINDOW_SECONDS);
59+
}
60+
61+
if ($count >= self::MAX_404_COUNT) {
62+
$this->banIp($ip, "Exceeded 404 threshold ({$count} in ".self::WINDOW_SECONDS.' seconds)');
63+
64+
Cache::forget($key);
65+
}
66+
}
67+
68+
private function banIp(string $ip, string $reason): void
69+
{
70+
BanIpOnCloudflare::dispatch($ip, $reason);
71+
}
72+
}

app/Jobs/BanIpOnCloudflare.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use Illuminate\Contracts\Queue\ShouldQueue;
6+
use Illuminate\Foundation\Queue\Queueable;
7+
use Illuminate\Support\Facades\Config;
8+
use Illuminate\Support\Facades\Http;
9+
use Illuminate\Support\Facades\Log;
10+
11+
class BanIpOnCloudflare implements ShouldQueue
12+
{
13+
use Queueable;
14+
15+
public function __construct(
16+
public string $ip,
17+
public string $reason,
18+
) {}
19+
20+
public function handle(): void
21+
{
22+
$expression = $this->fetchCurrentExpression();
23+
24+
$updatedExpression = $this->appendIp($expression);
25+
26+
$this->updateRule($updatedExpression);
27+
28+
Log::info("Banned IP {$this->ip} on Cloudflare", [
29+
'ip' => $this->ip,
30+
'reason' => $this->reason,
31+
]);
32+
}
33+
34+
private function fetchCurrentExpression(): string
35+
{
36+
$response = Http::withToken($this->token())
37+
->throw()
38+
->get($this->rulesetUrl());
39+
40+
/** @var array<int, array{id: string, expression: string}> $rules */
41+
$rules = $response->json('result.rules', []);
42+
43+
foreach ($rules as $rule) {
44+
if ($rule['id'] === $this->ruleId()) {
45+
return $rule['expression'];
46+
}
47+
}
48+
49+
return '(ip.src eq 0.0.0.0)';
50+
}
51+
52+
private function appendIp(string $expression): string
53+
{
54+
return "{$expression} or (ip.src eq {$this->ip})";
55+
}
56+
57+
private function updateRule(string $expression): void
58+
{
59+
Http::withToken($this->token())
60+
->patch($this->ruleUrl(), [
61+
'action' => 'block',
62+
'description' => 'IP Ban',
63+
'enabled' => true,
64+
'expression' => $expression,
65+
])
66+
->throw();
67+
}
68+
69+
private function rulesetUrl(): string
70+
{
71+
$zoneId = Config::string('services.cloudflare.zone_id');
72+
$rulesetId = Config::string('services.cloudflare.ruleset_id');
73+
74+
return "https://api.cloudflare.com/client/v4/zones/{$zoneId}/rulesets/{$rulesetId}";
75+
}
76+
77+
private function ruleUrl(): string
78+
{
79+
return "{$this->rulesetUrl()}/rules/{$this->ruleId()}";
80+
}
81+
82+
private function ruleId(): string
83+
{
84+
return Config::string('services.cloudflare.rule_id');
85+
}
86+
87+
private function token(): string
88+
{
89+
return Config::string('services.cloudflare.token');
90+
}
91+
}

bootstrap/app.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Middleware\BanMaliciousIpMiddleware;
34
use App\Http\Middleware\CachePageMiddleware;
45
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
56
use Illuminate\Cookie\Middleware\EncryptCookies;
@@ -30,6 +31,7 @@
3031
$middleware->append([
3132
RenderTorchlight::class,
3233
CachePageMiddleware::class,
34+
BanMaliciousIpMiddleware::class,
3335
]);
3436
})
3537
->withExceptions(function (Exceptions $exceptions): void {

config/services.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,11 @@
88
'transistor' => [
99
'api_key' => env('TRANSISTOR_API_KEY'),
1010
],
11+
12+
'cloudflare' => [
13+
'zone_id' => env('CLOUDFLARE_ZONE'),
14+
'ruleset_id' => env('CLOUDFLARE_RULESET'),
15+
'rule_id' => env('CLOUDFLARE_RULE'),
16+
'token' => env('CLOUDFLARE_TOKEN'),
17+
],
1118
];
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
use App\Jobs\BanIpOnCloudflare;
4+
use Illuminate\Support\Facades\Cache;
5+
use Illuminate\Support\Facades\Queue;
6+
7+
beforeEach(function (): void {
8+
Queue::fake();
9+
Cache::flush();
10+
});
11+
12+
it('dispatches ban job for wp-admin requests in production', function (): void {
13+
app()->detectEnvironment(fn () => 'production');
14+
15+
$this->get('/wp-admin')
16+
->assertForbidden();
17+
18+
Queue::assertPushed(BanIpOnCloudflare::class, fn ($job): bool => $job->reason === 'WordPress probe detected');
19+
});
20+
21+
it('dispatches ban job for wp-login requests in production', function (): void {
22+
app()->detectEnvironment(fn () => 'production');
23+
24+
$this->get('/wp-login.php')
25+
->assertForbidden();
26+
27+
Queue::assertPushed(BanIpOnCloudflare::class);
28+
});
29+
30+
it('dispatches ban job for wp-includes requests in production', function (): void {
31+
app()->detectEnvironment(fn () => 'production');
32+
33+
$this->get('/wp-includes/some-file.php')
34+
->assertForbidden();
35+
36+
Queue::assertPushed(BanIpOnCloudflare::class);
37+
});
38+
39+
it('dispatches ban job for xmlrpc requests in production', function (): void {
40+
app()->detectEnvironment(fn () => 'production');
41+
42+
$this->get('/xmlrpc.php')
43+
->assertForbidden();
44+
45+
Queue::assertPushed(BanIpOnCloudflare::class);
46+
});
47+
48+
it('tracks 404 errors and bans after threshold in production', function (): void {
49+
app()->detectEnvironment(fn () => 'production');
50+
51+
for ($i = 1; $i <= 4; $i++) {
52+
$this->get('/nonexistent-page-'.$i)
53+
->assertNotFound();
54+
55+
Queue::assertNotPushed(BanIpOnCloudflare::class);
56+
}
57+
58+
$this->get('/nonexistent-page-5')
59+
->assertNotFound();
60+
61+
Queue::assertPushed(BanIpOnCloudflare::class, fn ($job): bool => str_contains((string) $job->reason, 'Exceeded 404 threshold'));
62+
});
63+
64+
it('does not track 404s in non-production environments', function (): void {
65+
app()->detectEnvironment(fn () => 'local');
66+
67+
for ($i = 1; $i <= 10; $i++) {
68+
$this->get('/nonexistent-page-'.$i)
69+
->assertNotFound();
70+
}
71+
72+
Queue::assertNotPushed(BanIpOnCloudflare::class);
73+
});
74+
75+
it('does not ban wp-admin in non-production environments', function (): void {
76+
app()->detectEnvironment(fn () => 'local');
77+
78+
$this->get('/wp-admin')
79+
->assertNotFound();
80+
81+
Queue::assertNotPushed(BanIpOnCloudflare::class);
82+
});
83+
84+
it('clears 404 count after banning', function (): void {
85+
app()->detectEnvironment(fn () => 'production');
86+
87+
for ($i = 1; $i <= 5; $i++) {
88+
$this->get('/nonexistent-page-'.$i);
89+
}
90+
91+
expect(Cache::get('ip_404_count:127.0.0.1'))->toBeNull();
92+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
use App\Jobs\BanIpOnCloudflare;
4+
use Illuminate\Support\Facades\Http;
5+
use Illuminate\Support\Facades\Log;
6+
7+
beforeEach(function (): void {
8+
config([
9+
'services.cloudflare.zone_id' => 'test-zone-id',
10+
'services.cloudflare.ruleset_id' => 'test-ruleset-id',
11+
'services.cloudflare.rule_id' => 'test-rule-id',
12+
'services.cloudflare.token' => 'test-token',
13+
]);
14+
});
15+
16+
it('fetches current rule and appends new ip', function (): void {
17+
Http::fake([
18+
'api.cloudflare.com/client/v4/zones/test-zone-id/rulesets/test-ruleset-id' => Http::response([
19+
'result' => [
20+
'rules' => [
21+
[
22+
'id' => 'test-rule-id',
23+
'expression' => '(ip.src eq 1.2.3.4)',
24+
],
25+
],
26+
],
27+
]),
28+
'api.cloudflare.com/client/v4/zones/test-zone-id/rulesets/test-ruleset-id/rules/test-rule-id' => Http::response([
29+
'success' => true,
30+
]),
31+
]);
32+
33+
$job = new BanIpOnCloudflare('5.6.7.8', 'Test ban');
34+
$job->handle();
35+
36+
Http::assertSent(function ($request): bool {
37+
if ($request->method() === 'PATCH') {
38+
$body = $request->data();
39+
40+
return $body['expression'] === '(ip.src eq 1.2.3.4) or (ip.src eq 5.6.7.8)'
41+
&& $body['action'] === 'block'
42+
&& $body['enabled'] === true;
43+
}
44+
45+
return true;
46+
});
47+
});
48+
49+
it('logs the ban', function (): void {
50+
Http::fake([
51+
'api.cloudflare.com/*' => Http::response([
52+
'result' => [
53+
'rules' => [
54+
[
55+
'id' => 'test-rule-id',
56+
'expression' => '(ip.src eq 1.2.3.4)',
57+
],
58+
],
59+
],
60+
'success' => true,
61+
]),
62+
]);
63+
64+
Log::shouldReceive('info')
65+
->once()
66+
->with('Banned IP 5.6.7.8 on Cloudflare', [
67+
'ip' => '5.6.7.8',
68+
'reason' => 'WordPress probe detected',
69+
]);
70+
71+
$job = new BanIpOnCloudflare('5.6.7.8', 'WordPress probe detected');
72+
$job->handle();
73+
});
74+
75+
it('handles empty ruleset by using default expression', function (): void {
76+
Http::fake([
77+
'api.cloudflare.com/client/v4/zones/test-zone-id/rulesets/test-ruleset-id' => Http::response([
78+
'result' => [
79+
'rules' => [],
80+
],
81+
]),
82+
'api.cloudflare.com/client/v4/zones/test-zone-id/rulesets/test-ruleset-id/rules/test-rule-id' => Http::response([
83+
'success' => true,
84+
]),
85+
]);
86+
87+
$job = new BanIpOnCloudflare('5.6.7.8', 'Test ban');
88+
$job->handle();
89+
90+
Http::assertSent(function ($request): bool {
91+
if ($request->method() === 'PATCH') {
92+
$body = $request->data();
93+
94+
return $body['expression'] === '(ip.src eq 0.0.0.0) or (ip.src eq 5.6.7.8)';
95+
}
96+
97+
return true;
98+
});
99+
});

0 commit comments

Comments
 (0)