feat: add captcha-backed forum security hardening
This commit is contained in:
95
tests/Unit/AccountFarmDetectorTest.php
Normal file
95
tests/Unit/AccountFarmDetectorTest.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\AccountFarmDetector;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('flags repeated posting patterns across multiple accounts', function () {
|
||||
config()->set('forum_bot_protection.account_farm', [
|
||||
'window_minutes' => 10,
|
||||
'register_attempt_threshold' => 10,
|
||||
'same_ip_users_threshold' => 5,
|
||||
'same_fingerprint_users_threshold' => 3,
|
||||
'same_pattern_users_threshold' => 2,
|
||||
'register_attempt_penalty' => 50,
|
||||
'same_ip_penalty' => 35,
|
||||
'same_fingerprint_penalty' => 40,
|
||||
'same_pattern_penalty' => 45,
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_posts');
|
||||
Schema::dropIfExists('forum_bot_device_fingerprints');
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_device_fingerprints', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('fingerprint', 128)->nullable();
|
||||
$table->timestamp('first_seen')->nullable();
|
||||
$table->timestamp('last_seen')->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('user_agent_hash', 64)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('thread_id')->nullable();
|
||||
$table->unsignedBigInteger('topic_id')->nullable();
|
||||
$table->string('source_ip_hash', 64)->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->longText('content')->nullable();
|
||||
$table->string('content_hash', 64)->nullable();
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->unsignedInteger('spam_score')->default(0);
|
||||
$table->unsignedInteger('quality_score')->default(0);
|
||||
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||
$table->unsignedInteger('behavior_score')->default(0);
|
||||
$table->unsignedInteger('link_score')->default(0);
|
||||
$table->integer('learning_score')->default(0);
|
||||
$table->unsignedInteger('risk_score')->default(0);
|
||||
$table->integer('trust_modifier')->default(0);
|
||||
$table->boolean('flagged')->default(false);
|
||||
$table->string('flagged_reason')->nullable();
|
||||
$table->boolean('moderation_checked')->default(false);
|
||||
$table->string('moderation_status')->nullable();
|
||||
$table->json('moderation_labels')->nullable();
|
||||
$table->json('moderation_meta')->nullable();
|
||||
$table->timestamp('last_ai_scan_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
$hash = hash('sha256', 'buy cheap backlinks now');
|
||||
|
||||
foreach ([1, 2, 3] as $userId) {
|
||||
DB::table('forum_posts')->insert([
|
||||
'user_id' => $userId,
|
||||
'content' => 'buy cheap backlinks now',
|
||||
'content_hash' => $hash,
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(AccountFarmDetector::class)->analyze(1, '203.0.113.10', null, 'forum_reply_create');
|
||||
|
||||
expect($result['score'])->toBe(45)
|
||||
->and($result['reasons'])->toContain('Posting patterns or repeated content overlap across multiple accounts.');
|
||||
});
|
||||
52
tests/Unit/BotRiskScorerTest.php
Normal file
52
tests/Unit/BotRiskScorerTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\BotRiskScorer;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('maps bot risk thresholds to the expected decisions', function () {
|
||||
config()->set('forum_bot_protection.thresholds', [
|
||||
'allow' => 20,
|
||||
'log' => 20,
|
||||
'captcha' => 40,
|
||||
'moderate' => 60,
|
||||
'block' => 80,
|
||||
]);
|
||||
|
||||
$scorer = app(BotRiskScorer::class);
|
||||
|
||||
expect($scorer->score(['behavior' => 10]))->toMatchArray([
|
||||
'risk_score' => 10,
|
||||
'decision' => 'allow',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 20]))->toMatchArray([
|
||||
'risk_score' => 20,
|
||||
'decision' => 'log',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 40]))->toMatchArray([
|
||||
'risk_score' => 40,
|
||||
'decision' => 'captcha',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 60]))->toMatchArray([
|
||||
'risk_score' => 60,
|
||||
'decision' => 'moderate',
|
||||
'requires_review' => true,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 80]))->toMatchArray([
|
||||
'risk_score' => 80,
|
||||
'decision' => 'block',
|
||||
'requires_review' => false,
|
||||
'blocked' => true,
|
||||
]);
|
||||
});
|
||||
166
tests/Unit/ForumRateLimitMiddlewareTest.php
Normal file
166
tests/Unit/ForumRateLimitMiddlewareTest.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ForumRateLimitMiddleware;
|
||||
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('reports forum throttle violations to bot protection before rethrowing', function () {
|
||||
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.reply';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '42',
|
||||
'X-RateLimit-Limit' => '3',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||
->andThrow($exception);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_reply_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-post-write'
|
||||
&& $context['bucket'] === 'minute'
|
||||
&& $context['max_attempts'] === 3
|
||||
&& $context['retry_after'] === 42;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
|
||||
it('classifies forum hourly limiter violations using the actual limit bucket', function () {
|
||||
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.reply';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '120',
|
||||
'X-RateLimit-Limit' => '10',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||
->andThrow($exception);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_reply_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['bucket'] === 'hour'
|
||||
&& $context['max_attempts'] === 10
|
||||
&& $context['retry_after'] === 120;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
|
||||
it('classifies thread creation minute and hour limiter buckets correctly', function () {
|
||||
$request = Request::create('/forum/example-board/new', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.store';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$minuteException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '30',
|
||||
'X-RateLimit-Limit' => '3',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$hourException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '120',
|
||||
'X-RateLimit-Limit' => '10',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||
->andThrow($minuteException);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||
->andThrow($hourException);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_topic_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-thread-create'
|
||||
&& $context['bucket'] === 'minute'
|
||||
&& $context['max_attempts'] === 3
|
||||
&& $context['retry_after'] === 30;
|
||||
})
|
||||
);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_topic_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-thread-create'
|
||||
&& $context['bucket'] === 'hour'
|
||||
&& $context['max_attempts'] === 10
|
||||
&& $context['retry_after'] === 120;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
try {
|
||||
$middleware->handle($request, $next);
|
||||
} catch (ThrottleRequestsException) {
|
||||
}
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
482
tests/Unit/ForumRateLimitRouteTest.php
Normal file
482
tests/Unit/ForumRateLimitRouteTest.php
Normal file
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use cPad\Plugins\Forum\Services\ForumModerationService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('enforces forum write rate limits for thread creation and replies', function () {
|
||||
ensureForumRateLimitModelClassesLoaded();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
config()->set('forum_bot_protection.enabled', false);
|
||||
config()->set('forum_bot_protection.behavior.new_account_days', 0);
|
||||
config()->set('skinbase_ai_moderation.enabled', false);
|
||||
|
||||
$moderationService = \Mockery::mock(ForumModerationService::class);
|
||||
$moderationService->shouldReceive('preflight')->andReturnUsing(static function ($user, $content, $sourceIp): array {
|
||||
return [
|
||||
'spam_score' => 0,
|
||||
'quality_score' => 0,
|
||||
'ai_spam_score' => 0,
|
||||
'ai_toxicity_score' => 0,
|
||||
'behavior_score' => 0,
|
||||
'link_score' => 0,
|
||||
'learning_score' => 0,
|
||||
'risk_score' => 0,
|
||||
'trust_modifier' => 0,
|
||||
'decision' => 'allowed',
|
||||
'captcha_required' => false,
|
||||
'blocked' => false,
|
||||
'requires_review' => false,
|
||||
'flagged' => false,
|
||||
'reason' => null,
|
||||
'content_hash' => hash('sha256', (string) $content),
|
||||
'pattern_signature' => null,
|
||||
'source_ip_hash' => $sourceIp ? hash('sha256', $sourceIp) : null,
|
||||
'moderation_labels' => ['preflight', 'allowed'],
|
||||
'provider' => 'none',
|
||||
'provider_available' => false,
|
||||
'language' => null,
|
||||
];
|
||||
});
|
||||
$moderationService->shouldReceive('applyPreflightAssessment')->andReturnUsing(static function (ForumPost $post, array $assessment): void {
|
||||
$post->forceFill([
|
||||
'source_ip_hash' => $assessment['source_ip_hash'] ?? $post->source_ip_hash,
|
||||
'content_hash' => $assessment['content_hash'] ?? $post->content_hash,
|
||||
'spam_score' => (int) ($assessment['spam_score'] ?? 0),
|
||||
'quality_score' => (int) ($assessment['quality_score'] ?? 0),
|
||||
'ai_spam_score' => (int) ($assessment['ai_spam_score'] ?? 0),
|
||||
'ai_toxicity_score' => (int) ($assessment['ai_toxicity_score'] ?? 0),
|
||||
'behavior_score' => (int) ($assessment['behavior_score'] ?? 0),
|
||||
'link_score' => (int) ($assessment['link_score'] ?? 0),
|
||||
'learning_score' => (int) ($assessment['learning_score'] ?? 0),
|
||||
'risk_score' => (int) ($assessment['risk_score'] ?? 0),
|
||||
'trust_modifier' => (int) ($assessment['trust_modifier'] ?? 0),
|
||||
'flagged' => (bool) ($assessment['flagged'] ?? false),
|
||||
'flagged_reason' => $assessment['reason'] ?? null,
|
||||
'moderation_checked' => false,
|
||||
'moderation_status' => 'pending_ai_scan',
|
||||
'moderation_labels' => (array) ($assessment['moderation_labels'] ?? []),
|
||||
'moderation_meta' => [
|
||||
'provider' => $assessment['provider'] ?? 'none',
|
||||
'provider_available' => (bool) ($assessment['provider_available'] ?? false),
|
||||
'language' => $assessment['language'] ?? null,
|
||||
],
|
||||
])->save();
|
||||
});
|
||||
$moderationService->shouldReceive('logRequestSecurity')->andReturnNull();
|
||||
$moderationService->shouldReceive('dispatchAsyncScan')->andReturnNull();
|
||||
|
||||
$this->app->instance(ForumModerationService::class, $moderationService);
|
||||
|
||||
createForumRateLimitTestSchema();
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => 'ratelimit-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Rate Limit User',
|
||||
'email' => 'ratelimit@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
markForumRateLimitUserAsEstablished($user);
|
||||
|
||||
$board = makeForumBoard('thread-limit');
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
$response = $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||
'title' => 'Rate limit topic ' . $attempt,
|
||||
'content' => 'Thread body ' . $attempt,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
$this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||
'title' => 'Rate limit topic 4',
|
||||
'content' => 'Thread body 4',
|
||||
])->assertStatus(429);
|
||||
|
||||
expect(ForumTopic::query()->count())->toBe(3)
|
||||
->and(ForumPost::query()->count())->toBe(3);
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
|
||||
$replyUser = User::query()->create([
|
||||
'username' => 'reply-limit-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Reply Limit User',
|
||||
'email' => 'replylimit@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
markForumRateLimitUserAsEstablished($replyUser);
|
||||
|
||||
clearForumRateLimiters((string) $replyUser->id);
|
||||
|
||||
$topic = makeForumTopic($replyUser);
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
$response = $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||
'content' => 'Reply burst ' . $attempt,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
$this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||
'content' => 'Reply burst 4',
|
||||
])->assertStatus(429);
|
||||
|
||||
expect(ForumPost::query()->where('topic_id', $topic->id)->count())->toBe(4);
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
clearForumRateLimiters((string) $replyUser->id);
|
||||
});
|
||||
|
||||
function createForumRateLimitTestSchema(): void
|
||||
{
|
||||
foreach ([
|
||||
'forum_security_logs',
|
||||
'forum_firewall_logs',
|
||||
'forum_bot_ip_blacklist',
|
||||
'forum_spam_signatures',
|
||||
'forum_spam_learning',
|
||||
'forum_spam_domains',
|
||||
'forum_spam_keywords',
|
||||
'forum_topic_tags',
|
||||
'forum_tags',
|
||||
'forum_posts',
|
||||
'forum_topics',
|
||||
'forum_threads',
|
||||
'forum_boards',
|
||||
'forum_categories',
|
||||
'users',
|
||||
] as $table) {
|
||||
Schema::dropIfExists($table);
|
||||
}
|
||||
|
||||
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45)->unique();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_firewall_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->string('threat_type', 80)->nullable();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_security_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->unsignedBigInteger('post_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->json('layer_scores')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_signatures', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('content_hash', 64)->nullable()->index();
|
||||
$table->string('pattern_signature', 191)->nullable()->index();
|
||||
$table->string('source', 32)->nullable();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedInteger('confidence')->default(0);
|
||||
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->timestamp('username_changed_at')->nullable();
|
||||
$table->timestamp('last_username_change_at')->nullable();
|
||||
$table->string('onboarding_step')->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedInteger('trust_score')->default(0);
|
||||
$table->unsignedInteger('approved_posts')->default(0);
|
||||
$table->unsignedInteger('flagged_posts')->default(0);
|
||||
$table->string('role')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_categories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedBigInteger('parent_id')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_boards', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('category_id');
|
||||
$table->unsignedBigInteger('legacy_category_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_read_only')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_threads', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('category_id');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->longText('content');
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->string('visibility')->default('public');
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_topics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('board_id');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_thread_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->unsignedInteger('replies_count')->default(0);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_deleted')->default(false);
|
||||
$table->unsignedBigInteger('last_post_id')->nullable();
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('thread_id');
|
||||
$table->unsignedBigInteger('topic_id')->nullable();
|
||||
$table->string('source_ip_hash', 64)->nullable();
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->longText('content');
|
||||
$table->string('content_hash', 64)->nullable();
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->unsignedInteger('spam_score')->default(0);
|
||||
$table->unsignedInteger('quality_score')->default(0);
|
||||
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||
$table->unsignedInteger('behavior_score')->default(0);
|
||||
$table->unsignedInteger('link_score')->default(0);
|
||||
$table->integer('learning_score')->default(0);
|
||||
$table->unsignedInteger('risk_score')->default(0);
|
||||
$table->integer('trust_modifier')->default(0);
|
||||
$table->boolean('flagged')->default(false);
|
||||
$table->string('flagged_reason')->nullable();
|
||||
$table->boolean('moderation_checked')->default(false);
|
||||
$table->string('moderation_status')->nullable();
|
||||
$table->json('moderation_labels')->nullable();
|
||||
$table->json('moderation_meta')->nullable();
|
||||
$table->timestamp('last_ai_scan_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('slug')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_topic_tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('topic_id');
|
||||
$table->unsignedBigInteger('tag_id');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['topic_id', 'tag_id']);
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_domains', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('domain')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('keyword', 120)->unique();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_learning', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('content_hash', 64)->index();
|
||||
$table->string('decision', 32);
|
||||
$table->string('pattern_signature', 191)->nullable();
|
||||
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
function ensureForumRateLimitModelClassesLoaded(): void
|
||||
{
|
||||
foreach ([
|
||||
'packages/klevze/Plugins/Forum/Models/ForumPost.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumSpamLearning.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumAiLog.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumModerationQueue.php',
|
||||
] as $relativePath) {
|
||||
require_once base_path($relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
function makeForumBoard(string $suffix): ForumBoard
|
||||
{
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Rate Limit Category ' . $suffix,
|
||||
'title' => 'Rate Limit Category ' . $suffix,
|
||||
'slug' => 'rate-limit-category-' . $suffix,
|
||||
'description' => 'Test category',
|
||||
'position' => 1,
|
||||
'is_active' => true,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
|
||||
return ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Rate Limit Board ' . $suffix,
|
||||
'slug' => 'rate-limit-board-' . $suffix,
|
||||
'description' => 'Test board',
|
||||
'position' => 1,
|
||||
'is_active' => true,
|
||||
'is_read_only' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeForumTopic(User $user): ForumTopic
|
||||
{
|
||||
$board = makeForumBoard('reply-limit');
|
||||
|
||||
$legacyThreadId = DB::table('forum_threads')->insertGetId([
|
||||
'category_id' => $board->category_id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Existing topic',
|
||||
'slug' => 'existing-topic-reply-limit',
|
||||
'content' => 'Opening post',
|
||||
'views' => 0,
|
||||
'is_locked' => false,
|
||||
'is_pinned' => false,
|
||||
'visibility' => 'public',
|
||||
'last_post_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $user->id,
|
||||
'legacy_thread_id' => $legacyThreadId,
|
||||
'title' => 'Existing topic',
|
||||
'slug' => 'existing-topic-reply-limit',
|
||||
'views' => 0,
|
||||
'replies_count' => 0,
|
||||
'is_pinned' => false,
|
||||
'is_locked' => false,
|
||||
'is_deleted' => false,
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
$post = ForumPost::query()->create([
|
||||
'thread_id' => $legacyThreadId,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $user->id,
|
||||
'content' => 'Opening post',
|
||||
'is_edited' => false,
|
||||
]);
|
||||
|
||||
$topic->forceFill([
|
||||
'last_post_id' => $post->id,
|
||||
'last_post_at' => $post->created_at,
|
||||
])->save();
|
||||
|
||||
return $topic;
|
||||
}
|
||||
|
||||
function clearForumRateLimiters(string $key): void
|
||||
{
|
||||
foreach ([
|
||||
'forum-thread-minute:' . $key,
|
||||
'forum-thread-hour:' . $key,
|
||||
'forum-post-minute:' . $key,
|
||||
'forum-post-hour:' . $key,
|
||||
] as $limiterKey) {
|
||||
RateLimiter::clear($limiterKey);
|
||||
}
|
||||
}
|
||||
|
||||
function markForumRateLimitUserAsEstablished(User $user): void
|
||||
{
|
||||
$timestamp = now()->subDays(30);
|
||||
|
||||
$user->forceFill([
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
])->save();
|
||||
}
|
||||
83
tests/Unit/GeoBehaviorAnalyzerTest.php
Normal file
83
tests/Unit/GeoBehaviorAnalyzerTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBotLog;
|
||||
use cPad\Plugins\Forum\Services\Security\GeoBehaviorAnalyzer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('scores only rapid login country changes for the same account', function () {
|
||||
config()->set('forum_bot_protection.geo_behavior', [
|
||||
'enabled' => true,
|
||||
'login_actions' => ['login'],
|
||||
'country_headers' => ['CF-IPCountry'],
|
||||
'recent_login_window_minutes' => 60,
|
||||
'country_change_penalty' => 50,
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => 1,
|
||||
'email' => 'geo@example.com',
|
||||
'password' => 'secret',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::query()->findOrFail(1);
|
||||
|
||||
ForumBotLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'action' => 'login',
|
||||
'risk_score' => 0,
|
||||
'decision' => 'allow',
|
||||
'metadata' => ['country_code' => 'SI'],
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->headers->set('CF-IPCountry', 'SI');
|
||||
|
||||
$unchanged = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||
|
||||
expect($unchanged)->toMatchArray([
|
||||
'score' => 0,
|
||||
'country_code' => 'SI',
|
||||
])->and($unchanged['reasons'])->toBe([]);
|
||||
|
||||
$request->headers->set('CF-IPCountry', 'JP');
|
||||
|
||||
$analysis = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||
|
||||
expect($analysis['score'])->toBe(50)
|
||||
->and($analysis['country_code'])->toBe('JP')
|
||||
->and($analysis['reasons'])->toHaveCount(1)
|
||||
->and($analysis['reasons'][0])->toContain('SI')
|
||||
->and($analysis['reasons'][0])->toContain('JP');
|
||||
});
|
||||
70
tests/Unit/IPReputationServiceTest.php
Normal file
70
tests/Unit/IPReputationServiceTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\IPReputationService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('scores CIDR datacenter and proxy ranges in IP reputation analysis', function () {
|
||||
Cache::flush();
|
||||
|
||||
config()->set('forum_bot_protection.ip', [
|
||||
'cache_ttl_minutes' => 15,
|
||||
'recent_high_risk_window_hours' => 24,
|
||||
'recent_high_risk_threshold' => 3,
|
||||
'recent_high_risk_penalty' => 20,
|
||||
'known_proxy_penalty' => 20,
|
||||
'datacenter_penalty' => 25,
|
||||
'tor_penalty' => 40,
|
||||
'blacklist_penalty' => 100,
|
||||
'known_proxies' => ['198.51.100.0/24'],
|
||||
'datacenter_ranges' => ['203.0.113.0/24'],
|
||||
'provider_ranges' => [
|
||||
'aws' => ['54.240.0.0/12'],
|
||||
],
|
||||
'tor_exit_nodes' => [],
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_bot_ip_blacklist');
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
|
||||
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45)->unique();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
$service = app(IPReputationService::class);
|
||||
|
||||
$proxyResult = $service->analyze('198.51.100.23');
|
||||
$datacenterResult = $service->analyze('203.0.113.77');
|
||||
$providerResult = $service->analyze('54.240.10.20');
|
||||
|
||||
expect($proxyResult['score'])->toBe(20)
|
||||
->and($proxyResult['reasons'])->toContain('IP address is in the proxy watch list.')
|
||||
->and($proxyResult['blocked'])->toBeFalse();
|
||||
|
||||
expect($datacenterResult['score'])->toBe(25)
|
||||
->and($datacenterResult['reasons'])->toContain('IP address belongs to a datacenter or hosting network range.')
|
||||
->and($datacenterResult['blocked'])->toBeFalse();
|
||||
|
||||
expect($providerResult['score'])->toBe(25)
|
||||
->and($providerResult['reasons'])->toContain('IP address belongs to the configured AWS provider range.')
|
||||
->and($providerResult['blocked'])->toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user