修复认证与基础安全链路
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user