fix(security): CORS 不再反射任意 Origin; X-Forwarded-For 改走 trust proxy

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。
This commit is contained in:
SnowCat Security Bot committed 2026-11-13 03:33:30 +08:00
1 parent 6f9d3cdb0f
commit fe5dafc527
2 files changed
+16 -19

No files matched your search

@@ -32,7 +32,10 @@ const WebUiConfigSchema = Type.Object({
// IP黑名单列表 // IP黑名单列表
ipBlacklist: Type.Array(Type.String(), { default: [] }), ipBlacklist: Type.Array(Type.String(), { default: [] }),
// 是否启用 X-Forwarded-For 获取真实IP // 是否启用 X-Forwarded-For 获取真实IP
// 安全提示:该请求头可被客户端伪造,仅在部署于可信反向代理(会重写该头)之后才有意义。
enableXForwardedFor: Type.Boolean({ default: false }), enableXForwardedFor: Type.Boolean({ default: false }),
// 允许跨域访问的前端源列表(精确匹配,含协议)。空列表 = 仅允许同源/本机 host 一致的 Origin。
allowOrigins: Type.Array(Type.String(), { default: [] }),
// 是否启用双因素认证(2FA) // 是否启用双因素认证(2FA)
enable2FA: Type.Boolean({ default: false }), enable2FA: Type.Boolean({ default: false }),
// TOTP 密钥 // TOTP 密钥
@@ -184,24 +184,9 @@ function isIPInList (ip: string, ipList: string[]): boolean {
export const cors: RequestHandler = async (req, res, next) => { export const cors: RequestHandler = async (req, res, next) => {
const config = await WebUiConfig.GetWebUIConfig(); const config = await WebUiConfig.GetWebUIConfig();
// 根据配置决定如何获取客户端IP // 获取客户端IP优先走 req.ip(express 'trust proxy' 负责可信代理的解析),
let clientIP: string; // 不再无条件信任请求方可伪造的 X-Forwarded-For 头。
if (config.enableXForwardedFor) { const clientIP: string = req.ip || req.socket.remoteAddress || '';
// 启用 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 || '';
}
// 检查访问控制模式 // 检查访问控制模式
if (config.accessControlMode === 'whitelist') { if (config.accessControlMode === 'whitelist') {
@@ -219,11 +204,20 @@ export const cors: RequestHandler = async (req, res, next) => {
} }
// 'none' 模式:不进行任何限制 // 'none' 模式:不进行任何限制
const origin = req.headers.origin || '*'; // 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('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-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-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Credentials', 'true');
}
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.sendStatus(204); res.sendStatus(204);