feat: add captcha-backed forum security hardening

This commit is contained in:
2026-03-17 16:06:28 +01:00
parent 980a15f66e
commit b3fc889452
40 changed files with 2849 additions and 108 deletions

View 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.');
});

View 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,
]);
});

View 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);

View 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();
}

View 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');
});

View 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();
});