feat: add captcha-backed forum security hardening
This commit is contained in:
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();
|
||||
}
|
||||
Reference in New Issue
Block a user