修复认证与基础安全链路

This commit is contained in:
2026-04-19 14:42:42 +08:00
parent bd97ed0b73
commit 5ce83a769d
13 changed files with 636 additions and 55 deletions
+1
View File
@@ -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
@@ -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], '<b><strong><em><span><br>');
$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;
}
}
+9 -4
View File
@@ -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');
+83 -19
View File
@@ -1,42 +1,106 @@
<?php
/**
* 文件功能:在可信代理场景下解析客户端真实 IP。
*
* 仅当当前请求明确来自配置中的反向代理 / CDN 节点时,
* 才会采信其透传的真实客户端 IP 头,避免外部客户端伪造来源。
*/
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\IpUtils;
use Symfony\Component\HttpFoundation\Response;
/**
* 类功能:为可信代理请求恢复真实客户端 IP。
*/
class CloudflareProxies
{
/**
* 文件功能:强制信任并解析 CDN 传导的真实客户端 IP。
* 解决 Herd 环境 / Nginx 本地反代时,丢失 X-Forwarded-For 导致全员 IP 变成 127.0.0.1 的问题。
* 处理进入应用的请求,并在可信代理场景下覆写客户端 IP。
*/
public function handle(Request $request, Closure $next): Response
{
// 优先采纳 Cloudflare 的 CF-Connecting-IP
if ($request->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;
}
}
+57 -2
View File
@@ -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()]);
}
}
+11 -6
View File
@@ -1,15 +1,21 @@
<?php
/**
* 文件功能:注册 Horizon 面板的访问授权规则。
*/
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Horizon;
use Laravel\Horizon\HorizonApplicationServiceProvider;
/**
* 类功能:注册 Horizon 面板访问门禁。
*/
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
* 引导 Horizon 服务并加载父类默认注册逻辑。
*/
public function boot(): void
{
@@ -21,14 +27,13 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
* 注册 Horizon 面板访问门禁。
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return $user && $user->user_level >= 15;
// Horizon 属于高敏运维面板,仅站长账号允许进入,避免绕过后台主权限体系。
return $user && (int) $user->id === 1;
});
}
}
+8 -3
View File
@@ -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: httpsLaravel 据此将请求识别为 HTTPSurl()/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,
+15
View File
@@ -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
@@ -34,6 +34,25 @@
* 用于展示任命公告、好友通知、红包选择等需要居中展示的大卡片。
*/
window.chatBanner = (function() {
/**
* 将任意文本转为 HTML 安全文本。
*/
function escapeBannerText(text) {
return String(text ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* 将多行纯文本转为带 <br> 的安全 HTML。
*/
function renderMultilineText(text) {
return escapeBannerText(text).replace(/\n/g, '<br>');
}
/** 注入入场/退场动画(全局只注入一次) */
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 || '确定')}
</button>`;
});
buttonsHtml += '</div>';
@@ -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 ? `<div style="font-size:40px; margin-bottom:8px;">${opts.icon}</div>` : ''}
${opts.icon ? `<div style="font-size:40px; margin-bottom:8px;">${escapeBannerText(opts.icon)}</div>` : ''}
${opts.title ? `<div style="color:${titleColor}; font-size:13px; font-weight:bold; letter-spacing:3px; margin-bottom:12px;">
══ ${opts.title} ══
══ ${escapeBannerText(opts.title)} ══
</div>` : ''}
${opts.name ? `<div style="color:white; font-size:22px; font-weight:900; text-shadow:0 2px 8px rgba(0,0,0,0.3);">
${escapeHtml(opts.name)}
${escapeBannerText(opts.name)}
</div>` : ''}
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${opts.body}</div>` : ''}
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${opts.sub}</div>` : ''}
${opts.body ? `<div style="color:rgba(255,255,255,0.9); font-size:14px; margin-top:10px;">${renderMultilineText(opts.body)}</div>` : ''}
${opts.sub ? `<div style="color:rgba(255,255,255,0.6); font-size:12px; margin-top:6px;">${renderMultilineText(opts.sub)}</div>` : ''}
${buttonsHtml}
<div style="color:rgba(255,255,255,0.35); font-size:11px; margin-top:14px;">
${new Date().toLocaleTimeString('zh-CN')}
+6 -2
View File
@@ -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');
+104 -7
View File
@@ -1,5 +1,12 @@
<?php
/**
* 文件功能:前台认证控制器功能测试
*
* 覆盖登录即注册、老密码升级、封禁限制、退出登录、
* session 轮换与服务端限流等关键认证行为。
*/
namespace Tests\Feature;
use App\Models\Sysparam;
@@ -7,14 +14,21 @@ use App\Models\User;
use App\Models\UsernameBlacklist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
/**
* 类功能:验证前台认证链路的核心行为。
*/
class AuthControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 初始化验证码桩与基础系统参数。
*/
protected function setUp(): void
{
parent::setUp();
@@ -31,7 +45,37 @@ class AuthControllerTest extends TestCase
);
}
public function test_can_register_new_user()
/**
* 测试普通登录成功后会轮换 session id,阻断会话固定风险。
*/
public function test_login_regenerates_session_id(): void
{
Redis::shouldReceive('sismember')->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');
}
}
@@ -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');
}
}
+224
View File
@@ -0,0 +1,224 @@
<?php
/**
* 文件功能:安全加固回归测试
*
* 覆盖可信代理 IP 解析、Banner 广播净化,以及 Horizon 访问门禁,
* 确保本轮安全修复不会在后续迭代中退化。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Tests\Feature;
use App\Events\BannerNotification;
use App\Http\Controllers\Admin\BannerBroadcastController;
use App\Http\Middleware\CloudflareProxies;
use App\Models\User;
use App\Providers\HorizonServiceProvider;
use Illuminate\Broadcasting\PendingBroadcast;
use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
/**
* 类功能:验证本轮安全修复的核心保护行为。
*/
class SecurityHardeningTest extends TestCase
{
/**
* 验证只有来自可信代理的请求才允许采用透传客户端 IP。
*/
public function test_cloudflare_proxies_only_accepts_forwarded_ip_from_trusted_proxy(): void
{
config()->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' => '<span onclick="alert(1)">危险标题</span>',
'name' => '<b>管理员</b>',
'body' => '<span onclick="alert(1)">危险正文</span>',
'sub' => '<img src=x onerror=alert(1)>副标题',
'titleColor' => 'url(javascript:1)',
'gradient' => ['#123456', 'rgba(1,2,3,0.5)', 'url(javascript:alert(1))'],
'buttons' => [
[
'label' => '<b>立即处理</b>',
'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);
}
}