收紧输入渲染与后台配置权限

This commit is contained in:
2026-04-19 14:43:02 +08:00
parent ba6406ed68
commit 438241e878
12 changed files with 550 additions and 48 deletions
+17 -1
View File
@@ -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);
}
}
+52 -13
View File
@@ -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']);
}
}
+36 -1
View File
@@ -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' => '发言动作不合法,请重新选择。',
];
}
+25 -3
View File
@@ -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' => '房间名称不能包含尖括号。',
];
}
}
+32 -3
View File
@@ -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 -5
View File
@@ -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>';
+45 -11
View File
@@ -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',
];
}
}
+64
View File
@@ -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' => '安全房间',
]);
}
}