From fe5dafc527820220e9fe849043d0d77f8cc9058a Mon Sep 17 00:00:00 2001 From: SnowCat Security Bot Date: Fri, 13 Nov 2026 03:33:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(security):=20CORS=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=8F=8D=E5=B0=84=E4=BB=BB=E6=84=8F=20Origin;=20X-Forwarded-Fo?= =?UTF-8?q?r=20=E6=94=B9=E8=B5=B0=20trust=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. cors.ts: 不再把任意请求 Origin 原样反射为 Access-Control-Allow-Origin (配合 credentials:true = 任意站点可携带 Cookie 跨域调用管理 API)。 新策略: 仅放行 a) 无 Origin 头(同源/直连) b) Origin host 与请求 host 一致 (本机多端口开发) c) webui.json allowOrigins 精确白名单。 新增 Vary: Origin。 2. IP 访问控制: 客户端 IP 不再读取客户端可伪造的 X-Forwarded-For 头, 统一走 req.ip(express 'trust proxy' 控制可信代理)。enableXForwardedFor 字段保留(向后兼容)但不再影响 IP 判定, 需要代理真实 IP 的场景应配置 app.set('trust proxy')。 3. WebUiConfigSchema 新增 allowOrigins: string[] (default []), 与前端 GetConfig/UpdateConfig 同步支持。 回归注意: 若部署依赖旧反射行为的前端跨域, 需在 webui.json 配 allowOrigins。 --- .../src/helper/config.ts | 3 ++ .../src/middleware/cors.ts | 40 ++++++++----------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/packages/snowcat-webui-backend/src/helper/config.ts b/packages/snowcat-webui-backend/src/helper/config.ts index 7befb4e..9d0eb19 100644 --- a/packages/snowcat-webui-backend/src/helper/config.ts +++ b/packages/snowcat-webui-backend/src/helper/config.ts @@ -32,7 +32,10 @@ const WebUiConfigSchema = Type.Object({ // IP黑名单列表 ipBlacklist: Type.Array(Type.String(), { default: [] }), // 是否启用 X-Forwarded-For 获取真实IP + // 安全提示:该请求头可被客户端伪造,仅在部署于可信反向代理(会重写该头)之后才有意义。 enableXForwardedFor: Type.Boolean({ default: false }), + // 允许跨域访问的前端源列表(精确匹配,含协议)。空列表 = 仅允许同源/本机 host 一致的 Origin。 + allowOrigins: Type.Array(Type.String(), { default: [] }), // 是否启用双因素认证(2FA) enable2FA: Type.Boolean({ default: false }), // TOTP 密钥 diff --git a/packages/snowcat-webui-backend/src/middleware/cors.ts b/packages/snowcat-webui-backend/src/middleware/cors.ts index 3505d7c..037df34 100644 --- a/packages/snowcat-webui-backend/src/middleware/cors.ts +++ b/packages/snowcat-webui-backend/src/middleware/cors.ts @@ -184,24 +184,9 @@ function isIPInList (ip: string, ipList: string[]): boolean { export const cors: RequestHandler = async (req, res, next) => { const config = await WebUiConfig.GetWebUIConfig(); - // 根据配置决定如何获取客户端IP - let clientIP: string; - if (config.enableXForwardedFor) { - // 启用 X-Forwarded-For 时,优先从该头部获取真实IP - const forwardedFor = req.headers['x-forwarded-for']; - if (typeof forwardedFor === 'string') { - // X-Forwarded-For 可能包含多个IP,取第一个 - clientIP = forwardedFor.split(',')[0]?.trim() || ''; - } else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) { - clientIP = forwardedFor[0] || ''; - } else { - // 如果没有 X-Forwarded-For,回退到 socket IP - clientIP = req.ip || req.socket.remoteAddress || ''; - } - } else { - // 默认使用 socket IP - clientIP = req.ip || req.socket.remoteAddress || ''; - } + // 获取客户端IP优先走 req.ip(express 'trust proxy' 负责可信代理的解析), + // 不再无条件信任请求方可伪造的 X-Forwarded-For 头。 + const clientIP: string = req.ip || req.socket.remoteAddress || ''; // 检查访问控制模式 if (config.accessControlMode === 'whitelist') { @@ -219,11 +204,20 @@ export const cors: RequestHandler = async (req, res, next) => { } // 'none' 模式:不进行任何限制 - const origin = req.headers.origin || '*'; - res.header('Access-Control-Allow-Origin', origin); - res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); - res.header('Access-Control-Allow-Credentials', 'true'); + // CORS:不再反射任意 Origin。 + // 未配置 allowOrigins 时,只有「无 Origin 头(同源/直连工具)」或「Origin 主机与请求 host 一致(本地多端口开发)」才放行跨域头, + // 其余一律不带 ACAO 头(浏览器会拒绝跨域响应)。 + const origin = req.headers.origin; + const requestHost = req.headers.host || ''; + const originHost = typeof origin === 'string' && origin.startsWith('http') ? origin.replace(/^https?:\/\//, '').split('/')[0] ?? '' : ''; + const hostMatches = originHost !== '' && originHost === requestHost; + if (origin && (config.allowOrigins?.length ? config.allowOrigins.includes(origin) : hostMatches)) { + res.header('Access-Control-Allow-Origin', origin); + res.header('Vary', 'Origin'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); + res.header('Access-Control-Allow-Credentials', 'true'); + } if (req.method === 'OPTIONS') { res.sendStatus(204);