diff --git a/.env.example b/.env.example index 6453de0..455f761 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +TRUSTED_PROXIES=127.0.0.1,::1 BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/app/Http/Controllers/Admin/BannerBroadcastController.php b/app/Http/Controllers/Admin/BannerBroadcastController.php index 40a4f22..91141fd 100644 --- a/app/Http/Controllers/Admin/BannerBroadcastController.php +++ b/app/Http/Controllers/Admin/BannerBroadcastController.php @@ -9,7 +9,7 @@ * 安全保证: * - 路由被 ['chat.auth', 'chat.has_position', 'chat.level:super'] 三层中间件保护 * - 普通用户无权访问此接口,无法伪造对他人的广播 - * - options 中的用户输入字段在后端经过 strip_tags 清洗 + * - options 中的用户输入字段在后端统一降级为纯文本 / 白名单样式值 * * @author ChatRoom Laravel * @@ -23,6 +23,9 @@ use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +/** + * 类功能:安全地下发大卡片广播消息。 + */ class BannerBroadcastController extends Controller { /** @@ -46,23 +49,39 @@ class BannerBroadcastController extends Controller 'options.body' => ['nullable', 'string', 'max:500'], 'options.sub' => ['nullable', 'string', 'max:200'], 'options.gradient' => ['nullable', 'array', 'max:5'], + 'options.gradient.*' => ['nullable', 'string', 'max:30'], 'options.titleColor' => ['nullable', 'string', 'max:30'], 'options.autoClose' => ['nullable', 'integer', 'min:0', 'max:30000'], 'options.buttons' => ['nullable', 'array', 'max:4'], + 'options.buttons.*.label' => ['nullable', 'string', 'max:30'], + 'options.buttons.*.color' => ['nullable', 'string', 'max:30'], + 'options.buttons.*.action' => ['nullable', 'string', 'max:20'], ]); - // 对可能包含用户输入的字段进行 HTML 净化(防 XSS) + // 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。 $opts = $validated['options']; foreach (['title', 'name', 'body', 'sub'] as $field) { if (isset($opts[$field])) { - $opts[$field] = strip_tags($opts[$field], '
'); + $opts[$field] = $this->sanitizeBannerText($opts[$field]); } } - // 按钮 label 不允许 HTML + + if (isset($opts['titleColor'])) { + $opts['titleColor'] = $this->sanitizeCssValue($opts['titleColor'], '#fde68a'); + } + + if (! empty($opts['gradient'])) { + $opts['gradient'] = array_values(array_map( + fn ($color) => $this->sanitizeCssValue($color, '#4f46e5'), + $opts['gradient'] + )); + } + + // 按钮 label 与颜色都只允许安全文本 / 颜色值。 if (! empty($opts['buttons'])) { $opts['buttons'] = array_map(function ($btn) { - $btn['label'] = strip_tags($btn['label'] ?? ''); - $btn['color'] = preg_replace('/[^a-z0-9#(),\s.%rgba\/]/i', '', $btn['color'] ?? '#10b981'); + $btn['label'] = $this->sanitizeBannerText($btn['label'] ?? ''); + $btn['color'] = $this->sanitizeCssValue($btn['color'] ?? '#10b981', '#10b981'); // action 只允许预定义值,防止注入任意 JS $btn['action'] = in_array($btn['action'] ?? '', ['close', 'add_friend', 'remove_friend', 'link']) ? $btn['action'] : 'close'; @@ -79,4 +98,38 @@ class BannerBroadcastController extends Controller return response()->json(['status' => 'success', 'message' => '广播已发送']); } + + /** + * 将 Banner 文案净化为安全纯文本。 + */ + private function sanitizeBannerText(?string $text): string + { + return trim(strip_tags((string) $text)); + } + + /** + * 清洗颜色 / 渐变等 CSS 值,阻断样式属性注入。 + */ + private function sanitizeCssValue(?string $value, string $default): string + { + $sanitized = strtolower(trim((string) $value)); + if ($sanitized === '' || preg_match('/(?:javascript|expression|url\s*\(|data:|var\s*\()/i', $sanitized)) { + return $default; + } + + $allowedPatterns = [ + '/^#[0-9a-f]{3,8}$/i', + '/^rgba?\(\s*(?:\d{1,3}\s*,\s*){2}\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i', + '/^hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)$/i', + '/^(?:white|black|red|blue|green|gray|grey|yellow|orange|pink|purple|teal|cyan|indigo|amber|emerald|transparent|currentcolor)$/i', + ]; + + foreach ($allowedPatterns as $allowedPattern) { + if (preg_match($allowedPattern, $sanitized)) { + return $sanitized; + } + } + + return $default; + } } diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index 0e8c5bc..02ef2a3 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -20,6 +20,9 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Redis; +/** + * 类功能:处理聊天室前台登录、自动注册与退出登录。 + */ class AuthController extends Controller { /** @@ -61,7 +64,7 @@ class AuthController extends Controller } } - $this->performLogin($user, $ip); + $this->performLogin($user, $ip, $request); return response()->json(['status' => 'success', 'message' => '登录成功']); } @@ -83,7 +86,7 @@ class AuthController extends Controller } } - $this->performLogin($user, $ip); + $this->performLogin($user, $ip, $request); return response()->json(['status' => 'success', 'message' => '登录成功,且安全策略已自动升级']); } @@ -139,7 +142,7 @@ class AuthController extends Controller 'inviter_id' => $inviterId, // 记录邀请人 ]); - $this->performLogin($newUser, $ip); + $this->performLogin($newUser, $ip, $request); // 如果是通过邀请注册的,响应成功后建议清除 Cookie,防止污染后续注册 if ($inviterId) { @@ -152,9 +155,11 @@ class AuthController extends Controller /** * 执行实际的登录操作并记录时间、IP 等。 */ - private function performLogin(User $user, string $ip): void + private function performLogin(User $user, string $ip, Request $request): void { Auth::login($user); + // 登录成功后立即轮换 session id,阻断会话固定攻击。 + $request->session()->regenerate(); // 递增访问次数 $user->increment('visit_num'); diff --git a/app/Http/Middleware/CloudflareProxies.php b/app/Http/Middleware/CloudflareProxies.php index e7417b5..677d912 100644 --- a/app/Http/Middleware/CloudflareProxies.php +++ b/app/Http/Middleware/CloudflareProxies.php @@ -1,42 +1,106 @@ hasHeader('CF-Connecting-IP')) { - $realIp = $request->header('CF-Connecting-IP'); - } - // 腾讯云 EdgeOne CDN 自定义回源头部(后台配置名:EO-Client-IP) - elseif ($request->hasHeader('EO-Client-IP')) { - $realIp = $request->header('EO-Client-IP'); - } - // 其他国内 CDN 厂商(阿里云 DCDN 等)通用头部 - elseif ($request->hasHeader('X-Real-IP')) { - $realIp = $request->header('X-Real-IP'); - } - // 最后兜底:取 X-Forwarded-For 最左边第一个(真实客户端)IP - // 格式为 "真实客户端, CDN节点1, CDN节点2" - elseif ($request->hasHeader('X-Forwarded-For')) { - $realIp = trim(explode(',', $request->header('X-Forwarded-For'))[0]); - } + $realIp = $this->resolveTrustedClientIp($request); if (! empty($realIp)) { + // 仅在确认上游代理可信且透传 IP 合法时,才覆写 request()->ip() 的来源。 $request->server->set('REMOTE_ADDR', $realIp); $request->headers->set('X-Forwarded-For', $realIp); } return $next($request); } + + /** + * 从可信代理头中解析真实客户端 IP。 + */ + private function resolveTrustedClientIp(Request $request): ?string + { + $remoteAddress = (string) $request->server->get('REMOTE_ADDR', ''); + if (! $this->isTrustedProxy($remoteAddress)) { + return null; + } + + foreach (['CF-Connecting-IP', 'EO-Client-IP', 'X-Real-IP'] as $headerName) { + $resolvedIp = $this->sanitizeIp($request->header($headerName)); + if ($resolvedIp !== null) { + return $resolvedIp; + } + } + + return $this->extractForwardedForIp($request->header('X-Forwarded-For')); + } + + /** + * 判断当前请求是否来自受信代理节点。 + */ + private function isTrustedProxy(string $remoteAddress): bool + { + if ($this->sanitizeIp($remoteAddress) === null) { + return false; + } + + $trustedProxies = config('app.trusted_proxies', ['127.0.0.1', '::1']); + foreach ($trustedProxies as $trustedProxy) { + $trustedProxy = trim((string) $trustedProxy); + if ($trustedProxy !== '' && IpUtils::checkIp($remoteAddress, $trustedProxy)) { + return true; + } + } + + return false; + } + + /** + * 从 X-Forwarded-For 头中提取最左侧的合法 IP。 + */ + private function extractForwardedForIp(?string $forwardedFor): ?string + { + if (! is_string($forwardedFor) || $forwardedFor === '') { + return null; + } + + foreach (explode(',', $forwardedFor) as $candidateIp) { + $resolvedIp = $this->sanitizeIp($candidateIp); + if ($resolvedIp !== null) { + return $resolvedIp; + } + } + + return null; + } + + /** + * 校验并标准化 IP 文本。 + */ + private function sanitizeIp(?string $ip): ?string + { + $normalizedIp = trim((string) $ip); + + return filter_var($normalizedIp, FILTER_VALIDATE_IP) ? $normalizedIp : null; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index d6a1eee..844c459 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -11,15 +11,22 @@ namespace App\Providers; use App\Listeners\SaveMarriageSystemMessage; use App\Models\Sysparam; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Str; +/** + * 类功能:注册应用级服务与全局安全配置。 + */ class AppServiceProvider extends ServiceProvider { /** - * Register any application services. + * 注册应用级服务容器绑定。 */ public function register(): void { @@ -27,10 +34,13 @@ class AppServiceProvider extends ServiceProvider } /** - * Bootstrap any application services. + * 引导应用启动阶段的全局配置与事件订阅。 */ public function boot(): void { + // 注册登录入口限流器,阻断爆破和批量注册滥用。 + $this->registerAuthRateLimiters(); + // 注册婚姻系统消息订阅者(结婚/婚礼/离婚通知写入聊天历史) Event::subscribe(SaveMarriageSystemMessage::class); @@ -62,4 +72,49 @@ class AppServiceProvider extends ServiceProvider // 在安装初期表不存在时忽略,防止应用崩溃 } } + + /** + * 注册聊天室前台登录与隐藏后台登录的独立限流器。 + */ + private function registerAuthRateLimiters(): void + { + RateLimiter::for('chat-login', function (Request $request): Limit { + return Limit::perMinute(5) + ->by($this->buildAuthRateLimitKey($request, 'chat-login')) + ->response(function (Request $request, array $headers) { + return response()->json([ + 'status' => 'error', + 'message' => '登录尝试过于频繁,请 1 分钟后再试。', + ], 429, $headers); + }); + }); + + RateLimiter::for('admin-hidden-login', function (Request $request): Limit { + return Limit::perMinute(5) + ->by($this->buildAuthRateLimitKey($request, 'admin-hidden-login')) + ->response(function (Request $request, array $headers) { + $response = redirect()->route('admin.login') + ->withInput($request->except(['password', 'captcha'])) + ->withErrors(['username' => '登录尝试过于频繁,请 1 分钟后再试。']); + + foreach ($headers as $headerName => $headerValue) { + $response->headers->set($headerName, $headerValue); + } + + $response->setStatusCode(429); + + return $response; + }); + }); + } + + /** + * 构造登录限流键,按场景 + 用户名 + IP 维度隔离计数。 + */ + private function buildAuthRateLimitKey(Request $request, string $scene): string + { + $username = Str::lower(trim((string) $request->input('username', 'guest'))); + + return implode('|', [$scene, $username, $request->ip()]); + } } diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php index 6218493..67cb8a1 100644 --- a/app/Providers/HorizonServiceProvider.php +++ b/app/Providers/HorizonServiceProvider.php @@ -1,15 +1,21 @@ user_level >= 15; + // Horizon 属于高敏运维面板,仅站长账号允许进入,避免绕过后台主权限体系。 + return $user && (int) $user->id === 1; }); } } diff --git a/bootstrap/app.php b/bootstrap/app.php index b331bdb..5f4027d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -15,12 +15,17 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware) { + $trustedProxies = array_values(array_filter(array_map( + static fn (string $proxy): string => trim($proxy), + explode(',', (string) env('TRUSTED_PROXIES', '127.0.0.1,::1')) + ))); + // 强制解析并信任 CDN (如 Cloudflare) 透传的真实 IP (最高优先级) $middleware->prepend(\App\Http\Middleware\CloudflareProxies::class); - // 信任所有代理转发头(腾讯 EdgeCDN HTTPS 回源 HTTP 场景) - // CDN 携带 X-Forwarded-Proto: https,Laravel 据此将请求识别为 HTTPS,url()/route() 生成正确的 https:// 链接 - $middleware->trustProxies(at: '*'); + // 仅信任显式配置的反向代理 / CDN 节点,避免外部客户端伪造转发头污染 request()->ip()。 + // 生产环境需要把实际代理 IP / CIDR 写入 TRUSTED_PROXIES。 + $middleware->trustProxies(at: empty($trustedProxies) ? null : $trustedProxies); $middleware->alias([ 'chat.auth' => \App\Http\Middleware\ChatAuthenticate::class, diff --git a/config/app.php b/config/app.php index 9b24e59..6681568 100644 --- a/config/app.php +++ b/config/app.php @@ -54,6 +54,21 @@ return [ 'url' => env('APP_URL', 'http://localhost'), + /* + |-------------------------------------------------------------------------- + | Trusted Proxies + |-------------------------------------------------------------------------- + | + | 仅这些反向代理 / CDN 节点允许透传真实客户端 IP。 + | 生产环境请将此值配置为实际反向代理 IP 或 CIDR,多个值用逗号分隔。 + | + */ + + 'trusted_proxies' => array_values(array_filter(array_map( + static fn (string $proxy): string => trim($proxy), + explode(',', (string) env('TRUSTED_PROXIES', '127.0.0.1,::1')) + ))), + /* |-------------------------------------------------------------------------- | Application Timezone diff --git a/resources/views/chat/partials/chat-banner.blade.php b/resources/views/chat/partials/chat-banner.blade.php index 3ffd354..fb32712 100644 --- a/resources/views/chat/partials/chat-banner.blade.php +++ b/resources/views/chat/partials/chat-banner.blade.php @@ -34,6 +34,25 @@ * 用于展示任命公告、好友通知、红包选择等需要居中展示的大卡片。 */ window.chatBanner = (function() { + /** + * 将任意文本转为 HTML 安全文本。 + */ + function escapeBannerText(text) { + return String(text ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * 将多行纯文本转为带
的安全 HTML。 + */ + function renderMultilineText(text) { + return escapeBannerText(text).replace(/\n/g, '
'); + } + /** 注入入场/退场动画(全局只注入一次) */ function ensureKeyframes() { if (document.getElementById('appoint-keyframes')) { @@ -89,7 +108,7 @@ style="background:${btn.color || '#10b981'}; color:#fff; border:none; border-radius:8px; padding:8px 20px; font-size:13px; font-weight:bold; cursor:pointer; box-shadow:0 4px 12px rgba(0,0,0,0.25);"> - ${btn.label || '确定'} + ${escapeBannerText(btn.label || '确定')} `; }); buttonsHtml += ''; @@ -110,15 +129,15 @@ box-shadow: 0 20px 60px rgba(0,0,0,0.4); border: 2px solid rgba(255,255,255,0.25); backdrop-filter: blur(8px); min-width: 260px;"> - ${opts.icon ? `
${opts.icon}
` : ''} + ${opts.icon ? `
${escapeBannerText(opts.icon)}
` : ''} ${opts.title ? `
- ══ ${opts.title} ══ + ══ ${escapeBannerText(opts.title)} ══
` : ''} ${opts.name ? `
- ${escapeHtml(opts.name)} + ${escapeBannerText(opts.name)}
` : ''} - ${opts.body ? `
${opts.body}
` : ''} - ${opts.sub ? `
${opts.sub}
` : ''} + ${opts.body ? `
${renderMultilineText(opts.body)}
` : ''} + ${opts.sub ? `
${renderMultilineText(opts.sub)}
` : ''} ${buttonsHtml}
${new Date().toLocaleTimeString('zh-CN')} diff --git a/routes/web.php b/routes/web.php index 3d0335c..07217bf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -32,10 +32,14 @@ Route::get('/', function () { // 站长隐藏登录入口(仅 id=1 可使用,成功后直接进入后台控制台) Route::get('/lkddi', [AdminAuthController::class, 'create'])->name('admin.login'); -Route::post('/lkddi', [AdminAuthController::class, 'store'])->name('admin.login.store'); +Route::post('/lkddi', [AdminAuthController::class, 'store']) + ->middleware('throttle:admin-hidden-login') + ->name('admin.login.store'); // 处理登录/自动注册请求 -Route::post('/login', [AuthController::class, 'login'])->name('login.post'); +Route::post('/login', [AuthController::class, 'login']) + ->middleware('throttle:chat-login') + ->name('login.post'); // 处理退出登录 Route::post('/logout', [AuthController::class, 'logout'])->name('logout'); diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php index 0bb8a55..04f51b7 100644 --- a/tests/Feature/AuthControllerTest.php +++ b/tests/Feature/AuthControllerTest.php @@ -1,5 +1,12 @@ with('banned_ips', '127.0.0.1')->andReturn(false); + + $user = User::factory()->create([ + 'username' => 'session-user', + 'password' => Hash::make('password123'), + ]); + + $this->startSession(); + $oldSessionId = session()->getId(); + + $response = $this->postJson('/login', [ + 'username' => 'session-user', + 'password' => 'password123', + 'captcha' => '1234', + ]); + + $response->assertOk() + ->assertJsonPath('status', 'success'); + $this->assertAuthenticatedAs($user); + $this->assertNotSame($oldSessionId, session()->getId()); + } + + /** + * 验证首次登录的用户会被自动注册并完成登录。 + */ + public function test_can_register_new_user(): void { Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false); @@ -54,7 +98,10 @@ class AuthControllerTest extends TestCase $this->assertAuthenticated(); } - public function test_cannot_register_with_blacklisted_username() + /** + * 验证命中黑名单的用户名会被拒绝注册。 + */ + public function test_cannot_register_with_blacklisted_username(): void { Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false); @@ -79,7 +126,10 @@ class AuthControllerTest extends TestCase $this->assertGuest(); } - public function test_can_login_existing_user() + /** + * 验证已有用户可以通过正确密码登录。 + */ + public function test_can_login_existing_user(): void { Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false); @@ -100,7 +150,10 @@ class AuthControllerTest extends TestCase $this->assertAuthenticatedAs($user); } - public function test_login_md5_user_upgrades_to_bcrypt() + /** + * 验证旧版 MD5 密码会在成功登录后升级为 bcrypt。 + */ + public function test_login_md5_user_upgrades_to_bcrypt(): void { Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(false); @@ -132,7 +185,10 @@ class AuthControllerTest extends TestCase $this->assertAuthenticatedAs($user); } - public function test_banned_user_cannot_login() + /** + * 验证被封禁账号无法登录。 + */ + public function test_banned_user_cannot_login(): void { $user = User::factory()->create([ 'username' => 'banneduser', @@ -152,7 +208,10 @@ class AuthControllerTest extends TestCase $this->assertGuest(); } - public function test_banned_ip_cannot_login() + /** + * 验证被封禁 IP 无法登录普通账号。 + */ + public function test_banned_ip_cannot_login(): void { Redis::shouldReceive('sismember')->with('banned_ips', '127.0.0.1')->andReturn(true); @@ -171,7 +230,10 @@ class AuthControllerTest extends TestCase $this->assertGuest(); } - public function test_can_logout() + /** + * 验证已登录用户可以正常退出。 + */ + public function test_can_logout(): void { /** @var \App\Models\User $user */ $user = User::factory()->create(); @@ -181,4 +243,39 @@ class AuthControllerTest extends TestCase $response->assertRedirect('/'); $this->assertGuest(); } + + /** + * 测试前台登录接口在连续失败后会触发服务端限流。 + */ + public function test_login_route_is_rate_limited_after_repeated_failures(): void + { + RateLimiter::clear('chat-login|rate-user|127.0.0.1'); + Redis::shouldReceive('sismember')->zeroOrMoreTimes()->andReturn(false); + + User::factory()->create([ + 'username' => 'rate-user', + 'password' => Hash::make('correct-password'), + ]); + + for ($attempt = 1; $attempt <= 5; $attempt++) { + $response = $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1']) + ->postJson('/login', [ + 'username' => 'rate-user', + 'password' => 'wrong-password', + 'captcha' => '1234', + ]); + + $response->assertStatus(422); + } + + $rateLimitedResponse = $this->withServerVariables(['REMOTE_ADDR' => '127.0.0.1']) + ->postJson('/login', [ + 'username' => 'rate-user', + 'password' => 'wrong-password', + 'captcha' => '1234', + ]); + + $rateLimitedResponse->assertStatus(429) + ->assertJsonPath('status', 'error'); + } } diff --git a/tests/Feature/Feature/AdminAuthControllerTest.php b/tests/Feature/Feature/AdminAuthControllerTest.php index 4695ce9..17b6078 100644 --- a/tests/Feature/Feature/AdminAuthControllerTest.php +++ b/tests/Feature/Feature/AdminAuthControllerTest.php @@ -16,6 +16,7 @@ namespace Tests\Feature\Feature; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; use Tests\TestCase; @@ -112,4 +113,37 @@ class AdminAuthControllerTest extends TestCase $response->assertRedirect(route('admin.dashboard')); } + + /** + * 测试隐藏后台登录入口会在连续失败后触发服务端限流。 + */ + public function test_hidden_admin_login_route_is_rate_limited_after_repeated_failures(): void + { + RateLimiter::clear('admin-hidden-login|site-owner|127.0.0.1'); + + User::factory()->create([ + 'id' => 1, + 'username' => 'site-owner', + 'password' => Hash::make('correct-password'), + ]); + + for ($attempt = 1; $attempt <= 5; $attempt++) { + $response = $this->from('/lkddi')->post('/lkddi', [ + 'username' => 'site-owner', + 'password' => 'wrong-password', + 'captcha' => '1234', + ]); + + $response->assertRedirect('/lkddi'); + } + + $rateLimitedResponse = $this->from('/lkddi')->post('/lkddi', [ + 'username' => 'site-owner', + 'password' => 'wrong-password', + 'captcha' => '1234', + ]); + + $rateLimitedResponse->assertStatus(429); + $rateLimitedResponse->assertSessionHasErrors('username'); + } } diff --git a/tests/Feature/SecurityHardeningTest.php b/tests/Feature/SecurityHardeningTest.php new file mode 100644 index 0000000..ab3edfc --- /dev/null +++ b/tests/Feature/SecurityHardeningTest.php @@ -0,0 +1,224 @@ +set('app.trusted_proxies', ['127.0.0.1', '::1']); + $middleware = new CloudflareProxies; + + $trustedRequest = Request::create('/rooms', 'GET', server: [ + 'REMOTE_ADDR' => '127.0.0.1', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.10', + ]); + + $middleware->handle($trustedRequest, fn () => response('ok')); + $this->assertSame('203.0.113.10', $trustedRequest->server->get('REMOTE_ADDR')); + + $untrustedRequest = Request::create('/rooms', 'GET', server: [ + 'REMOTE_ADDR' => '198.51.100.25', + 'HTTP_CF_CONNECTING_IP' => '203.0.113.11', + ]); + + $middleware->handle($untrustedRequest, fn () => response('ok')); + $this->assertSame('198.51.100.25', $untrustedRequest->server->get('REMOTE_ADDR')); + } + + /** + * 验证 Banner 广播会将危险 HTML 净化为纯文本后再下发。 + */ + public function test_banner_broadcast_sanitizes_user_controlled_html(): void + { + $fakeBroadcastFactory = new class + { + /** + * 记录控制器最终广播出去的事件对象。 + */ + public ?BannerNotification $capturedEvent = null; + + /** + * 截获 broadcast() helper 传入的事件实例。 + */ + public function event($event): PendingBroadcast + { + $this->capturedEvent = $event; + + return new class($event) extends PendingBroadcast + { + /** + * 构造一个不会二次派发的占位 PendingBroadcast。 + */ + public function __construct(mixed $event) + { + parent::__construct(app('events'), $event); + } + + /** + * 测试替身无需在析构时再次派发事件。 + */ + public function __destruct() {} + }; + } + + /** + * 满足广播工厂抽象依赖,当前测试不会走到该分支。 + */ + public function connection($name = null): never + { + throw new \RuntimeException('SecurityHardeningTest 不应调用广播连接。'); + } + }; + $this->app->instance(BroadcastFactory::class, $fakeBroadcastFactory); + + $request = Request::create('/admin/banner/broadcast', 'POST', [ + 'target' => 'room', + 'target_id' => 1, + 'options' => [ + 'title' => '危险标题', + 'name' => '管理员', + 'body' => '危险正文', + 'sub' => '副标题', + 'titleColor' => 'url(javascript:1)', + 'gradient' => ['#123456', 'rgba(1,2,3,0.5)', 'url(javascript:alert(1))'], + 'buttons' => [ + [ + 'label' => '立即处理', + 'color' => 'rgba(16,185,129,1)', + 'action' => 'close', + ], + ], + ], + ]); + $request->headers->set('Accept', 'application/json'); + + $response = app(BannerBroadcastController::class)->send($request); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('success', $response->getData(true)['status']); + $this->assertInstanceOf(BannerNotification::class, $fakeBroadcastFactory->capturedEvent); + $this->assertSame('危险标题', $fakeBroadcastFactory->capturedEvent->options['title']); + $this->assertSame('管理员', $fakeBroadcastFactory->capturedEvent->options['name']); + $this->assertSame('危险正文', $fakeBroadcastFactory->capturedEvent->options['body']); + $this->assertSame('副标题', $fakeBroadcastFactory->capturedEvent->options['sub']); + $this->assertStringNotContainsString('"', $fakeBroadcastFactory->capturedEvent->options['titleColor']); + $this->assertStringNotContainsString('javascript', $fakeBroadcastFactory->capturedEvent->options['gradient'][2]); + $this->assertSame('立即处理', $fakeBroadcastFactory->capturedEvent->options['buttons'][0]['label']); + } + + /** + * 验证 Horizon 面板仅允许站长账号访问。 + */ + public function test_horizon_gate_only_allows_site_owner(): void + { + $provider = new class($this->app) extends HorizonServiceProvider + { + /** + * 显式注册测试所需的 Horizon gate。 + */ + public function registerTestGate(): void + { + $this->gate(); + } + }; + $provider->registerTestGate(); + + $siteOwner = new User([ + 'username' => 'site-owner', + 'user_level' => 1, + ]); + $siteOwner->id = 1; + + $revokedManager = new User([ + 'username' => 'revoked-manager', + 'user_level' => 100, + ]); + + $this->assertTrue(Gate::forUser($siteOwner)->allows('viewHorizon')); + $this->assertFalse(Gate::forUser($revokedManager)->allows('viewHorizon')); + } + + /** + * 验证前台登录入口在命中限流后会直接返回 429。 + */ + public function test_chat_login_route_returns_429_when_rate_limited(): void + { + $rateLimitKey = md5('chat-login'.'chat-login|testuser|127.0.0.1'); + RateLimiter::clear($rateLimitKey); + + foreach (range(1, 5) as $attempt) { + RateLimiter::hit($rateLimitKey, 60); + } + + $response = $this->withoutMiddleware(ValidateCsrfToken::class) + ->withServerVariables(['REMOTE_ADDR' => '127.0.0.1']) + ->postJson('/login', [ + 'username' => 'TestUser', + 'password' => 'secret', + 'captcha' => 'invalid', + ]); + + $response->assertStatus(429) + ->assertJsonPath('status', 'error'); + + RateLimiter::clear($rateLimitKey); + } + + /** + * 验证隐藏后台登录入口在命中限流后会直接拒绝请求。 + */ + public function test_hidden_admin_login_route_returns_429_when_rate_limited(): void + { + $rateLimitKey = md5('admin-hidden-login'.'admin-hidden-login|site-owner|127.0.0.1'); + RateLimiter::clear($rateLimitKey); + + foreach (range(1, 5) as $attempt) { + RateLimiter::hit($rateLimitKey, 60); + } + + $response = $this->withoutMiddleware(ValidateCsrfToken::class) + ->from('/lkddi') + ->withServerVariables(['REMOTE_ADDR' => '127.0.0.1']) + ->post('/lkddi', [ + 'username' => 'site-owner', + 'password' => 'secret', + 'captcha' => 'invalid', + ]); + + $response->assertStatus(429); + $this->assertStringContainsString('/lkddi', $response->headers->get('Location', '')); + + RateLimiter::clear($rateLimitKey); + } +}