validate([ 'target' => ['required', 'in:user,room'], 'target_id' => ['required'], 'options' => ['required', 'array'], 'options.icon' => ['nullable', 'string', 'max:20'], 'options.title' => ['nullable', 'string', 'max:50'], 'options.name' => ['nullable', 'string', 'max:100'], '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'], ]); // 所有可见文案一律降级为纯文本,避免允许标签残留属性后在前端 innerHTML 中执行。 $opts = $validated['options']; foreach (['title', 'name', 'body', 'sub'] as $field) { if (isset($opts[$field])) { $opts[$field] = $this->sanitizeBannerText($opts[$field]); } } 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'] = $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'; return $btn; }, $opts['buttons']); } broadcast(new BannerNotification( target: $validated['target'], targetId: $validated['target_id'], options: $opts, )); 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; } }