收紧输入渲染与后台配置权限
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}》— <a href=\"{$url}\" target=\"_blank\" class=\"underline\">点击查看详情</a>",
|
||||
'content' => "📢 【版本更新 {$safeTypeLabel}】v{$safeVersion}《{$safeTitle}》— <a href=\"{$detailUrl}\" target=\"_blank\" rel=\"noopener\" class=\"underline\">点击查看详情</a>",
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => '发言动作不合法,请重新选择。',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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' => '房间名称不能包含尖括号。',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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' => '房间名称不能包含尖括号。',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,27 +17,39 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mx-6 mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg text-amber-800 text-sm">
|
||||
通用系统参数页仅维护低敏公共配置;SMTP、VIP 支付、微信机器人、AI 机器人等站长专属敏感项已迁移到各自独立页面。
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form action="{{ route('admin.system.update') }}" method="POST">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
@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
|
||||
<div>
|
||||
<label class="block text-sm font-bold text-gray-700 mb-2">
|
||||
{{ $descriptions[$alias] ?? $alias }}
|
||||
<span class="text-gray-400 font-normal ml-2">[{{ $alias }}]</span>
|
||||
</label>
|
||||
@if (strlen($body) > 50 || str_contains($body, "\n") || str_contains($body, '<'))
|
||||
@if ($shouldUseTextarea)
|
||||
<textarea name="{{ $alias }}" rows="4"
|
||||
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">{{ $body }}</textarea>
|
||||
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 }}</textarea>
|
||||
@else
|
||||
<input type="text" name="{{ $alias }}" value="{{ $body }}"
|
||||
<input type="text" name="{{ $alias }}" value="{{ $fieldValue }}"
|
||||
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">
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
@empty
|
||||
<div class="rounded-lg border border-dashed border-gray-300 bg-gray-50 px-4 py-6 text-sm text-gray-500">
|
||||
当前没有可在通用系统页维护的公共参数,请前往对应专属配置页处理敏感模块参数。
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t flex space-x-3">
|
||||
|
||||
@@ -108,6 +108,18 @@
|
||||
|
||||
{{-- ── 手机端抽屉控制脚本 ── --}}
|
||||
<script>
|
||||
/**
|
||||
* 对手机端抽屉中的动态文本做 HTML 转义,避免直接拼入 innerHTML。
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
function escapeMobileDrawerHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前打开的抽屉名称:'toolbar' | 'users' | null
|
||||
*
|
||||
@@ -205,7 +217,7 @@
|
||||
const users = Object.keys(onlineUsers);
|
||||
container.innerHTML = users.length
|
||||
? users.filter(u => !keyword || u.toLowerCase().includes(keyword))
|
||||
.map(u => `<div class="user-item" style="padding:5px 8px;font-size:12px;border-bottom:1px solid #eee;">${u}</div>`).join('')
|
||||
.map(u => `<div class="user-item" style="padding:5px 8px;font-size:12px;border-bottom:1px solid #eee;">${escapeMobileDrawerHtml(u)}</div>`).join('')
|
||||
: '<div style="text-align:center;color:#aaa;padding:20px;font-size:12px;">暂无用户</div>';
|
||||
}
|
||||
|
||||
@@ -232,25 +244,34 @@
|
||||
return;
|
||||
}
|
||||
const currentRoomId = window.chatContext?.roomId;
|
||||
container.innerHTML = data.rooms.map(room => {
|
||||
const isCurrent = room.id === currentRoomId;
|
||||
const roomRows = data.rooms.map(room => {
|
||||
const roomId = Number.parseInt(room.id, 10);
|
||||
if (!Number.isInteger(roomId)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isCurrent = roomId === currentRoomId;
|
||||
const bg = isCurrent ? '#ecf4ff' : '#fff';
|
||||
const nameColor = isCurrent ? '#336699' : (room.door_open ? '#444' : '#bbb');
|
||||
const badge = room.online > 0
|
||||
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 6px;font-size:10px;font-weight:bold;">${room.online}人</span>`
|
||||
const safeRoomName = escapeMobileDrawerHtml(String(room.name ?? ''));
|
||||
const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0);
|
||||
const badge = safeOnlineCount > 0
|
||||
? `<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 6px;font-size:10px;font-weight:bold;">${safeOnlineCount}人</span>`
|
||||
: `<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 6px;font-size:10px;">空</span>`;
|
||||
const currentTag = isCurrent ? `<span style="font-size:9px;color:#7090b0;margin-left:3px;">当前</span>` : '';
|
||||
const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${room.id}'"`;
|
||||
const clickAttr = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
|
||||
|
||||
return `<div ${clickAttr}
|
||||
style="display:flex;align-items:center;justify-content:space-between;
|
||||
padding:6px 10px;border-bottom:1px solid #eef2f8;background:${bg};
|
||||
cursor:${isCurrent ? 'default' : 'pointer'};">
|
||||
<span style="color:${nameColor};font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:6px;">
|
||||
${room.name}${currentTag}
|
||||
${safeRoomName}${currentTag}
|
||||
</span>${badge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
container.innerHTML = roomRows || '<div style="text-align:center;color:#bbb;padding:16px;font-size:11px;">暂无房间</div>';
|
||||
})
|
||||
.catch(() => {
|
||||
container.innerHTML = '<div style="text-align:center;color:#f00;padding:10px;font-size:11px;">加载失败</div>';
|
||||
|
||||
@@ -556,19 +556,26 @@
|
||||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = data.rooms.map(room => {
|
||||
const isCurrent = room.id === _currentRoomId;
|
||||
const roomRows = data.rooms.map(room => {
|
||||
const roomId = Number.parseInt(room.id, 10);
|
||||
if (!Number.isInteger(roomId)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const isCurrent = roomId === _currentRoomId;
|
||||
const closed = !room.door_open;
|
||||
const safeRoomName = escapeHtml(String(room.name ?? ''));
|
||||
const safeOnlineCount = Math.max(Number.parseInt(room.online, 10) || 0, 0);
|
||||
const bg = isCurrent ? '#ecf4ff' : '#fff';
|
||||
const border = isCurrent ? '#aac5f0' : '#e0eaf5';
|
||||
const nameColor = isCurrent ? '#336699' : (closed ? '#bbb' : '#444');
|
||||
const badge = room.online > 0 ?
|
||||
`<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 5px;font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${room.online} 人</span>` :
|
||||
const badge = safeOnlineCount > 0 ?
|
||||
`<span style="background:#e8f5e9;color:#2e7d32;border-radius:8px;padding:0 5px;font-size:10px;font-weight:bold;white-space:nowrap;flex-shrink:0;">${safeOnlineCount} 人</span>` :
|
||||
`<span style="background:#f5f5f5;color:#bbb;border-radius:8px;padding:0 5px;font-size:10px;white-space:nowrap;flex-shrink:0;">空</span>`;
|
||||
const currentTag = isCurrent ?
|
||||
`<span style="font-size:9px;color:#336699;opacity:.7;margin-left:3px;">当前</span>` :
|
||||
'';
|
||||
const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${room.id}'"`;
|
||||
const clickHandler = isCurrent ? '' : `onclick="location.href='/room/${roomId}'"`;
|
||||
|
||||
return `<div ${clickHandler}
|
||||
style="display:flex;align-items:center;justify-content:space-between;
|
||||
@@ -579,11 +586,14 @@
|
||||
onmouseover="if(${!isCurrent}) this.style.background='#ddeeff';"
|
||||
onmouseout="this.style.background='${bg}';">
|
||||
<span style="color:${nameColor};font-size:11px;font-weight:${isCurrent?'bold':'normal'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;margin-right:4px;">
|
||||
${room.name}${currentTag}
|
||||
${safeRoomName}${currentTag}
|
||||
</span>
|
||||
${badge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}).filter(Boolean).join('');
|
||||
|
||||
container.innerHTML = roomRows ||
|
||||
'<div style="text-align:center;color:#bbb;padding:16px 0;font-size:11px;">暂无房间</div>';
|
||||
})
|
||||
.catch(() => {
|
||||
container.innerHTML =
|
||||
@@ -1030,7 +1040,7 @@
|
||||
// 生成自然语序的动作串:情绪型=[人][着/地]对[目标][verb]:;动作型=[人][了][目标],[verb]:
|
||||
const buildActionStr = (action, fromHtml, toHtml, verb = '说') => {
|
||||
const info = actionTextMap[action];
|
||||
if (!info) return `${fromHtml}对${toHtml}${action}${verb}:`;
|
||||
if (!info) return `${fromHtml}对${toHtml}${escapeHtml(String(action || ''))}${verb}:`;
|
||||
if (info.type === 'emotion') return `${fromHtml}${info.word}对${toHtml}${verb}:`;
|
||||
return `${fromHtml}${info.word}${toHtml},${verb}:`;
|
||||
};
|
||||
@@ -1437,6 +1447,7 @@
|
||||
.listen('ScreenCleared', (e) => {
|
||||
console.log('收到全员清屏事件:', e);
|
||||
const operator = e.operator;
|
||||
const safeOperator = escapeHtml(String(operator || ''));
|
||||
|
||||
// 清除公聊窗口所有消息
|
||||
const say1 = document.getElementById('chat-messages-container');
|
||||
@@ -1462,7 +1473,7 @@
|
||||
now.getMinutes().toString().padStart(2, '0') + ':' +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
sysDiv.innerHTML =
|
||||
`<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${operator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
||||
`<span style="color: #dc2626; font-weight: bold;">🧹 管理员 <b>${safeOperator}</b> 已执行全员清屏</span><span class="msg-time">(${timeStr})</span>`;
|
||||
if (say1) {
|
||||
say1.appendChild(sysDiv);
|
||||
say1.scrollTop = say1.scrollHeight;
|
||||
@@ -1494,6 +1505,9 @@
|
||||
const timeStr = now.getHours().toString().padStart(2, '0') + ':' +
|
||||
now.getMinutes().toString().padStart(2, '0') + ':' +
|
||||
now.getSeconds().toString().padStart(2, '0');
|
||||
const safeVersion = e.safe_version ?? escapeHtml(String(e.version ?? ''));
|
||||
const safeTitle = e.safe_title ?? escapeHtml(String(e.title ?? ''));
|
||||
const safeUrl = escapeHtml(normalizeSafeChatUrl(e.url, '{{ route('changelog.index') }}'));
|
||||
|
||||
const sysDiv = document.createElement('div');
|
||||
sysDiv.className = 'msg-line';
|
||||
@@ -1501,8 +1515,8 @@
|
||||
sysDiv.style.cssText =
|
||||
'background: #fffbeb; border-left: 3px solid #d97706; border-radius: 4px; padding: 5px 10px; margin: 3px 0;';
|
||||
sysDiv.innerHTML = `<span style="color: #b45309; font-weight: bold;">
|
||||
📋 【版本更新】v${e.version} · ${e.title}
|
||||
<a href="${e.url}" target="_blank" rel="noopener"
|
||||
📋 【版本更新】v${safeVersion} · ${safeTitle}
|
||||
<a href="${safeUrl}" target="_blank" rel="noopener"
|
||||
style="color: #7c3aed; text-decoration: underline; margin-left: 8px; font-size: 0.85em;">
|
||||
查看详情 →
|
||||
</a>
|
||||
@@ -2405,4 +2419,24 @@
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规整广播携带的链接,只允许当前站点的 http(s) 地址进入 innerHTML。
|
||||
*/
|
||||
function normalizeSafeChatUrl(url, fallback) {
|
||||
try {
|
||||
const parsedUrl = new URL(url || fallback, window.location.origin);
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (parsedUrl.origin !== window.location.origin) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台更新日志安全测试
|
||||
* 验证广播 payload 与系统消息在发布时会对危险标题进行转义。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature\Feature;
|
||||
|
||||
use App\Events\ChangelogPublished;
|
||||
use App\Jobs\SaveMessageJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 后台更新日志安全测试
|
||||
* 负责回归标题进入广播和系统公告前会被安全处理。
|
||||
*/
|
||||
class AdminChangelogControllerSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试发布更新日志时会对广播字段与系统消息正文做 XSS 防护。
|
||||
*/
|
||||
public function test_published_changelog_escapes_title_for_broadcast_and_system_message(): void
|
||||
{
|
||||
Event::fake([ChangelogPublished::class]);
|
||||
Queue::fake();
|
||||
|
||||
$admin = User::factory()->create([
|
||||
'id' => 1,
|
||||
'user_level' => 100,
|
||||
]);
|
||||
|
||||
$dangerousTitle = '<img src=x onerror=alert(1)>';
|
||||
$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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:后台通用系统参数页权限边界测试
|
||||
*
|
||||
* 覆盖通用系统参数页对站长专属敏感配置的读写隔离,
|
||||
* 防止 SMTP、VIP 支付、微信机器人及 AI 机器人等配置被越权访问。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature\Feature;
|
||||
|
||||
use App\Models\Sysparam;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 类功能:验证后台通用系统参数页只允许维护白名单公共配置。
|
||||
*/
|
||||
class AdminSystemControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 验证通用系统参数页不会展示站长专属敏感配置。
|
||||
*/
|
||||
public function test_system_page_does_not_show_site_owner_only_sensitive_configs(): void
|
||||
{
|
||||
$this->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<string, string>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 文件功能:房间名称安全校验测试
|
||||
* 验证建房与改房时会拦截可能注入前端的危险名称。
|
||||
*/
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 房间名称安全校验测试
|
||||
* 负责回归房间名称中的尖括号会被后端验证直接拦截。
|
||||
*/
|
||||
class RoomRequestSecurityTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试建房时不能提交包含尖括号的危险房间名称。
|
||||
*/
|
||||
public function test_cannot_create_room_with_html_like_name(): void
|
||||
{
|
||||
$user = User::factory()->create(['user_level' => 10]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('rooms.store'), [
|
||||
'name' => '<img src=x onerror=alert(1)>',
|
||||
'description' => '危险名称测试',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
$this->assertDatabaseMissing('rooms', [
|
||||
'room_name' => '<img src=x onerror=alert(1)>',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试修改房间时同样不能把危险名称写入数据库。
|
||||
*/
|
||||
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' => '<svg onload=alert(1)>',
|
||||
'description' => '危险更新测试',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('name');
|
||||
$this->assertDatabaseHas('rooms', [
|
||||
'id' => $room->id,
|
||||
'room_name' => '安全房间',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user