From b3fc88945209fdebd9f63dba3ecebbcf08e7044d Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Tue, 17 Mar 2026 16:06:28 +0100 Subject: [PATCH] feat: add captcha-backed forum security hardening --- .../Auth/AuthenticatedSessionController.php | 11 +- .../Auth/RegisteredUserController.php | 26 +- .../Controllers/User/ProfileController.php | 4 + .../ForumAIModerationMiddleware.php | 50 ++ .../ForumBotProtectionMiddleware.php | 96 ++++ .../Middleware/ForumRateLimitMiddleware.php | 70 +++ .../ForumSecurityFirewallMiddleware.php | 90 ++++ .../ForumSpamDetectionMiddleware.php | 66 +++ app/Models/ForumPost.php | 23 + app/Models/User.php | 12 + .../Captcha/CaptchaProviderInterface.php | 18 + .../Captcha/HcaptchaCaptchaProvider.php | 69 +++ .../Captcha/RecaptchaCaptchaProvider.php | 69 +++ .../Captcha/TurnstileCaptchaProvider.php | 69 +++ app/Services/Security/CaptchaVerifier.php | 71 +++ app/Services/Security/TurnstileVerifier.php | 45 +- bootstrap/app.php | 10 + config/forum_bot_protection.php | 147 ++++++ config/forum_security.php | 65 +++ config/services.php | 11 + config/skinbase_ai_moderation.php | 132 +++++ deploy/supervisor/skinbase-queue.conf | 2 +- deploy/systemd/skinbase-queue.service | 2 +- docs/forum-bot-protection.md | 208 ++++++++ resources/js/Pages/Settings/ProfileEdit.jsx | 197 ++++++- .../js/components/security/TurnstileField.jsx | 165 ++++++ resources/js/lib/security/botFingerprint.js | 65 +++ resources/views/auth/login.blade.php | 30 +- resources/views/auth/register.blade.php | 30 +- .../partials/bot-fingerprint-script.blade.php | 55 ++ routes/api.php | 46 +- routes/auth.php | 5 +- routes/console.php | 25 +- routes/web.php | 25 +- tests/Unit/AccountFarmDetectorTest.php | 95 ++++ tests/Unit/BotRiskScorerTest.php | 52 ++ tests/Unit/ForumRateLimitMiddlewareTest.php | 166 ++++++ tests/Unit/ForumRateLimitRouteTest.php | 482 ++++++++++++++++++ tests/Unit/GeoBehaviorAnalyzerTest.php | 83 +++ tests/Unit/IPReputationServiceTest.php | 70 +++ 40 files changed, 2849 insertions(+), 108 deletions(-) create mode 100644 app/Http/Middleware/ForumAIModerationMiddleware.php create mode 100644 app/Http/Middleware/ForumBotProtectionMiddleware.php create mode 100644 app/Http/Middleware/ForumRateLimitMiddleware.php create mode 100644 app/Http/Middleware/ForumSecurityFirewallMiddleware.php create mode 100644 app/Http/Middleware/ForumSpamDetectionMiddleware.php create mode 100644 app/Services/Security/Captcha/CaptchaProviderInterface.php create mode 100644 app/Services/Security/Captcha/HcaptchaCaptchaProvider.php create mode 100644 app/Services/Security/Captcha/RecaptchaCaptchaProvider.php create mode 100644 app/Services/Security/Captcha/TurnstileCaptchaProvider.php create mode 100644 app/Services/Security/CaptchaVerifier.php create mode 100644 config/forum_bot_protection.php create mode 100644 config/forum_security.php create mode 100644 config/skinbase_ai_moderation.php create mode 100644 docs/forum-bot-protection.md create mode 100644 resources/js/components/security/TurnstileField.jsx create mode 100644 resources/js/lib/security/botFingerprint.js create mode 100644 resources/views/partials/bot-fingerprint-script.blade.php create mode 100644 tests/Unit/AccountFarmDetectorTest.php create mode 100644 tests/Unit/BotRiskScorerTest.php create mode 100644 tests/Unit/ForumRateLimitMiddlewareTest.php create mode 100644 tests/Unit/ForumRateLimitRouteTest.php create mode 100644 tests/Unit/GeoBehaviorAnalyzerTest.php create mode 100644 tests/Unit/IPReputationServiceTest.php diff --git a/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index 45ffdad6..3dac1cf5 100644 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Services\Security\CaptchaVerifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -14,9 +15,17 @@ class AuthenticatedSessionController extends Controller /** * Display the login view. */ + public function __construct( + private readonly CaptchaVerifier $captchaVerifier, + ) { + } + public function create(): View { - return view('auth.login'); + return view('auth.login', [ + 'requiresCaptcha' => session('bot_captcha_required', false), + 'captcha' => $this->captchaVerifier->frontendConfig(), + ]); } /** diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 76c9ea65..56454daa 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -8,7 +8,7 @@ use App\Models\EmailSendEvent; use App\Models\User; use App\Services\Auth\DisposableEmailService; use App\Services\Auth\RegistrationVerificationTokenService; -use App\Services\Security\TurnstileVerifier; +use App\Services\Security\CaptchaVerifier; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -19,7 +19,7 @@ use Illuminate\View\View; class RegisteredUserController extends Controller { public function __construct( - private readonly TurnstileVerifier $turnstileVerifier, + private readonly CaptchaVerifier $captchaVerifier, private readonly DisposableEmailService $disposableEmailService, private readonly RegistrationVerificationTokenService $verificationTokenService, ) @@ -33,8 +33,8 @@ class RegisteredUserController extends Controller { return view('auth.register', [ 'prefillEmail' => (string) $request->query('email', ''), - 'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()), - 'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''), + 'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()), + 'captcha' => $this->captchaVerifier->frontendConfig(), ]); } @@ -56,20 +56,22 @@ class RegisteredUserController extends Controller */ public function store(Request $request): RedirectResponse { - $validated = $request->validate([ + $rules = [ 'email' => ['required', 'string', 'lowercase', 'email', 'max:255'], 'website' => ['nullable', 'max:0'], - 'cf-turnstile-response' => ['nullable', 'string'], - ]); + ]; + $rules[$this->captchaVerifier->inputName()] = ['nullable', 'string']; + + $validated = $request->validate($rules); $email = strtolower(trim((string) $validated['email'])); $ip = $request->ip(); $this->trackRegisterAttempt($ip); - if ($this->shouldRequireTurnstile($ip)) { - $verified = $this->turnstileVerifier->verify( - (string) $request->input('cf-turnstile-response', ''), + if ($this->shouldRequireCaptcha($ip)) { + $verified = $this->captchaVerifier->verify( + (string) $request->input($this->captchaVerifier->inputName(), ''), $ip ); @@ -199,9 +201,9 @@ class RegisteredUserController extends Controller ]); } - private function shouldRequireTurnstile(?string $ip): bool + private function shouldRequireCaptcha(?string $ip): bool { - if (! $this->turnstileVerifier->isEnabled()) { + if (! $this->captchaVerifier->isEnabled()) { return false; } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index ce3472d0..ba555aa8 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -17,6 +17,7 @@ use App\Models\Artwork; use App\Models\ProfileComment; use App\Models\Story; use App\Models\User; +use App\Services\Security\CaptchaVerifier; use App\Services\AvatarService; use App\Services\ArtworkService; use App\Services\FollowService; @@ -47,6 +48,7 @@ class ProfileController extends Controller private readonly UsernameApprovalService $usernameApprovalService, private readonly FollowService $followService, private readonly UserStatsService $userStats, + private readonly CaptchaVerifier $captchaVerifier, ) { } @@ -240,7 +242,9 @@ class ProfileController extends Controller 'flash' => [ 'status' => session('status'), 'error' => session('error'), + 'botCaptchaRequired' => session('bot_captcha_required', false), ], + 'captcha' => $this->captchaVerifier->frontendConfig(), ])->rootView('settings'); } diff --git a/app/Http/Middleware/ForumAIModerationMiddleware.php b/app/Http/Middleware/ForumAIModerationMiddleware.php new file mode 100644 index 00000000..52feca9b --- /dev/null +++ b/app/Http/Middleware/ForumAIModerationMiddleware.php @@ -0,0 +1,50 @@ + $action, + 'ai_spam_score' => 0, + 'ai_toxicity_score' => 0, + 'flags' => [], + 'reasons' => [], + 'provider' => 'none', + 'available' => false, + 'raw' => null, + 'language' => null, + ]; + + $title = trim((string) $request->input('title', '')); + $content = trim((string) $request->input('content', '')); + $combinedContent = trim($title !== '' ? $title . "\n" . $content : $content); + + if ( + $combinedContent !== '' + && (bool) config('skinbase_ai_moderation.enabled', true) + && (bool) config('skinbase_ai_moderation.preflight.run_ai_sync', true) + ) { + $spamAssessment = $request->attributes->get('forum_spam_assessment'); + $assessment = ['action' => $action] + $this->aiContentModerator->analyze($combinedContent, [ + 'links' => is_array($spamAssessment) ? (array) ($spamAssessment['links'] ?? []) : [], + ]); + } + + $request->attributes->set('forum_ai_assessment', $assessment); + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/ForumBotProtectionMiddleware.php b/app/Http/Middleware/ForumBotProtectionMiddleware.php new file mode 100644 index 00000000..693cbf82 --- /dev/null +++ b/app/Http/Middleware/ForumBotProtectionMiddleware.php @@ -0,0 +1,96 @@ +botProtectionService->assess($request, $action); + $request->attributes->set('forum_bot_assessment', $assessment); + + if ($this->requiresCaptcha($assessment, $action)) { + $captcha = $this->captchaVerifier->frontendConfig(); + $tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName()); + $token = (string) ( + $request->input($tokenInput) + ?: $request->header('X-Captcha-Token', '') + ?: $request->header('X-Turnstile-Token', '') + ); + + if (! $this->captchaVerifier->verify($token, $request->ip())) { + $message = (string) config('forum_bot_protection.captcha.message', 'Complete the captcha challenge to continue.'); + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'captcha' => [$message], + ], + 'requires_captcha' => true, + 'captcha' => $captcha, + 'captcha_provider' => (string) ($captcha['provider'] ?? $this->captchaVerifier->provider()), + 'captcha_site_key' => (string) ($captcha['siteKey'] ?? ''), + 'captcha_input' => (string) ($captcha['inputName'] ?? $tokenInput), + 'captcha_script_url' => (string) ($captcha['scriptUrl'] ?? ''), + ], 422); + } + + return redirect()->back() + ->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput])) + ->withErrors(['captcha' => $message]) + ->with('bot_captcha_required', true) + ->with('bot_turnstile_required', true); + } + + $request->attributes->set('forum_bot_captcha_verified', true); + } + + if ((bool) ($assessment['blocked'] ?? false)) { + $message = 'Suspicious activity detected.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'bot' => [$message], + ], + ], 429); + } + + throw ValidationException::withMessages([ + 'bot' => [$message], + ]); + } + + return $next($request); + } + + private function requiresCaptcha(array $assessment, string $action): bool + { + if (! $this->captchaVerifier->isEnabled()) { + return false; + } + + if ((int) ($assessment['risk_score'] ?? 0) < (int) config('forum_bot_protection.thresholds.captcha', 60)) { + return false; + } + + return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true); + } +} diff --git a/app/Http/Middleware/ForumRateLimitMiddleware.php b/app/Http/Middleware/ForumRateLimitMiddleware.php new file mode 100644 index 00000000..1f4068be --- /dev/null +++ b/app/Http/Middleware/ForumRateLimitMiddleware.php @@ -0,0 +1,70 @@ +route())->getName(); + $limiterName = match ($routeName) { + 'forum.topic.store' => 'forum-thread-create', + default => 'forum-post-write', + }; + + try { + return $this->throttleRequests->handle($request, $next, $limiterName); + } catch (ThrottleRequestsException $exception) { + $maxAttempts = (int) ($exception->getHeaders()['X-RateLimit-Limit'] ?? 0); + + $this->botProtectionService->recordRateLimitViolation( + $request, + $this->resolveActionName($routeName), + [ + 'limiter' => $limiterName, + 'bucket' => $this->resolveBucket($limiterName, $maxAttempts), + 'max_attempts' => $maxAttempts, + 'retry_after' => (int) ($exception->getHeaders()['Retry-After'] ?? 0), + 'reason' => sprintf('Forum write rate limit exceeded on %s.', $routeName !== '' ? $routeName : 'unknown route'), + ], + ); + + throw $exception; + } + } + + private function resolveActionName(string $routeName): string + { + return match ($routeName) { + 'forum.topic.store' => 'forum_topic_create', + 'forum.post.update' => 'forum_post_update', + default => 'forum_reply_create', + }; + } + + private function resolveBucket(string $limiterName, int $maxAttempts): string + { + return $maxAttempts <= $this->minuteLimitThreshold($limiterName) ? 'minute' : 'hour'; + } + + private function minuteLimitThreshold(string $limiterName): int + { + return match ($limiterName) { + 'forum-thread-create', 'forum-post-write' => 3, + default => PHP_INT_MAX, + }; + } +} diff --git a/app/Http/Middleware/ForumSecurityFirewallMiddleware.php b/app/Http/Middleware/ForumSecurityFirewallMiddleware.php new file mode 100644 index 00000000..fbac864a --- /dev/null +++ b/app/Http/Middleware/ForumSecurityFirewallMiddleware.php @@ -0,0 +1,90 @@ +firewallService->assess($request, $action); + $request->attributes->set('forum_firewall_assessment', $assessment); + + if ($this->requiresCaptcha($assessment, $action)) { + $captcha = $this->captchaVerifier->frontendConfig(); + $tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName()); + $token = (string) ( + $request->input($tokenInput) + ?: $request->header('X-Captcha-Token', '') + ?: $request->header('X-Turnstile-Token', '') + ); + + if (! $this->captchaVerifier->verify($token, $request->ip())) { + $message = 'Additional verification is required before continuing.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'captcha' => [$message], + ], + 'requires_captcha' => true, + 'captcha' => $captcha, + ], 422); + } + + return redirect()->back() + ->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput])) + ->withErrors(['captcha' => $message]); + } + + $request->attributes->set('forum_firewall_captcha_verified', true); + } + + if ((bool) ($assessment['blocked'] ?? false)) { + $message = 'Security firewall blocked this request.'; + + if ($request->expectsJson()) { + return response()->json([ + 'message' => $message, + 'errors' => [ + 'security' => [$message], + ], + ], 429); + } + + throw ValidationException::withMessages([ + 'security' => [$message], + ]); + } + + return $next($request); + } + + private function requiresCaptcha(array $assessment, string $action): bool + { + if (! $this->captchaVerifier->isEnabled()) { + return false; + } + + if (! (bool) ($assessment['requires_captcha'] ?? false)) { + return false; + } + + return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true); + } +} \ No newline at end of file diff --git a/app/Http/Middleware/ForumSpamDetectionMiddleware.php b/app/Http/Middleware/ForumSpamDetectionMiddleware.php new file mode 100644 index 00000000..70c940f9 --- /dev/null +++ b/app/Http/Middleware/ForumSpamDetectionMiddleware.php @@ -0,0 +1,66 @@ +input('title', '')); + $content = trim((string) $request->input('content', '')); + $combinedContent = trim($title !== '' ? $title . "\n" . $content : $content); + + if ($combinedContent === '') { + $request->attributes->set('forum_spam_assessment', [ + 'action' => $action, + 'spam_score' => 0, + 'spam_reasons' => [], + 'link_score' => 0, + 'link_reasons' => [], + 'links' => [], + 'domains' => [], + 'pattern_score' => 0, + 'pattern_reasons' => [], + 'matched_categories' => [], + ]); + + return $next($request); + } + + $spam = $this->spamDetector->analyze($combinedContent); + $link = $this->linkAnalyzer->analyze($combinedContent); + $patterns = $this->contentPatternAnalyzer->analyze($combinedContent); + + $request->attributes->set('forum_spam_assessment', [ + 'action' => $action, + 'spam_score' => max((int) ($spam['score'] ?? 0), (int) ($patterns['score'] ?? 0)), + 'spam_reasons' => array_values(array_unique(array_merge( + (array) ($spam['reasons'] ?? []), + (array) ($patterns['reasons'] ?? []), + ))), + 'link_score' => (int) ($link['score'] ?? 0), + 'link_reasons' => (array) ($link['reasons'] ?? []), + 'links' => (array) ($link['links'] ?? []), + 'domains' => (array) ($link['domains'] ?? []), + 'pattern_score' => (int) ($patterns['score'] ?? 0), + 'pattern_reasons' => (array) ($patterns['reasons'] ?? []), + 'matched_categories' => (array) ($patterns['matched_categories'] ?? []), + ]); + + return $next($request); + } +} \ No newline at end of file diff --git a/app/Models/ForumPost.php b/app/Models/ForumPost.php index 2475b440..61c94e1f 100644 --- a/app/Models/ForumPost.php +++ b/app/Models/ForumPost.php @@ -18,15 +18,28 @@ class ForumPost extends Model 'id', 'thread_id', 'topic_id', + 'source_ip_hash', 'user_id', 'content', + 'content_hash', 'is_edited', 'edited_at', 'spam_score', 'quality_score', + 'ai_spam_score', + 'ai_toxicity_score', + 'behavior_score', + 'link_score', + 'learning_score', + 'risk_score', + 'trust_modifier', 'flagged', 'flagged_reason', 'moderation_checked', + 'moderation_status', + 'moderation_labels', + 'moderation_meta', + 'last_ai_scan_at', ]; public $incrementing = true; @@ -36,8 +49,18 @@ class ForumPost extends Model 'edited_at' => 'datetime', 'spam_score' => 'integer', 'quality_score' => 'integer', + 'ai_spam_score' => 'integer', + 'ai_toxicity_score' => 'integer', + 'behavior_score' => 'integer', + 'link_score' => 'integer', + 'learning_score' => 'integer', + 'risk_score' => 'integer', + 'trust_modifier' => 'integer', 'flagged' => 'boolean', 'moderation_checked' => 'boolean', + 'moderation_labels' => 'array', + 'moderation_meta' => 'array', + 'last_ai_scan_at' => 'datetime', ]; public function thread(): BelongsTo diff --git a/app/Models/User.php b/app/Models/User.php index a4ce12dd..205e7dc5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -44,6 +44,12 @@ class User extends Authenticatable 'cover_ext', 'cover_position', 'trust_score', + 'bot_risk_score', + 'bot_flags', + 'last_bot_activity_at', + 'spam_reports', + 'approved_posts', + 'flagged_posts', 'password', 'role', 'allow_messages_from', @@ -76,6 +82,12 @@ class User extends Authenticatable 'deleted_at' => 'datetime', 'cover_position' => 'integer', 'trust_score' => 'integer', + 'bot_risk_score' => 'integer', + 'bot_flags' => 'array', + 'last_bot_activity_at' => 'datetime', + 'spam_reports' => 'integer', + 'approved_posts' => 'integer', + 'flagged_posts' => 'integer', 'password' => 'hashed', 'allow_messages_from' => 'string', ]; diff --git a/app/Services/Security/Captcha/CaptchaProviderInterface.php b/app/Services/Security/Captcha/CaptchaProviderInterface.php new file mode 100644 index 00000000..4b97d172 --- /dev/null +++ b/app/Services/Security/Captcha/CaptchaProviderInterface.php @@ -0,0 +1,18 @@ +siteKey() !== '' + && (string) config('services.hcaptcha.secret', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.hcaptcha.site_key', ''); + } + + public function inputName(): string + { + return 'h-captcha-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.hcaptcha.script_url', 'https://js.hcaptcha.com/1/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.hcaptcha.timeout', 5)) + ->post((string) config('services.hcaptcha.verify_url', 'https://hcaptcha.com/siteverify'), [ + 'secret' => (string) config('services.hcaptcha.secret', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('hcaptcha verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php b/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php new file mode 100644 index 00000000..146172c6 --- /dev/null +++ b/app/Services/Security/Captcha/RecaptchaCaptchaProvider.php @@ -0,0 +1,69 @@ +siteKey() !== '' + && (string) config('services.recaptcha.secret', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.recaptcha.site_key', ''); + } + + public function inputName(): string + { + return 'g-recaptcha-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.recaptcha.script_url', 'https://www.google.com/recaptcha/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.recaptcha.timeout', 5)) + ->post((string) config('services.recaptcha.verify_url', 'https://www.google.com/recaptcha/api/siteverify'), [ + 'secret' => (string) config('services.recaptcha.secret', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('recaptcha verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/Captcha/TurnstileCaptchaProvider.php b/app/Services/Security/Captcha/TurnstileCaptchaProvider.php new file mode 100644 index 00000000..f286610a --- /dev/null +++ b/app/Services/Security/Captcha/TurnstileCaptchaProvider.php @@ -0,0 +1,69 @@ +siteKey() !== '' + && (string) config('services.turnstile.secret_key', '') !== ''; + } + + public function siteKey(): string + { + return (string) config('services.turnstile.site_key', ''); + } + + public function inputName(): string + { + return 'cf-turnstile-response'; + } + + public function scriptUrl(): string + { + return (string) config('services.turnstile.script_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js'); + } + + public function verify(string $token, ?string $ip = null): bool + { + if (! $this->isEnabled()) { + return true; + } + + if (trim($token) === '') { + return false; + } + + try { + $response = Http::asForm() + ->timeout((int) config('services.turnstile.timeout', 5)) + ->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [ + 'secret' => (string) config('services.turnstile.secret_key', ''), + 'response' => $token, + 'remoteip' => $ip, + ]); + + if ($response->failed()) { + return false; + } + + return (bool) data_get($response->json(), 'success', false); + } catch (\Throwable $exception) { + Log::warning('turnstile verification request failed', [ + 'message' => $exception->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/Security/CaptchaVerifier.php b/app/Services/Security/CaptchaVerifier.php new file mode 100644 index 00000000..bcdbf699 --- /dev/null +++ b/app/Services/Security/CaptchaVerifier.php @@ -0,0 +1,71 @@ + 'recaptcha', + 'hcaptcha' => 'hcaptcha', + default => 'turnstile', + }; + } + + public function isEnabled(): bool + { + return $this->resolveProvider()->isEnabled(); + } + + public function inputName(): string + { + $configured = trim((string) config('forum_bot_protection.captcha.input', '')); + + if ($configured !== '') { + return $configured; + } + + return $this->resolveProvider()->inputName(); + } + + public function verify(string $token, ?string $ip = null): bool + { + return $this->resolveProvider()->verify($token, $ip); + } + + public function frontendConfig(): array + { + $provider = $this->resolveProvider(); + + return [ + 'provider' => $provider->name(), + 'siteKey' => $provider->isEnabled() ? $provider->siteKey() : '', + 'inputName' => $this->inputName(), + 'scriptUrl' => $provider->isEnabled() ? $provider->scriptUrl() : '', + ]; + } + + private function resolveProvider(): CaptchaProviderInterface + { + return match ($this->provider()) { + 'recaptcha' => $this->recaptchaProvider, + 'hcaptcha' => $this->hcaptchaProvider, + default => $this->turnstileProvider, + }; + } +} diff --git a/app/Services/Security/TurnstileVerifier.php b/app/Services/Security/TurnstileVerifier.php index 05965852..29cd24a4 100644 --- a/app/Services/Security/TurnstileVerifier.php +++ b/app/Services/Security/TurnstileVerifier.php @@ -2,50 +2,21 @@ namespace App\Services\Security; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; - class TurnstileVerifier { + public function __construct( + private readonly CaptchaVerifier $captchaVerifier, + ) { + } + public function isEnabled(): bool { - return (bool) config('registration.enable_turnstile', true) - && (string) config('services.turnstile.site_key', '') !== '' - && (string) config('services.turnstile.secret_key', '') !== ''; + return $this->captchaVerifier->provider() === 'turnstile' + && $this->captchaVerifier->isEnabled(); } public function verify(string $token, ?string $ip = null): bool { - if (! $this->isEnabled()) { - return true; - } - - if (trim($token) === '') { - return false; - } - - try { - $response = Http::asForm() - ->timeout((int) config('services.turnstile.timeout', 5)) - ->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [ - 'secret' => (string) config('services.turnstile.secret_key', ''), - 'response' => $token, - 'remoteip' => $ip, - ]); - - if ($response->failed()) { - return false; - } - - $payload = $response->json(); - - return (bool) data_get($payload, 'success', false); - } catch (\Throwable $exception) { - Log::warning('turnstile verification request failed', [ - 'message' => $exception->getMessage(), - ]); - - return false; - } + return $this->captchaVerifier->verify($token, $ip); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index 81db6687..33988007 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,11 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'chat_post', + 'chat_post/*', + ]); + $middleware->web(append: [ \App\Http\Middleware\HandleInertiaRequests::class, // Runs on every web request; no-ops for guests, redirects authenticated @@ -23,6 +28,11 @@ return Application::configure(basePath: dirname(__DIR__)) 'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class, 'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class, 'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class, + 'forum.ai.moderation' => \App\Http\Middleware\ForumAIModerationMiddleware::class, + 'forum.bot.protection' => \App\Http\Middleware\ForumBotProtectionMiddleware::class, + 'forum.spam.detection' => \App\Http\Middleware\ForumSpamDetectionMiddleware::class, + 'forum.security.firewall' => \App\Http\Middleware\ForumSecurityFirewallMiddleware::class, + 'forum.rate_limit' => \App\Http\Middleware\ForumRateLimitMiddleware::class, 'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class, 'normalize.username' => \App\Http\Middleware\NormalizeUsername::class, ]); diff --git a/config/forum_bot_protection.php b/config/forum_bot_protection.php new file mode 100644 index 00000000..e900a164 --- /dev/null +++ b/config/forum_bot_protection.php @@ -0,0 +1,147 @@ + env('FORUM_BOT_PROTECTION_ENABLED', true), + + 'thresholds' => [ + 'allow' => 20, + 'log' => 20, + 'captcha' => 40, + 'moderate' => 60, + 'block' => 80, + ], + + 'honeypots' => [ + 'fields' => ['homepage_url', 'company_name'], + 'penalty' => 60, + ], + + 'captcha' => [ + 'provider' => env('FORUM_BOT_CAPTCHA_PROVIDER', 'turnstile'), + 'actions' => [ + 'register', + 'login', + 'forum_topic_create', + 'forum_reply_create', + 'forum_post_update', + 'profile_update', + 'api_write', + ], + 'input' => env('FORUM_BOT_CAPTCHA_INPUT', ''), + 'message' => 'Complete the captcha challenge to continue.', + ], + + 'behavior' => [ + 'new_account_days' => 7, + 'rapid_post_window_minutes' => 1, + 'rapid_post_threshold' => 5, + 'rapid_thread_threshold' => 2, + 'recent_action_window_seconds' => 45, + 'recent_action_threshold' => 6, + 'login_attempt_window_minutes' => 10, + 'login_attempt_threshold' => 8, + 'profile_update_threshold' => 6, + 'profile_update_window_minutes' => 60, + 'api_request_window_minutes' => 1, + 'api_request_threshold' => 100, + 'repeated_content_penalty' => 50, + 'new_account_links_penalty' => 30, + 'rapid_post_penalty' => 40, + 'recent_action_penalty' => 40, + 'login_burst_penalty' => 35, + 'profile_burst_penalty' => 20, + 'api_burst_penalty' => 60, + ], + + 'account_farm' => [ + 'window_minutes' => 10, + 'register_attempt_threshold' => 10, + 'same_ip_users_threshold' => 5, + 'same_fingerprint_users_threshold' => 3, + 'same_pattern_users_threshold' => 3, + 'register_attempt_penalty' => 50, + 'same_ip_penalty' => 35, + 'same_fingerprint_penalty' => 40, + 'same_pattern_penalty' => 45, + ], + + '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' => [], + 'datacenter_ranges' => [], + 'provider_ranges' => [ + 'aws' => [], + 'azure' => [], + 'gcp' => [], + 'digitalocean' => [], + 'hetzner' => [], + 'ovh' => [], + ], + 'tor_exit_nodes' => [], + ], + + 'rate_limits' => [ + 'penalties' => [ + 'default' => 35, + 'minute' => 35, + 'hour' => 45, + ], + ], + + 'geo_behavior' => [ + 'enabled' => true, + 'login_actions' => ['login'], + 'country_headers' => [ + 'CF-IPCountry', + 'CloudFront-Viewer-Country', + 'X-Country-Code', + 'X-App-Country-Code', + ], + 'recent_login_window_minutes' => 60, + 'country_change_penalty' => 50, + ], + + 'patterns' => [ + 'seo' => [ + 'best seo service', + 'cheap backlinks', + 'guaranteed traffic', + 'rank your website', + ], + 'casino' => [ + 'online casino', + 'jackpot bonus', + 'slot machine', + 'betting tips', + ], + 'crypto' => [ + 'crypto signal', + 'double your bitcoin', + 'guaranteed profit', + 'token presale', + ], + 'affiliate' => [ + 'affiliate link', + 'promo code', + 'limited offer', + 'work from home', + ], + 'repeated_phrase_penalty' => 40, + 'category_penalty' => 30, + ], + + 'scan' => [ + 'lookback_minutes' => 5, + 'auto_blacklist_attempts' => 10, + 'auto_blacklist_risk' => 80, + 'auto_blacklist_reason' => 'Automatically blacklisted by bot activity monitor.', + 'queue' => env('FORUM_BOT_SCAN_QUEUE', 'forum-moderation'), + ], +]; diff --git a/config/forum_security.php b/config/forum_security.php new file mode 100644 index 00000000..03b88c96 --- /dev/null +++ b/config/forum_security.php @@ -0,0 +1,65 @@ + env('FORUM_SECURITY_ENABLED', true), + + 'thresholds' => [ + 'safe' => 20, + 'log' => 20, + 'captcha' => 40, + 'moderate' => 60, + 'block' => 80, + 'firewall_block' => 70, + ], + + 'queues' => [ + 'moderation' => env('FORUM_SECURITY_MODERATION_QUEUE', 'forum-moderation'), + 'firewall' => env('FORUM_SECURITY_FIREWALL_QUEUE', 'forum-security'), + ], + + 'firewall' => [ + 'enabled' => true, + 'request_pattern' => [ + 'window_seconds' => 60, + 'burst_threshold' => 15, + 'burst_penalty' => 25, + 'missing_user_agent_penalty' => 10, + 'suspicious_path_penalty' => 20, + 'repeat_route_penalty' => 20, + ], + 'spam_wave' => [ + 'window_minutes' => 15, + 'same_hash_threshold' => 3, + 'same_hash_penalty' => 30, + 'same_ip_flagged_threshold' => 4, + 'same_ip_flagged_penalty' => 25, + 'same_signature_threshold' => 3, + 'same_signature_penalty' => 20, + ], + 'thread_attack' => [ + 'window_minutes' => 10, + 'topic_threshold' => 4, + 'reply_threshold' => 8, + 'topic_penalty' => 25, + 'reply_penalty' => 20, + ], + 'login_attack' => [ + 'window_minutes' => 15, + 'login_threshold' => 10, + 'register_threshold' => 6, + 'login_penalty' => 30, + 'register_penalty' => 35, + ], + 'scan' => [ + 'lookback_minutes' => 15, + 'auto_blacklist_attempts' => 4, + 'auto_blacklist_risk' => 70, + 'auto_blacklist_reason' => 'Automatically blacklisted by forum firewall activity monitor.', + ], + ], + + 'logging' => [ + 'store_request_payload' => false, + 'reason_limit' => 8, + ], +]; \ No newline at end of file diff --git a/config/services.php b/config/services.php index edd4a86f..b2c15a0d 100644 --- a/config/services.php +++ b/config/services.php @@ -43,13 +43,24 @@ return [ 'enabled' => env('RECAPTCHA_ENABLED', false), 'site_key' => env('RECAPTCHA_SITE_KEY'), 'secret' => env('RECAPTCHA_SECRET_KEY'), + 'script_url' => env('RECAPTCHA_SCRIPT_URL', 'https://www.google.com/recaptcha/api.js'), 'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'), 'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5), ], + 'hcaptcha' => [ + 'enabled' => env('HCAPTCHA_ENABLED', false), + 'site_key' => env('HCAPTCHA_SITE_KEY'), + 'secret' => env('HCAPTCHA_SECRET_KEY'), + 'script_url' => env('HCAPTCHA_SCRIPT_URL', 'https://js.hcaptcha.com/1/api.js'), + 'verify_url' => env('HCAPTCHA_VERIFY_URL', 'https://hcaptcha.com/siteverify'), + 'timeout' => (int) env('HCAPTCHA_TIMEOUT', 5), + ], + 'turnstile' => [ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), + 'script_url' => env('TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'), 'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), 'timeout' => (int) env('TURNSTILE_TIMEOUT', 5), ], diff --git a/config/skinbase_ai_moderation.php b/config/skinbase_ai_moderation.php new file mode 100644 index 00000000..a3b61a86 --- /dev/null +++ b/config/skinbase_ai_moderation.php @@ -0,0 +1,132 @@ + env('SKINBASE_AI_MODERATION_ENABLED', true), + + 'provider' => env('SKINBASE_AI_MODERATION_PROVIDER', 'openai'), + + 'queue' => [ + 'name' => env('SKINBASE_AI_MODERATION_QUEUE', 'forum-moderation'), + ], + + 'preflight' => [ + 'run_ai_sync' => (bool) env('SKINBASE_AI_PREFLIGHT_SYNC', true), + ], + + 'thresholds' => [ + 'safe' => 20, + 'low_quality' => 40, + 'suspicious' => 60, + 'block' => 80, + ], + + 'behavior' => [ + 'new_account_days' => 7, + 'rapid_post_window_minutes' => 2, + 'rapid_post_threshold' => 5, + 'same_ip_window_days' => 7, + 'same_ip_accounts_threshold' => 2, + 'repeat_content_penalty' => 40, + 'new_account_with_links_penalty' => 30, + 'rapid_post_penalty' => 20, + 'same_ip_penalty' => 25, + 'high_link_frequency_penalty' => 10, + 'flagged_history_penalty' => 15, + ], + + 'links' => [ + 'too_many_links_penalty' => 15, + 'suspicious_domain_penalty' => 40, + 'shortener_penalty' => 10, + 'suspicious_tld_penalty' => 15, + 'too_many_links_threshold' => 3, + 'shorteners' => [ + 'bit.ly', + 'tinyurl.com', + 'goo.gl', + 't.co', + 'ow.ly', + 'cutt.ly', + 'rebrand.ly', + ], + 'suspicious_tlds' => [ + 'xyz', + 'top', + 'click', + 'loan', + 'work', + 'gq', + 'ml', + 'tk', + ], + ], + + 'trust' => [ + 'high' => 80, + 'medium' => 50, + 'high_modifier' => -15, + 'medium_modifier' => -8, + 'low_modifier' => 0, + 'flagged_ratio_penalty' => 10, + ], + + 'learning' => [ + 'spam_penalty' => 40, + 'safe_modifier' => -12, + 'max_spam_penalty' => 60, + 'max_safe_modifier' => -20, + ], + + 'scan' => [ + 'limit' => 200, + 'stale_after_minutes' => 10, + ], + + 'privacy' => [ + 'redact_emails' => true, + 'redact_ip_addresses' => true, + 'redact_mentions' => false, + ], + + 'heuristics' => [ + 'promotional_phrases' => [ + 'buy now', + 'limited offer', + 'cheap seo', + 'guaranteed traffic', + 'visit my profile', + 'work from home', + 'crypto signal', + 'telegram me', + 'whatsapp me', + 'dm for service', + ], + 'toxic_phrases' => [ + 'kill yourself', + 'you idiot', + 'piece of trash', + 'hate you', + 'worthless', + ], + ], + + 'providers' => [ + 'openai' => [ + 'api_key' => env('OPENAI_API_KEY'), + 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'), + 'model' => env('SKINBASE_AI_OPENAI_MODEL', 'gpt-4.1-mini'), + 'timeout' => (int) env('SKINBASE_AI_OPENAI_TIMEOUT', 5), + ], + 'perspective_api' => [ + 'api_key' => env('PERSPECTIVE_API_KEY'), + 'base_url' => env('PERSPECTIVE_API_BASE_URL', 'https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze'), + 'timeout' => (int) env('SKINBASE_AI_PERSPECTIVE_TIMEOUT', 5), + ], + 'local_llm' => [ + 'endpoint' => env('SKINBASE_AI_LOCAL_LLM_ENDPOINT'), + 'model' => env('SKINBASE_AI_LOCAL_LLM_MODEL', 'moderation'), + 'timeout' => (int) env('SKINBASE_AI_LOCAL_LLM_TIMEOUT', 5), + 'token' => env('SKINBASE_AI_LOCAL_LLM_TOKEN'), + ], + ], +]; diff --git a/deploy/supervisor/skinbase-queue.conf b/deploy/supervisor/skinbase-queue.conf index b9f9e868..d04206d3 100644 --- a/deploy/supervisor/skinbase-queue.conf +++ b/deploy/supervisor/skinbase-queue.conf @@ -1,5 +1,5 @@ [program:skinbase-queue] -command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default process_name=%(program_name)s_%(process_num)02d numprocs=1 autostart=true diff --git a/deploy/systemd/skinbase-queue.service b/deploy/systemd/skinbase-queue.service index 3dadbbd8..dd91e233 100644 --- a/deploy/systemd/skinbase-queue.service +++ b/deploy/systemd/skinbase-queue.service @@ -8,7 +8,7 @@ Group=www-data Restart=always RestartSec=3 WorkingDirectory=/var/www/skinbase -ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default +ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default StandardOutput=syslog StandardError=syslog SyslogIdentifier=skinbase-queue diff --git a/docs/forum-bot-protection.md b/docs/forum-bot-protection.md new file mode 100644 index 00000000..35426d14 --- /dev/null +++ b/docs/forum-bot-protection.md @@ -0,0 +1,208 @@ +# Forum Bot Protection + +This document describes the production anti-bot stack protecting forum, auth, profile, and selected API write actions. + +## Scope + +Primary implementation lives in: + +- `config/forum_bot_protection.php` +- `packages/klevze/Plugins/Forum/Services/Security` +- `app/Http/Middleware/ForumBotProtectionMiddleware.php` +- `packages/klevze/Plugins/Forum/Console/ForumBotScanCommand.php` +- `packages/klevze/Plugins/Forum/Jobs/BotActivityMonitor.php` + +Protected actions currently include: + +- registration +- login +- forum topic create +- forum reply create +- forum post update +- profile update +- selected API write routes + +## Detection Layers + +Risk scoring combines multiple signals: + +- honeypot hits +- browser and device fingerprints +- repeated content and spam phrase analysis +- account age and action burst behavior +- proxy, Tor, and blacklist checks +- provider and datacenter CIDR range checks +- account farm heuristics across IP and fingerprint reuse + +The score is interpreted through `config/forum_bot_protection.php`: + +- `allow` +- `log` +- `captcha` +- `moderate` +- `block` + +## Persistence + +Bot activity is stored in: + +- `forum_bot_logs` +- `forum_bot_ip_blacklist` +- `forum_bot_device_fingerprints` +- `forum_bot_behavior_profiles` + +User records also carry: + +- `bot_risk_score` +- `bot_flags` +- `last_bot_activity_at` + +## Captcha Escalation + +When a request risk score reaches the configured captcha threshold, middleware requires a provider-backed challenge before allowing the action. + +Provider selection: + +- `FORUM_BOT_CAPTCHA_PROVIDER=turnstile` +- `FORUM_BOT_CAPTCHA_PROVIDER=recaptcha` +- `FORUM_BOT_CAPTCHA_PROVIDER=hcaptcha` + +Optional request input override: + +- `FORUM_BOT_CAPTCHA_INPUT` + +Supported provider environment keys: + +### Turnstile + +- `TURNSTILE_SITE_KEY` +- `TURNSTILE_SECRET_KEY` +- `TURNSTILE_SCRIPT_URL` +- `TURNSTILE_VERIFY_URL` + +### reCAPTCHA + +- `RECAPTCHA_ENABLED` +- `RECAPTCHA_SITE_KEY` +- `RECAPTCHA_SECRET_KEY` +- `RECAPTCHA_SCRIPT_URL` +- `RECAPTCHA_VERIFY_URL` + +### hCaptcha + +- `HCAPTCHA_ENABLED` +- `HCAPTCHA_SITE_KEY` +- `HCAPTCHA_SECRET_KEY` +- `HCAPTCHA_SCRIPT_URL` +- `HCAPTCHA_VERIFY_URL` + +If the selected provider is missing required keys, captcha escalation is effectively disabled and high-risk requests will continue through the non-captcha anti-bot path. + +## Origin Header Setup + +Geo-behavior scoring only activates when the origin receives a trusted two-letter country header. The current analyzer checks these headers in order: + +- `CF-IPCountry` +- `CloudFront-Viewer-Country` +- `X-Country-Code` +- `X-App-Country-Code` + +Recommended production setup: + +### Cloudflare + +- If you only need country detection: Cloudflare Dashboard → `Network` → turn `IP Geolocation` on. +- If you want the broader location header set: Cloudflare Dashboard → `Rules` → `Managed Transforms` → enable `Add visitor location headers`. +- The origin header used by this app is `CF-IPCountry`. + +### Amazon CloudFront + +- Edit the distribution behavior used for the app origin. +- Attach an origin request policy that includes geolocation headers, or create a custom origin request policy that forwards `CloudFront-Viewer-Country`. +- If you cache on that behavior and want cache variation by forwarded headers, ensure the paired cache policy is compatible with the origin request policy you choose. + +### Reverse Proxy / Load Balancer + +- Pass the CDN country header through unchanged to PHP-FPM / Laravel. +- For Nginx, avoid clearing the header and explicitly preserve it if you normalize upstream headers, for example: `proxy_set_header CF-IPCountry $http_cf_ipcountry;` or `proxy_set_header CloudFront-Viewer-Country $http_cloudfront_viewer_country;`. +- If you terminate the CDN header at the proxy and want a normalized application header instead, map it to `X-Country-Code` and keep the value as a two-letter ISO country code. + +Validation: + +- Send a request through the real edge and confirm the header is visible in Laravel request headers. +- Check that a login event stored in `forum_bot_logs.metadata.country_code` contains the expected country code. + +## IP Range Configuration + +IP reputation supports three types of network lists in `config/forum_bot_protection.php`: + +- `known_proxies`: exact IPs or CIDRs for proxy and VPN ranges +- `datacenter_ranges`: generic datacenter or hosting CIDRs +- `provider_ranges`: provider-specific buckets such as `aws`, `azure`, `gcp`, `digitalocean`, `hetzner`, and `ovh` + +All three lists accept either exact IP strings or CIDR notation. + +Example: + +```php +'ip' => [ + 'known_proxies' => ['198.51.100.0/24'], + 'datacenter_ranges' => ['203.0.113.0/24'], + 'provider_ranges' => [ + 'aws' => ['54.240.0.0/12'], + 'hetzner' => ['88.198.0.0/16'], + ], +], +``` + +Operational guidance: + +- keep provider ranges in the named `provider_ranges` buckets so the control panel can show per-provider coverage counts +- populate ranges only from provider-owned feeds or other trusted sources you maintain internally +- after changing CIDR lists, clear cache if you need immediate effect on hot IPs + +## Queue and Scheduling + +Recent activity scanning runs through: + +- command: `php artisan forum:bot-scan` +- queued job: `BotActivityMonitor` +- schedule: every 5 minutes in `routes/console.php` + +Default command behavior dispatches the monitor job onto the configured queue. Use `--sync` for inline execution. + +## Admin Operations + +Control panel screen: + +- route: `admin.forum.security.bot-protection.main` + +Available actions: + +- review recent bot events +- inspect suspicious users +- inspect high-risk fingerprints +- inspect recent rate-limit violations and their limiter metadata +- manually blacklist IPs +- approve or ban flagged users +- confirm current captcha provider, threshold, and required env keys +- confirm configured proxy, datacenter, tor, and provider CIDR coverage counts +- filter analytics by time window and action +- export recent bot events as CSV +- export top bot reasons as JSON + +## Validation Checklist + +Useful commands: + +- `php artisan forum:bot-scan --help` +- `php artisan forum:bot-scan --sync --minutes=5` +- `php artisan route:list --name=admin.forum.security.bot-protection.main` +- `npm run build` + +Quick runtime checks: + +- confirm new bot events land in `forum_bot_logs` +- confirm fingerprints land in `forum_bot_device_fingerprints` +- confirm the jobs table contains `BotActivityMonitor` after `forum:bot-scan` +- confirm the control panel shows the expected captcha provider and action list diff --git a/resources/js/Pages/Settings/ProfileEdit.jsx b/resources/js/Pages/Settings/ProfileEdit.jsx index 54df88ec..fbbe3264 100644 --- a/resources/js/Pages/Settings/ProfileEdit.jsx +++ b/resources/js/Pages/Settings/ProfileEdit.jsx @@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle' import Select from '../../components/ui/Select' import Modal from '../../components/ui/Modal' import { RadioGroup } from '../../components/ui/Radio' +import { buildBotFingerprint } from '../../lib/security/botFingerprint' +import TurnstileField from '../../components/security/TurnstileField' const SETTINGS_SECTIONS = [ { key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' }, @@ -57,6 +59,16 @@ function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } +async function botHeaders(extra = {}, captcha = {}) { + const fingerprint = await buildBotFingerprint() + + return { + ...extra, + 'X-Bot-Fingerprint': fingerprint, + ...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}), + } +} + function toIsoDate(day, month, year) { if (!day || !month || !year) return '' return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` @@ -122,6 +134,8 @@ export default function ProfileEdit() { usernameCooldownDays = 30, usernameCooldownRemainingDays = 0, usernameCooldownActive = false, + captcha: initialCaptcha = {}, + flash = {}, } = props const fallbackDate = toIsoDate( @@ -194,6 +208,17 @@ export default function ProfileEdit() { notifications: {}, security: {}, }) + const [captchaState, setCaptchaState] = useState({ + required: !!flash?.botCaptchaRequired, + section: '', + token: '', + message: '', + nonce: 0, + provider: initialCaptcha?.provider || '', + siteKey: initialCaptcha?.siteKey || '', + inputName: initialCaptcha?.inputName || 'cf-turnstile-response', + scriptUrl: initialCaptcha?.scriptUrl || '', + }) const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '') const [avatarFile, setAvatarFile] = useState(null) @@ -346,6 +371,92 @@ export default function ProfileEdit() { setErrorsBySection((prev) => ({ ...prev, [section]: {} })) } + const resetCaptchaState = () => { + setCaptchaState((prev) => ({ + ...prev, + required: false, + section: '', + token: '', + message: '', + nonce: prev.nonce + 1, + })) + } + + const captureCaptchaRequirement = (section, payload = {}) => { + const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha) + + if (!requiresCaptcha) { + return false + } + + const nextCaptcha = payload?.captcha || {} + const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.' + + setCaptchaState((prev) => ({ + required: true, + section, + token: '', + message, + nonce: prev.nonce + 1, + provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile', + siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '', + inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response', + scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '', + })) + + updateSectionErrors(section, { + _general: [message], + captcha: [message], + }) + + return true + } + + const applyCaptchaPayload = (payload = {}) => { + if (!captchaState.required || !captchaState.inputName) { + return payload + } + + return { + ...payload, + [captchaState.inputName]: captchaState.token || '', + } + } + + const applyCaptchaFormData = (formData) => { + if (captchaState.required && captchaState.inputName) { + formData.set(captchaState.inputName, captchaState.token || '') + } + } + + const renderCaptchaChallenge = (section, placement = 'section') => { + if (!captchaState.required || !captchaState.siteKey || activeSection !== section) { + return null + } + + if (section === 'account' && showEmailChangeModal && placement !== 'modal') { + return null + } + + if (section === 'account' && !showEmailChangeModal && placement === 'modal') { + return null + } + + return ( +
+

{captchaState.message || 'Complete the captcha challenge to continue.'}

+ setCaptchaState((prev) => ({ ...prev, token }))} + className="rounded-lg border border-white/10 bg-black/20 p-3" + /> +
+ ) + } + const switchSection = (nextSection) => { if (activeSection === nextSection) return if (dirtyMap[activeSection]) { @@ -397,19 +508,23 @@ export default function ProfileEdit() { if (avatarFile) { formData.append('avatar', avatarFile) } + applyCaptchaFormData(formData) const response = await fetch('/settings/profile/update', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, + }, captchaState), body: formData, }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('profile', payload)) { + return + } updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] }) return } @@ -421,6 +536,7 @@ export default function ProfileEdit() { setAvatarFile(null) setAvatarPosition('center') setRemoveAvatar(false) + resetCaptchaState() setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' }) } catch (error) { updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] }) @@ -446,21 +562,25 @@ export default function ProfileEdit() { const response = await fetch('/settings/account/username', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify({ username: accountForm.username }), + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('account', payload)) { + return + } updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] }) return } initialRef.current.accountForm = { ...accountForm } + resetCaptchaState() setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' }) } catch (error) { updateSectionErrors('account', { _general: ['Request failed. Please try again.'] }) @@ -478,21 +598,26 @@ export default function ProfileEdit() { const response = await fetch('/settings/email/request', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify({ new_email: emailChangeForm.new_email }), + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('account', payload)) { + setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.') + return + } setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.') return } setEmailChangeStep('verify') + resetCaptchaState() setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.') } catch (error) { setEmailChangeError('Request failed. Please try again.') @@ -510,16 +635,20 @@ export default function ProfileEdit() { const response = await fetch('/settings/email/verify', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify({ code: emailChangeForm.code }), + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('account', payload)) { + setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.') + return + } setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.') return } @@ -530,6 +659,7 @@ export default function ProfileEdit() { setShowEmailChangeModal(false) setEmailChangeStep('request') setEmailChangeForm({ new_email: '', code: '' }) + resetCaptchaState() setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' }) } catch (error) { setEmailChangeError('Request failed. Please try again.') @@ -547,25 +677,30 @@ export default function ProfileEdit() { const response = await fetch('/settings/personal/update', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify({ + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null, gender: personalForm.gender || null, country: personalForm.country || null, - }), + homepage_url: '', + })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('personal', payload)) { + return + } updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] }) return } initialRef.current.personalForm = { ...personalForm } + resetCaptchaState() setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' }) } catch (error) { updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] }) @@ -583,21 +718,25 @@ export default function ProfileEdit() { const response = await fetch('/settings/notifications/update', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify(notificationForm), + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('notifications', payload)) { + return + } updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] }) return } initialRef.current.notificationForm = { ...notificationForm } + resetCaptchaState() setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' }) } catch (error) { updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] }) @@ -615,16 +754,19 @@ export default function ProfileEdit() { const response = await fetch('/settings/security/password', { method: 'POST', credentials: 'same-origin', - headers: { + headers: await botHeaders({ 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify(securityForm), + }, captchaState), + body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })), }) const payload = await response.json().catch(() => ({})) if (!response.ok) { + if (captureCaptchaRequirement('security', payload)) { + return + } updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] }) return } @@ -634,6 +776,7 @@ export default function ProfileEdit() { new_password: '', new_password_confirmation: '', }) + resetCaptchaState() setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' }) } catch (error) { updateSectionErrors('security', { _general: ['Request failed. Please try again.'] }) @@ -857,6 +1000,8 @@ export default function ProfileEdit() { rows={3} error={errorsBySection.profile.description?.[0]} /> + + {renderCaptchaChallenge('profile')} @@ -933,6 +1078,8 @@ export default function ProfileEdit() {

You can change your username once every {usernameCooldownDays} days.

+ + {renderCaptchaChallenge('account')} ) : null} @@ -1034,6 +1181,8 @@ export default function ProfileEdit() { error={errorsBySection.personal.country?.[0]} /> )} + + {renderCaptchaChallenge('personal')} @@ -1085,6 +1234,8 @@ export default function ProfileEdit() { /> ))} + + {renderCaptchaChallenge('notifications')} @@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
Future security controls: Two-factor authentication, active sessions, and login history.
+ + {renderCaptchaChallenge('security')} @@ -1221,6 +1374,8 @@ export default function ProfileEdit() { ) : null} + {renderCaptchaChallenge('account', 'modal')} + {emailChangeStep === 'request' ? ( onToken?.(token || ''), + 'expired-callback': () => onToken?.(''), + 'error-callback': () => onToken?.(''), + }) + }, + cleanup(api, widgetId, container, onToken) { + if (widgetId !== null && api?.remove) { + api.remove(widgetId) + } + if (container) { + container.innerHTML = '' + } + onToken?.('') + }, + }, + recaptcha: { + globalName: 'grecaptcha', + render(api, container, { siteKey, theme, onToken }) { + return api.render(container, { + sitekey: siteKey, + theme, + callback: (token) => onToken?.(token || ''), + 'expired-callback': () => onToken?.(''), + 'error-callback': () => onToken?.(''), + }) + }, + cleanup(api, widgetId, container, onToken) { + if (widgetId !== null && api?.reset) { + api.reset(widgetId) + } + if (container) { + container.innerHTML = '' + } + onToken?.('') + }, + }, + hcaptcha: { + globalName: 'hcaptcha', + render(api, container, { siteKey, theme, onToken }) { + return api.render(container, { + sitekey: siteKey, + theme, + callback: (token) => onToken?.(token || ''), + 'expired-callback': () => onToken?.(''), + 'error-callback': () => onToken?.(''), + }) + }, + cleanup(api, widgetId, container, onToken) { + if (widgetId !== null && api?.remove) { + api.remove(widgetId) + } + if (container) { + container.innerHTML = '' + } + onToken?.('') + }, + }, +} + +function loadCaptchaScript(src) { + if (!src) { + return Promise.resolve() + } + + if (!window.__skinbaseCaptchaScripts) { + window.__skinbaseCaptchaScripts = {} + } + + if (!window.__skinbaseCaptchaScripts[src]) { + window.__skinbaseCaptchaScripts[src] = new Promise((resolve, reject) => { + const existing = document.querySelector(`script[src="${src}"]`) + + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve() + return + } + + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true }) + return + } + + const script = document.createElement('script') + script.src = src + script.async = true + script.defer = true + script.addEventListener('load', () => { + script.dataset.loaded = 'true' + resolve() + }, { once: true }) + script.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true }) + document.head.appendChild(script) + }) + } + + return window.__skinbaseCaptchaScripts[src] +} + +export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) { + const containerRef = useRef(null) + const widgetIdRef = useRef(null) + + useEffect(() => { + const adapter = providerAdapters[provider] || providerAdapters.turnstile + + if (!siteKey || !containerRef.current) { + return undefined + } + + let cancelled = false + let intervalId = null + + const mountWidget = () => { + const api = window[adapter.globalName] + + if (cancelled || !api?.render || widgetIdRef.current !== null) { + return + } + + widgetIdRef.current = adapter.render(api, containerRef.current, { + siteKey, + theme, + onToken, + }) + } + + loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => { + const api = window[adapter.globalName] + if (typeof api?.ready === 'function') { + api.ready(mountWidget) + } else { + mountWidget() + } + + if (widgetIdRef.current === null) { + intervalId = window.setInterval(mountWidget, 250) + } + }) + + return () => { + cancelled = true + if (intervalId) { + window.clearInterval(intervalId) + } + adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken) + widgetIdRef.current = null + } + }, [className, onToken, provider, scriptUrl, siteKey, theme]) + + if (!siteKey) { + return null + } + + return
+} diff --git a/resources/js/lib/security/botFingerprint.js b/resources/js/lib/security/botFingerprint.js new file mode 100644 index 00000000..6dec91a4 --- /dev/null +++ b/resources/js/lib/security/botFingerprint.js @@ -0,0 +1,65 @@ +async function sha256Hex(value) { + if (!window.crypto?.subtle) { + return '' + } + + const encoded = new TextEncoder().encode(value) + const digest = await window.crypto.subtle.digest('SHA-256', encoded) + return Array.from(new Uint8Array(digest)) + .map((part) => part.toString(16).padStart(2, '0')) + .join('') +} + +function readWebglVendor() { + try { + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') + if (!gl) { + return 'no-webgl' + } + + const extension = gl.getExtension('WEBGL_debug_renderer_info') + if (!extension) { + return 'webgl-hidden' + } + + return [ + gl.getParameter(extension.UNMASKED_VENDOR_WEBGL), + gl.getParameter(extension.UNMASKED_RENDERER_WEBGL), + ].join(':') + } catch { + return 'webgl-error' + } +} + +export async function buildBotFingerprint() { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown' + const screenSize = typeof window.screen !== 'undefined' + ? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}` + : 'no-screen' + + const payload = [ + navigator.userAgent || 'unknown-ua', + navigator.language || 'unknown-language', + navigator.platform || 'unknown-platform', + timezone, + screenSize, + readWebglVendor(), + ].join('|') + + return sha256Hex(payload) +} + +export async function populateBotFingerprint(form) { + if (!form) { + return '' + } + + const fingerprint = await buildBotFingerprint() + const field = form.querySelector('input[name="_bot_fingerprint"]') + if (field && fingerprint !== '') { + field.value = fingerprint + } + + return fingerprint +} diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index ac5e6217..2e705011 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -16,10 +16,23 @@
@endif + @if($errors->has('bot')) +
+ {{ $errors->first('bot') }} +
+ @endif + @include('auth.partials.social-login') -
+ @csrf + + + + @php + $captchaProvider = $captcha['provider'] ?? 'turnstile'; + $captchaSiteKey = $captcha['siteKey'] ?? ''; + @endphp
@@ -33,6 +46,17 @@
+ @if(($requiresCaptcha ?? false) && $captchaSiteKey !== '') + @if($captchaProvider === 'recaptcha') +
+ @elseif($captchaProvider === 'hcaptcha') +
+ @else +
+ @endif + + @endif +
+@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== '')) + +@endif +@include('partials.bot-fingerprint-script') @endsection diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php index c6d987b4..b732bd66 100644 --- a/resources/views/auth/register.blade.php +++ b/resources/views/auth/register.blade.php @@ -13,9 +13,22 @@ @endif + @if($errors->has('bot')) +
+ {{ $errors->first('bot') }} +
+ @endif + @include('auth.partials.social-login', ['dividerLabel' => 'or register with email']) - + @csrf + + + + @php + $captchaProvider = $captcha['provider'] ?? 'turnstile'; + $captchaSiteKey = $captcha['siteKey'] ?? ''; + @endphp
@@ -23,8 +36,14 @@
- @if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') -
+ @if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '') + @if($captchaProvider === 'recaptcha') +
+ @elseif($captchaProvider === 'hcaptcha') +
+ @else +
+ @endif @endif @@ -35,7 +54,8 @@ -@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '') - +@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== '')) + @endif +@include('partials.bot-fingerprint-script') @endsection diff --git a/resources/views/partials/bot-fingerprint-script.blade.php b/resources/views/partials/bot-fingerprint-script.blade.php new file mode 100644 index 00000000..09a659fe --- /dev/null +++ b/resources/views/partials/bot-fingerprint-script.blade.php @@ -0,0 +1,55 @@ + diff --git a/routes/api.php b/routes/api.php index c0b35190..12925d5e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,20 +11,20 @@ Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')- }); Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () { - Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->name('create'); - Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->name('update'); - Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->name('autosave'); + Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->middleware('forum.bot.protection:api_write')->name('create'); + Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->middleware('forum.bot.protection:api_write')->name('update'); + Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->middleware('forum.bot.protection:api_write')->name('autosave'); }); Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () { - Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->name('upload-image'); + Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->middleware('forum.bot.protection:api_write')->name('upload-image'); Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks'); }); Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover')->name('api.profile.cover.')->group(function () { - Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware('throttle:20,1')->name('upload'); - Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware('throttle:30,1')->name('position'); - Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware('throttle:20,1')->name('destroy'); + Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('upload'); + Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware(['throttle:30,1', 'forum.bot.protection:profile_update'])->name('position'); + Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('destroy'); }); // ── Per-artwork signal tracking (public) ──────────────────────────────────── @@ -38,14 +38,20 @@ Route::middleware(['web', 'throttle:300,1']) Route::middleware(['web', 'throttle:5,10']) ->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class) + ->middleware('forum.bot.protection:api_write') ->whereNumber('id') ->name('api.art.view'); Route::middleware(['web', 'throttle:10,1']) ->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class) + ->middleware('forum.bot.protection:api_write') ->whereNumber('id') ->name('api.art.download'); +Route::middleware(['web', 'throttle:reactions-read']) + ->get('community/activity', [\App\Http\Controllers\Api\CommunityActivityController::class, 'index']) + ->name('api.community.activity'); + // ── Ranking lists (public, throttled, Redis-cached) ───────────────────────── // GET /api/rank/global?type=trending|new_hot|best // GET /api/rank/category/{id}?type=trending|new_hot|best @@ -136,24 +142,25 @@ Route::middleware(['throttle:60,1']) Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () { Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store']) + ->middleware('forum.bot.protection:api_write') ->name('store'); }); Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () { Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init']) - ->middleware('throttle:uploads-init') + ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write']) ->name('init'); Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload']) - ->middleware('throttle:uploads-init') + ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write']) ->name('preload'); Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave']) - ->middleware('throttle:uploads-finish') + ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write']) ->name('autosave'); Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish']) - ->middleware('throttle:uploads-finish') + ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write']) ->name('publish'); Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus']) @@ -161,15 +168,15 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam ->name('processing-status'); Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk']) - ->middleware('throttle:uploads-init') + ->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write']) ->name('chunk'); Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish']) - ->middleware('throttle:uploads-finish') + ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write']) ->name('finish'); Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel']) - ->middleware('throttle:uploads-finish') + ->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write']) ->name('cancel'); Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status']) @@ -204,6 +211,9 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')- Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index']) ->name('feed-performance'); + + Route::get('tags', [\App\Http\Controllers\Api\Admin\TagInteractionReportController::class, 'index']) + ->name('tags'); }); Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () { @@ -223,13 +233,17 @@ Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtw ->middleware('throttle:uploads-status') ->name('api.analytics.similar-artworks.store'); +Route::middleware(['web'])->post('analytics/tags', [\App\Http\Controllers\Api\TagInteractionAnalyticsController::class, 'store']) + ->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write']) + ->name('api.analytics.tags.store'); + Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store']) - ->middleware('throttle:uploads-status') + ->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write']) ->name('api.analytics.feed.store'); Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () { Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store']) - ->middleware('throttle:uploads-status') + ->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write']) ->name('events.store'); }); diff --git a/routes/auth.php b/routes/auth.php index 6f7525b7..fc1f57a3 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -35,7 +35,7 @@ Route::middleware(['guest', 'normalize.username'])->group(function () { ->name('register.notice'); Route::post('register', [RegisteredUserController::class, 'store']) - ->middleware(['throttle:register-ip', 'throttle:register-ip-daily']); + ->middleware(['throttle:register-ip', 'throttle:register-ip-daily', 'forum.security.firewall:register', 'forum.bot.protection:register']); Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification']) ->middleware('throttle:register') @@ -47,7 +47,8 @@ Route::middleware(['guest', 'normalize.username'])->group(function () { Route::get('login', [AuthenticatedSessionController::class, 'create']) ->name('login'); - Route::post('login', [AuthenticatedSessionController::class, 'store']); + Route::post('login', [AuthenticatedSessionController::class, 'store']) + ->middleware(['forum.security.firewall:login', 'forum.bot.protection:login']); Route::get('forgot-password', [PasswordResetLinkController::class, 'create']) ->name('password.request'); diff --git a/routes/console.php b/routes/console.php index 9c2494d8..5c368bc8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -110,7 +110,26 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores') ->withoutOverlapping() ->runInBackground(); -Schedule::command('forum:scan-posts') +Schedule::command('forum:ai-scan') ->everyTenMinutes() - ->name('forum-scan-posts') - ->withoutOverlapping(); + ->name('forum-ai-scan') + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('forum:bot-scan') + ->everyFiveMinutes() + ->name('forum-bot-scan') + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('forum:scan-posts --limit=250') + ->everyFifteenMinutes() + ->name('forum-post-scan') + ->withoutOverlapping() + ->runInBackground(); + +Schedule::command('forum:firewall-scan') + ->everyFiveMinutes() + ->name('forum-firewall-scan') + ->withoutOverlapping() + ->runInBackground(); diff --git a/routes/web.php b/routes/web.php index 660f22e7..6b23cb7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -273,18 +273,18 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])- Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password'); Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload'); - Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->name('settings.profile.update'); - Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->name('settings.account.username'); - Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->name('settings.account.update'); + Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->middleware('forum.bot.protection:profile_update')->name('settings.profile.update'); + Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->middleware('forum.bot.protection:profile_update')->name('settings.account.username'); + Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->middleware('forum.bot.protection:profile_update')->name('settings.account.update'); Route::post('/settings/email/request', [ProfileController::class, 'requestEmailChange']) ->middleware('throttle:email-change-request') ->name('settings.email.request'); Route::post('/settings/email/verify', [ProfileController::class, 'verifyEmailChange']) ->middleware('throttle:10,1') ->name('settings.email.verify'); - Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->name('settings.personal.update'); - Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->name('settings.notifications.update'); - Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->name('settings.security.password'); + Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->middleware('forum.bot.protection:profile_update')->name('settings.personal.update'); + Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->middleware('forum.bot.protection:profile_update')->name('settings.notifications.update'); + Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->middleware('forum.bot.protection:profile_update')->name('settings.security.password'); }); // ── UPLOAD ──────────────────────────────────────────────────────────────────── @@ -377,6 +377,10 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function () ->middleware('admin.moderation') ->name('reports.queue'); + Route::get('reports/tags', [\App\Http\Controllers\Admin\TagInteractionReportController::class, 'index']) + ->middleware('admin.moderation') + ->name('reports.tags'); + Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () { Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index'); Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush'); @@ -411,6 +415,9 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n }); // ── COMMUNITY ACTIVITY ──────────────────────────────────────────────────────── +Route::match(['get', 'post'], '/community/chat', [\App\Http\Controllers\Community\ChatController::class, 'index']) + ->name('community.chat'); + Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index']) ->name('community.activity'); @@ -430,9 +437,11 @@ Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController ->name('feed.search'); // ── CONTENT BROWSER (artwork / category universal router) ───────────────────── -// Bind the artwork route parameter to the Artwork model by slug. +// Bind the artwork route parameter by slug when possible, but don't hard-fail. +// Some URLs that match this shape are actually nested category paths such as +// /skins/audio/blazemedia-pro, which should fall through to category handling. Route::bind('artwork', function ($value) { - return Artwork::where('slug', $value)->firstOrFail(); + return Artwork::where('slug', $value)->first(); }); Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork']) diff --git a/tests/Unit/AccountFarmDetectorTest.php b/tests/Unit/AccountFarmDetectorTest.php new file mode 100644 index 00000000..f563c32e --- /dev/null +++ b/tests/Unit/AccountFarmDetectorTest.php @@ -0,0 +1,95 @@ +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.'); +}); \ No newline at end of file diff --git a/tests/Unit/BotRiskScorerTest.php b/tests/Unit/BotRiskScorerTest.php new file mode 100644 index 00000000..758f67e4 --- /dev/null +++ b/tests/Unit/BotRiskScorerTest.php @@ -0,0 +1,52 @@ +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, + ]); +}); diff --git a/tests/Unit/ForumRateLimitMiddlewareTest.php b/tests/Unit/ForumRateLimitMiddlewareTest.php new file mode 100644 index 00000000..464fb8a9 --- /dev/null +++ b/tests/Unit/ForumRateLimitMiddlewareTest.php @@ -0,0 +1,166 @@ +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); \ No newline at end of file diff --git a/tests/Unit/ForumRateLimitRouteTest.php b/tests/Unit/ForumRateLimitRouteTest.php new file mode 100644 index 00000000..53b878e9 --- /dev/null +++ b/tests/Unit/ForumRateLimitRouteTest.php @@ -0,0 +1,482 @@ +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(); +} \ No newline at end of file diff --git a/tests/Unit/GeoBehaviorAnalyzerTest.php b/tests/Unit/GeoBehaviorAnalyzerTest.php new file mode 100644 index 00000000..b8b5bf8a --- /dev/null +++ b/tests/Unit/GeoBehaviorAnalyzerTest.php @@ -0,0 +1,83 @@ +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'); +}); diff --git a/tests/Unit/IPReputationServiceTest.php b/tests/Unit/IPReputationServiceTest.php new file mode 100644 index 00000000..ac248e04 --- /dev/null +++ b/tests/Unit/IPReputationServiceTest.php @@ -0,0 +1,70 @@ +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(); +}); \ No newline at end of file