diff --git a/app/Events/ChangelogPublished.php b/app/Events/ChangelogPublished.php index aebf9c3..9569fb7 100644 --- a/app/Events/ChangelogPublished.php +++ b/app/Events/ChangelogPublished.php @@ -20,6 +20,10 @@ use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; +/** + * 开发日志发布广播事件 + * 负责把更新日志的安全展示字段广播给大厅聊天室。 + */ class ChangelogPublished implements ShouldBroadcastNow { use Dispatchable, InteractsWithSockets, SerializesModels; @@ -67,8 +71,20 @@ class ChangelogPublished implements ShouldBroadcastNow 'title' => $this->changelog->title, 'type' => $this->changelog->type, 'type_label' => $this->changelog->type_label, + // 同步提供已转义字段,便于前端在 innerHTML 场景下直接复用安全文本。 + 'safe_version' => e((string) $this->changelog->version), + 'safe_title' => e((string) $this->changelog->title), + 'safe_type_label' => e((string) $this->changelog->type_label), // 前端点击后跳转的目标 URL,自动锚定至对应版本 - 'url' => url('/changelog').'#v'.$this->changelog->version, + 'url' => $this->buildDetailUrl(), ]; } + + /** + * 生成广播使用的更新日志详情地址,并编码版本锚点避免 href 注入。 + */ + private function buildDetailUrl(): string + { + return route('changelog.index').'#v'.rawurlencode((string) $this->changelog->version); + } } diff --git a/app/Http/Controllers/Admin/ChangelogController.php b/app/Http/Controllers/Admin/ChangelogController.php index 71f79c3..f76de98 100644 --- a/app/Http/Controllers/Admin/ChangelogController.php +++ b/app/Http/Controllers/Admin/ChangelogController.php @@ -174,18 +174,29 @@ class ChangelogController extends Controller */ private function saveChangelogNotification(DevChangelog $log): void { - $typeLabel = DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新'; - $url = url('/changelog').'#v'.$log->version; + // 广播文案允许保留安全链接,但标题与版本号必须先做 HTML 转义,避免系统消息被拼成恶意标签。 + $safeTypeLabel = e(DevChangelog::TYPE_CONFIG[$log->type]['label'] ?? '更新'); + $safeVersion = e((string) $log->version); + $safeTitle = e((string) $log->title); + $detailUrl = e($this->buildChangelogDetailUrl($log)); SaveMessageJob::dispatch([ 'room_id' => 1, 'from_user' => '系统公告', 'to_user' => '大家', - 'content' => "📢 【版本更新 {$typeLabel}】v{$log->version}《{$log->title}》— 点击查看详情", + 'content' => "📢 【版本更新 {$safeTypeLabel}】v{$safeVersion}《{$safeTitle}》— 点击查看详情", 'is_secret' => false, 'font_color' => '#7c3aed', 'action' => '', 'sent_at' => now()->toIso8601String(), ]); } + + /** + * 生成开发日志详情链接,并对版本片段做 URL 编码,避免广播 href 被注入额外属性。 + */ + private function buildChangelogDetailUrl(DevChangelog $log): string + { + return route('changelog.index').'#v'.rawurlencode((string) $log->version); + } } diff --git a/app/Http/Controllers/Admin/SystemController.php b/app/Http/Controllers/Admin/SystemController.php index 7986b95..e30d1b6 100644 --- a/app/Http/Controllers/Admin/SystemController.php +++ b/app/Http/Controllers/Admin/SystemController.php @@ -17,28 +17,37 @@ use App\Models\SysParam; use App\Services\ChatStateService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Str; use Illuminate\View\View; +/** + * 类功能:后台通用系统参数配置控制器 + * 仅允许维护低敏公共参数,站长专属敏感配置需走各自独立页面。 + */ class SystemController extends Controller { + /** + * 构造函数注入聊天室状态服务 + */ public function __construct( private readonly ChatStateService $chatState ) {} /** - * 显示全局参数配置表单 + * 显示通用系统参数配置表单 */ public function edit(): View { - // 读取数据库中最新的参数 (剔除专属模块已接管的配置,避免重复显示) - $params = SysParam::whereNotIn('alias', ['chatbot_enabled']) - ->where('alias', 'not like', 'smtp_%') - ->get()->pluck('body', 'alias')->toArray(); + $editableAliases = $this->editableSystemAliases(); - // 为后台界面准备的文案对照 (可动态化或硬编码) - $descriptions = SysParam::whereNotIn('alias', ['chatbot_enabled']) - ->where('alias', 'not like', 'smtp_%') - ->get()->pluck('guidetxt', 'alias')->toArray(); + // 通用系统页仅加载白名单字段,避免站长专属配置被普通高管查看。 + $systemParams = SysParam::query() + ->whereIn('alias', $editableAliases) + ->orderBy('id') + ->get(['alias', 'body', 'guidetxt']); + + $params = $systemParams->pluck('body', 'alias')->all(); + $descriptions = $systemParams->pluck('guidetxt', 'alias')->all(); return view('admin.system.edit', compact('params', 'descriptions')); } @@ -48,16 +57,19 @@ class SystemController extends Controller */ public function update(Request $request): RedirectResponse { - $data = $request->except(['_token', '_method']); + // 只接受通用系统页白名单内的字段,忽略任何伪造提交的敏感键。 + $data = $request->only($this->editableSystemAliases()); foreach ($data as $alias => $body) { + $normalizedBody = (string) $body; + SysParam::updateOrCreate( ['alias' => $alias], - ['body' => $body] + ['body' => $normalizedBody] ); - // 写入 Cache 保证极速读取 - $this->chatState->setSysParam($alias, $body); + // 仅对白名单字段同步缓存,杜绝越权请求覆盖站长专属配置。 + $this->chatState->setSysParam($alias, $normalizedBody); // 同时清除 Sysparam 模型的内部缓存 SysParam::clearCache($alias); @@ -65,4 +77,31 @@ class SystemController extends Controller return redirect()->route('admin.system.edit')->with('success', '系统参数已成功更新并生效!'); } + + /** + * 获取通用系统页允许维护的参数别名白名单 + * + * @return array + */ + private function editableSystemAliases(): array + { + return SysParam::query() + ->orderBy('id') + ->pluck('alias') + ->filter(fn (string $alias): bool => ! $this->isSensitiveAlias($alias)) + ->values() + ->all(); + } + + /** + * 判断参数是否属于站长专属敏感配置 + */ + private function isSensitiveAlias(string $alias): bool + { + if (Str::startsWith($alias, ['smtp_', 'vip_payment_', 'wechat_bot_', 'chatbot_'])) { + return true; + } + + return Str::endsWith($alias, ['_password', '_secret', '_token', '_key']); + } } diff --git a/app/Http/Requests/SendMessageRequest.php b/app/Http/Requests/SendMessageRequest.php index 72e2a8e..00a0f56 100644 --- a/app/Http/Requests/SendMessageRequest.php +++ b/app/Http/Requests/SendMessageRequest.php @@ -13,6 +13,7 @@ namespace App\Http\Requests; use Illuminate\Contracts\Validation\Validator; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Validation\Rule; /** * 聊天室发言请求验证器 @@ -20,6 +21,27 @@ use Illuminate\Http\Exceptions\HttpResponseException; */ class SendMessageRequest extends FormRequest { + /** + * 允许前端提交的发言动作白名单。 + */ + private const ALLOWED_ACTIONS = [ + '', + '微笑', + '大笑', + '愤怒', + '哭泣', + '害羞', + '鄙视', + '得意', + '疑惑', + '同情', + '无奈', + '拳打', + '飞吻', + '偷看', + '欢迎', + ]; + /** * 判断当前请求是否允许继续。 */ @@ -41,10 +63,22 @@ class SendMessageRequest extends FormRequest 'to_user' => ['nullable', 'string', 'max:50'], 'is_secret' => ['nullable', 'boolean'], 'font_color' => ['nullable', 'string', 'max:10'], // html color hex - 'action' => ['nullable', 'string', 'max:50'], // 动作(例如:微笑着说) + 'action' => ['nullable', 'string', 'max:50', Rule::in(self::ALLOWED_ACTIONS)], // 动作字段仅允许预设值,阻断拼接式 XSS 注入 ]; } + /** + * 在校验前统一整理输入,避免首尾空白绕过白名单判断。 + */ + protected function prepareForValidation(): void + { + $action = $this->input('action'); + + $this->merge([ + 'action' => is_string($action) ? trim($action) : $action, + ]); + } + /** * 返回校验失败时的中文提示。 */ @@ -57,6 +91,7 @@ class SendMessageRequest extends FormRequest 'image.image' => '上传的文件必须是图片。', 'image.mimes' => '仅支持 jpg、jpeg、png、gif、webp 图片格式。', 'image.max' => '图片大小不能超过 6MB。', + 'action.in' => '发言动作不合法,请重新选择。', ]; } diff --git a/app/Http/Requests/StoreRoomRequest.php b/app/Http/Requests/StoreRoomRequest.php index e94d1a2..72ea142 100644 --- a/app/Http/Requests/StoreRoomRequest.php +++ b/app/Http/Requests/StoreRoomRequest.php @@ -13,10 +13,14 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; +/** + * 新建聊天室请求验证器 + * 负责限制建房权限并拦截危险的房间名称输入。 + */ class StoreRoomRequest extends FormRequest { /** - * Determine if the user is authorized to make this request. + * 判断当前用户是否具备自建房间权限。 */ public function authorize(): bool { @@ -26,24 +30,42 @@ class StoreRoomRequest extends FormRequest } /** - * Get the validation rules that apply to the request. + * 返回建房请求的校验规则。 * * @return array|string> */ public function rules(): array { return [ - 'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name'], + 'name' => ['required', 'string', 'max:50', 'regex:/^[^<>]+$/u', 'unique:rooms,room_name'], 'description' => ['nullable', 'string', 'max:255'], ]; } + /** + * 在校验前整理房间输入,避免空白与危险字符绕过前端限制。 + */ + protected function prepareForValidation(): void + { + $name = $this->input('name'); + $description = $this->input('description'); + + $this->merge([ + 'name' => is_string($name) ? trim($name) : $name, + 'description' => is_string($description) ? trim($description) : $description, + ]); + } + + /** + * 返回建房失败时的中文提示。 + */ public function messages(): array { return [ 'name.required' => '必须填写房间名称。', 'name.unique' => '该房间名称已被占用。', 'name.max' => '房间名称最多 50 个字符。', + 'name.regex' => '房间名称不能包含尖括号。', ]; } } diff --git a/app/Http/Requests/UpdateRoomRequest.php b/app/Http/Requests/UpdateRoomRequest.php index 778e870..240e081 100644 --- a/app/Http/Requests/UpdateRoomRequest.php +++ b/app/Http/Requests/UpdateRoomRequest.php @@ -11,11 +11,16 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; +/** + * 修改聊天室设置请求验证器 + * 负责约束房间名称更新时的合法性,避免危险字符进入前端模板。 + */ class UpdateRoomRequest extends FormRequest { /** - * Determine if the user is authorized to make this request. + * 判断当前请求是否允许继续。 */ public function authorize(): bool { @@ -24,23 +29,47 @@ class UpdateRoomRequest extends FormRequest } /** - * Get the validation rules that apply to the request. + * 返回修改房间设置的校验规则。 * * @return array|string> */ public function rules(): array { return [ - 'name' => ['required', 'string', 'max:50', 'unique:rooms,room_name,'.$this->route('id')], + 'name' => [ + 'required', + 'string', + 'max:50', + 'regex:/^[^<>]+$/u', + Rule::unique('rooms', 'room_name')->ignore($this->route('id')), + ], 'description' => ['nullable', 'string', 'max:255'], ]; } + /** + * 在校验前整理更新表单,避免前后空白影响唯一性与安全判断。 + */ + protected function prepareForValidation(): void + { + $name = $this->input('name'); + $description = $this->input('description'); + + $this->merge([ + 'name' => is_string($name) ? trim($name) : $name, + 'description' => is_string($description) ? trim($description) : $description, + ]); + } + + /** + * 返回房间设置更新失败时的中文提示。 + */ public function messages(): array { return [ 'name.required' => '房间名称不能为空。', 'name.unique' => '该房间名称已存在。', + 'name.regex' => '房间名称不能包含尖括号。', ]; } } diff --git a/resources/views/admin/system/edit.blade.php b/resources/views/admin/system/edit.blade.php index beaea31..26111ad 100644 --- a/resources/views/admin/system/edit.blade.php +++ b/resources/views/admin/system/edit.blade.php @@ -17,27 +17,39 @@ @endif +
+ 通用系统参数页仅维护低敏公共配置;SMTP、VIP 支付、微信机器人、AI 机器人等站长专属敏感项已迁移到各自独立页面。 +
+
@csrf @method('PUT')
- @foreach ($params as $alias => $body) + @forelse ($params as $alias => $body) + @php + $fieldValue = (string) $body; + $shouldUseTextarea = strlen($fieldValue) > 50 || str_contains($fieldValue, "\n") || str_contains($fieldValue, '<'); + @endphp
- @if (strlen($body) > 50 || str_contains($body, "\n") || str_contains($body, '<')) + @if ($shouldUseTextarea) + class="w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-500 focus:ring-indigo-500 p-2.5 bg-gray-50 border whitespace-pre-wrap">{{ $fieldValue }} @else - @endif
- @endforeach + @empty +
+ 当前没有可在通用系统页维护的公共参数,请前往对应专属配置页处理敏感模块参数。 +
+ @endforelse
diff --git a/resources/views/chat/partials/layout/mobile-drawer.blade.php b/resources/views/chat/partials/layout/mobile-drawer.blade.php index 3e41358..d6756fc 100644 --- a/resources/views/chat/partials/layout/mobile-drawer.blade.php +++ b/resources/views/chat/partials/layout/mobile-drawer.blade.php @@ -108,6 +108,18 @@ {{-- ── 手机端抽屉控制脚本 ── --}} diff --git a/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php b/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php new file mode 100644 index 0000000..3d836a2 --- /dev/null +++ b/tests/Feature/Feature/AdminChangelogControllerSecurityTest.php @@ -0,0 +1,82 @@ +create([ + 'id' => 1, + 'user_level' => 100, + ]); + + $dangerousTitle = ''; + $dangerousVersion = '2026-04-" onclick="alert(2)'; + + $response = $this->actingAs($admin)->post(route('admin.changelogs.store'), [ + 'version' => $dangerousVersion, + 'title' => $dangerousTitle, + 'type' => 'fix', + 'content' => '修复说明', + 'is_published' => '1', + 'notify_chat' => '1', + ]); + + $response->assertRedirect(route('admin.changelogs.index')); + $this->assertDatabaseHas('dev_changelogs', [ + 'title' => $dangerousTitle, + 'version' => $dangerousVersion, + 'is_published' => true, + ]); + + Event::assertDispatched(ChangelogPublished::class, function (ChangelogPublished $event) use ($dangerousTitle, $dangerousVersion) { + $payload = $event->broadcastWith(); + + $this->assertSame($dangerousTitle, $payload['title']); + $this->assertSame(e($dangerousTitle), $payload['safe_title']); + $this->assertSame(e($dangerousVersion), $payload['safe_version']); + $this->assertStringContainsString(rawurlencode($dangerousVersion), $payload['url']); + $this->assertStringNotContainsString('" onclick="', $payload['url']); + + return true; + }); + + Queue::assertPushed(SaveMessageJob::class, function (SaveMessageJob $job) use ($dangerousTitle, $dangerousVersion) { + $content = $job->messageData['content'] ?? ''; + + $this->assertStringContainsString(e($dangerousTitle), $content); + $this->assertStringContainsString(e($dangerousVersion), $content); + $this->assertStringContainsString('rel="noopener"', $content); + $this->assertStringContainsString(rawurlencode($dangerousVersion), $content); + $this->assertStringNotContainsString($dangerousTitle, $content); + + return true; + }); + } +} diff --git a/tests/Feature/Feature/AdminSystemControllerTest.php b/tests/Feature/Feature/AdminSystemControllerTest.php new file mode 100644 index 0000000..7d7b970 --- /dev/null +++ b/tests/Feature/Feature/AdminSystemControllerTest.php @@ -0,0 +1,137 @@ +seedSystemParams(); + $admin = $this->createSuperAdmin(); + + $response = $this->actingAs($admin)->get(route('admin.system.edit')); + + $response->assertOk(); + $response->assertSee('sys_name'); + $response->assertSee('sys_notice'); + $response->assertDontSee('smtp_host'); + $response->assertDontSee('vip_payment_app_secret'); + $response->assertDontSee('wechat_bot_config'); + $response->assertDontSee('chatbot_max_gold'); + } + + /** + * 验证通用系统参数页更新时只会持久化白名单字段。 + */ + public function test_system_page_update_only_persists_whitelisted_configs(): void + { + $this->seedSystemParams(); + $admin = $this->createSuperAdmin(); + + $response = $this->actingAs($admin)->put(route('admin.system.update'), [ + 'sys_name' => '新版聊天室', + 'sys_notice' => '新的公共公告', + 'smtp_host' => 'attacker.smtp.example', + 'vip_payment_app_secret' => 'tampered-secret', + 'wechat_bot_config' => '{"api":{"bot_key":"stolen"}}', + 'chatbot_max_gold' => '999999', + 'rogue_secret_token' => 'hacked', + ]); + + $response->assertRedirect(route('admin.system.edit')); + $response->assertSessionHas('success'); + + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'sys_name', + 'body' => '新版聊天室', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'sys_notice', + 'body' => '新的公共公告', + ]); + + // 敏感配置必须保持原值,不能被通用系统页伪造请求覆盖。 + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'smtp_host', + 'body' => 'owner.smtp.example', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'vip_payment_app_secret', + 'body' => 'owner-secret', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'wechat_bot_config', + 'body' => '{"api":{"bot_key":"owner-only"}}', + ]); + $this->assertDatabaseHas('sysparam', [ + 'alias' => 'chatbot_max_gold', + 'body' => '5000', + ]); + $this->assertDatabaseMissing('sysparam', [ + 'alias' => 'rogue_secret_token', + ]); + } + + /** + * 创建可访问后台通用系统页的超级管理员账号。 + */ + private function createSuperAdmin(): User + { + return User::factory()->create([ + 'user_level' => 100, + ]); + } + + /** + * 预置通用系统页测试所需的公共参数与敏感参数。 + */ + private function seedSystemParams(): void + { + foreach ($this->systemParams() as $alias => $body) { + Sysparam::updateOrCreate( + ['alias' => $alias], + [ + 'body' => $body, + 'guidetxt' => strtoupper($alias).' 配置说明', + ] + ); + } + } + + /** + * 返回本轮测试覆盖的系统参数样本。 + * + * @return array + */ + private function systemParams(): array + { + return [ + 'sys_name' => '原始聊天室', + 'sys_notice' => '原始公告', + 'smtp_host' => 'owner.smtp.example', + 'vip_payment_app_secret' => 'owner-secret', + 'wechat_bot_config' => '{"api":{"bot_key":"owner-only"}}', + 'chatbot_max_gold' => '5000', + ]; + } +} diff --git a/tests/Feature/RoomRequestSecurityTest.php b/tests/Feature/RoomRequestSecurityTest.php new file mode 100644 index 0000000..14144f3 --- /dev/null +++ b/tests/Feature/RoomRequestSecurityTest.php @@ -0,0 +1,64 @@ +create(['user_level' => 10]); + + $response = $this->actingAs($user)->post(route('rooms.store'), [ + 'name' => '', + 'description' => '危险名称测试', + ]); + + $response->assertSessionHasErrors('name'); + $this->assertDatabaseMissing('rooms', [ + 'room_name' => '', + ]); + } + + /** + * 测试修改房间时同样不能把危险名称写入数据库。 + */ + public function test_cannot_update_room_with_html_like_name(): void + { + $owner = User::factory()->create(); + $room = Room::create([ + 'room_name' => '安全房间', + 'room_owner' => $owner->username, + 'room_keep' => false, + ]); + + $response = $this->actingAs($owner)->from(route('rooms.index'))->put(route('rooms.update', $room->id), [ + 'name' => '', + 'description' => '危险更新测试', + ]); + + $response->assertSessionHasErrors('name'); + $this->assertDatabaseHas('rooms', [ + 'id' => $room->id, + 'room_name' => '安全房间', + ]); + } +}