diff --git a/packages/snowcat-core/helper/log.ts b/packages/snowcat-core/helper/log.ts index e16a743..81c784a 100644 --- a/packages/snowcat-core/helper/log.ts +++ b/packages/snowcat-core/helper/log.ts @@ -336,9 +336,16 @@ function textElementToText (textElement: any): string { return ''; } +// 历史: 同 onebot/api/msg.ts 的特定会话特殊处理, 改为可配置数组, 默认空=不跳过。 +// 如需旧行为: NAPCAT_SPECIAL_PEER_UINS=',' +const SPECIAL_PEER_UINS: string[] = (process.env['NAPCAT_SPECIAL_PEER_UINS'] ?? '') + .split(',') + .map(s => s.trim()) + .filter(s => /^\d+$/.test(s)); + function replyElementToText (replyElement: any, msg: RawMessage, recursiveLevel: number): string { const recordMsgOrNull = msg.records.find((record) => replyElement.sourceMsgIdInRecords === record.msgId); - return `[回复消息 ${recordMsgOrNull && recordMsgOrNull.peerUin !== '284840486' && recordMsgOrNull.peerUin !== '1094950020' + return `[回复消息 ${recordMsgOrNull && !SPECIAL_PEER_UINS.includes(recordMsgOrNull.peerUin) ? rawMessageToText(recordMsgOrNull, recursiveLevel + 1) : `未找到消息记录 (MsgId = ${replyElement.sourceMsgIdInRecords})` }]`; diff --git a/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts b/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts index 4f81c99..a3baacf 100644 --- a/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts +++ b/packages/snowcat-onebot/action/go-cqhttp/GetForwardMsg.ts @@ -7,6 +7,13 @@ import { ChatType, ElementType, MsgSourceType, NTMsgType, RawMessage } from 'sno import { isNumeric } from 'snowcat-common/src/helper'; import { GoCQHTTPActionsExamples } from '../example/GoCQHTTPActionsExamples'; + +// 历史: 兼容层构造的占位消息主体。与 NAPCAT_SPECIAL_PEER_UINS 统一配置。 +const SPECIAL_PEER_UINS: string[] = (process.env['NAPCAT_SPECIAL_PEER_UINS'] ?? '') + .split(',') + .map(s => s.trim()) + .filter(s => /^\d+$/.test(s)); + const PayloadSchema = Type.Object({ message_id: Type.Optional(Type.String({ description: '消息ID' })), id: Type.Optional(Type.String({ description: '消息ID' })), @@ -106,14 +113,14 @@ export class GoCQHTTPGetForwardMsgAction extends OneBotAction s.trim()) + .filter(s => /^\d+$/.test(s)); import { MessageUnique } from 'snowcat-common/src/message-unique'; import { ChatType, @@ -298,7 +306,7 @@ export class OneBotMsgApi { const records = msg.records.find(msgRecord => msgRecord.msgId === element?.sourceMsgIdInRecords); // 特定账号的特殊处理 - if (records && (records.peerUin === '284840486' || records.peerUin === '1094950020')) { + if (records && (SPECIAL_PEER_UINS.includes(records.peerUin))) { return createReplyData(records.msgId); } @@ -416,7 +424,7 @@ export class OneBotMsgApi { // 读取视频链接并兜底 let videoUrlWrappers: Awaited> | undefined; - if (msg.peerUin === '284840486' || msg.peerUin === '1094950020') { + if (SPECIAL_PEER_UINS.includes(msg.peerUin)) { try { videoUrlWrappers = await this.core.apis.FileApi.getVideoUrl({ chatType: msg.chatType, @@ -1171,7 +1179,7 @@ export class OneBotMsgApi { resMsg.sender.nickname = member?.nick ?? member?.cardName ?? '临时会话'; resMsg.temp_source = 0; } else { - resMsg.group_id = 284840486; + resMsg.group_id = SPECIAL_PEER_UINS[0] ? parseInt(SPECIAL_PEER_UINS[0]) : 0; resMsg.temp_source = 0; resMsg.sender.nickname = '临时会话'; } diff --git a/packages/snowcat-webui-backend/src/api/Log.ts b/packages/snowcat-webui-backend/src/api/Log.ts index 40db251..c802938 100644 --- a/packages/snowcat-webui-backend/src/api/Log.ts +++ b/packages/snowcat-webui-backend/src/api/Log.ts @@ -7,8 +7,11 @@ const isMacOS = process.platform === 'darwin'; // 日志脱敏函数 const sanitizeLog = (log: string): string => { - // 脱敏 token 参数,将 token=xxx 替换为 token=*** - return log.replace(/token=[\w\d]+/gi, 'token=***'); + // 脱敏: token=xxx / token: xxx / access_token / refresh_token / credential / Authorization 一律打码 + return log + .replace(/token=[\w\d.\-]+/gi, 'token=***') + .replace(/token[:=]\s*"?[\w\d.\-]+/gi, 'token=***') + .replace(/(access_token|refresh_token|credential|Authorization)[:=]\s*"?[\w\d.\-+/=]+/gi, '$1=***'); }; // 日志记录 export const LogHandler: RequestHandler = async (req, res) => { diff --git a/packages/snowcat-webui-backend/src/api/WebUIConfig.ts b/packages/snowcat-webui-backend/src/api/WebUIConfig.ts index 2944609..06fb12b 100644 --- a/packages/snowcat-webui-backend/src/api/WebUIConfig.ts +++ b/packages/snowcat-webui-backend/src/api/WebUIConfig.ts @@ -18,6 +18,8 @@ export const GetWebUIConfigHandler: RequestHandler = async (_, res) => { ipWhitelist: config.ipWhitelist || [], ipBlacklist: config.ipBlacklist || [], enableXForwardedFor: config.enableXForwardedFor || false, + allowOrigins: config.allowOrigins || [], + // 安全修复:不回吐 token / totpSecret 等敏感字段 }); } catch (error) { const msg = (error as Error).message; @@ -93,7 +95,7 @@ export const GetClientIPHandler: RequestHandler = async (req, res) => { // 更新WebUI基础配置 export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { try { - const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor } = req.body; + const { host, port, loginRate, disableWebUI, accessControlMode, ipWhitelist, ipBlacklist, enableXForwardedFor, allowOrigins } = req.body; const updateConfig: any = {}; @@ -153,6 +155,13 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { updateConfig.enableXForwardedFor = enableXForwardedFor; } + if (allowOrigins !== undefined) { + if (!Array.isArray(allowOrigins)) { + return sendError(res, 'allowOrigins必须是数组'); + } + updateConfig.allowOrigins = allowOrigins; + } + await WebUiConfig.UpdateWebUIConfig(updateConfig); return sendSuccess(res, null); } catch (error) { @@ -161,7 +170,7 @@ export const UpdateWebUIConfigHandler: RequestHandler = async (req, res) => { } }; -// 获取SSL证书状态 +// 获取SSL证书状态(只返回存在性,不回吐证书与私钥内容) export const GetSSLStatusHandler: RequestHandler = async (_, res) => { try { const certPath = join(webUiPathWrapper.configPath, 'cert.pem'); @@ -170,22 +179,12 @@ export const GetSSLStatusHandler: RequestHandler = async (_, res) => { const certExists = existsSync(certPath); const keyExists = existsSync(keyPath); - let certContent = ''; - let keyContent = ''; - - if (certExists) { - certContent = await fsProm.readFile(certPath, 'utf-8'); - } - if (keyExists) { - keyContent = await fsProm.readFile(keyPath, 'utf-8'); - } - + // 安全修复:不再返回 certContent / keyContent(TLS 私钥明文不应经 API 下发)。 + // 前端只需展示「已配置/未配置」状态;如需查看证书详情可自行在服务器上读取。 return sendSuccess(res, { enabled: certExists && keyExists, certExists, keyExists, - certContent, - keyContent, }); } catch (error) { const msg = (error as Error).message; diff --git a/packages/snowcat-webui-backend/src/helper/SignToken.ts b/packages/snowcat-webui-backend/src/helper/SignToken.ts index 6888ad8..ec38e7c 100644 --- a/packages/snowcat-webui-backend/src/helper/SignToken.ts +++ b/packages/snowcat-webui-backend/src/helper/SignToken.ts @@ -1,8 +1,25 @@ import crypto from 'crypto'; +import { scryptSync, randomBytes, timingSafeEqual } from 'crypto'; import store from 'snowcat-common/src/store'; import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/snowcat-webui-backend/src/types'; + +// 常量时间字符串比较,避免逐字节短路造成的时序侧信道 +function safeEqual (a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8'); + const bufB = Buffer.from(b, 'utf8'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} + export class AuthHelper { - private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2); + // JWT/HMAC 签名密钥:优先环境变量;未配置时用 crypto 强随机生成并稳定缓存, + // 不再回落 Math.random()(可预测密钥=凭证可伪造)。 + // 注意:随机密钥在进程重启后失效,所有已签发凭证随之失效,用户需重新登录。 + private static readonly secretKey: string = (() => { + const envKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY']; + if (envKey && envKey.length >= 16) return envKey; + return crypto.randomBytes(32).toString('hex'); + })(); private static readonly MAX_CREDENTIAL_VALID_SECONDS = 3600; public static getSecretKey (): string { @@ -37,7 +54,7 @@ export class AuthHelper { .createHmac('sha256', AuthHelper.secretKey) .update(jsonString, 'utf8') .digest('hex'); - return calculatedHmac === credentialJson.Hmac; + return safeEqual(calculatedHmac, credentialJson.Hmac); } catch (_error) { return false; } @@ -65,7 +82,7 @@ export class AuthHelper { const createdTimeSeconds = Math.floor(Number(credentialJson.Data.CreatedTime)); const timeDifferenceSeconds = currentTimeSeconds - createdTimeSeconds; const isWithinOneHour = timeDifferenceSeconds >= 0 && timeDifferenceSeconds <= AuthHelper.MAX_CREDENTIAL_VALID_SECONDS; - return isWithinOneHour && credentialJson.Data.HashEncoded === AuthHelper.generatePasswordHash(token); + return isWithinOneHour && safeEqual(credentialJson.Data.HashEncoded, AuthHelper.generatePasswordHash(token)); } /** @@ -94,21 +111,39 @@ export class AuthHelper { } /** - * 生成密码Hash + * 生成密码Hash(scrypt,随机盐,N=16384 r=8 p=1)。 + * 存储格式: scrypt$$ * @param password 密码 - * @returns 生成的Hash值 + * @returns 生成的Hash值(含盐与参数,可自校验) */ public static generatePasswordHash (password: string): string { - return crypto.createHash('sha256').update(password + '.napcat').digest().toString('hex'); + const salt = randomBytes(16); + const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; } /** - * 对比密码和Hash值 + * 对比密码和Hash值。 + * 兼容两种输入: + * - 新格式 scrypt$$(逐盐校验,恒定时间比较) + * - 旧格式 单轮 SHA-256 hex(历史凭证,仅供校验,不用于生成) * @param password 密码 * @param hash Hash值 * @returns 布尔值,表示密码是否匹配Hash值 */ public static comparePasswordHash (password: string, hash: string): boolean { - return this.generatePasswordHash(password) === hash; + if (hash.startsWith('scrypt$')) { + const parts = hash.split('$'); + if (parts.length !== 3) return false; + const salt = Buffer.from(parts[1] ?? '', 'hex'); + const expected = Buffer.from(parts[2] ?? '', 'hex'); + if (salt.length !== 16 || expected.length !== 32) return false; + const actual = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 }); + return timingSafeEqual(actual, expected); + } + // 兼容历史数据:旧版单轮 SHA-256(password + '.napcat') + // 老凭证仍可通过校验(不丢功能语义),升级后建议改密码以迁移到 scrypt。 + const legacy = crypto.createHash('sha256').update(password + '.napcat').digest('hex'); + return safeEqual(legacy, hash); } } 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); diff --git a/packages/snowcat-webui-backend/webui.json b/packages/snowcat-webui-backend/webui.json index 5d74c5e..7a9d981 100644 --- a/packages/snowcat-webui-backend/webui.json +++ b/packages/snowcat-webui-backend/webui.json @@ -7,5 +7,6 @@ "accessControlMode": "none", "ipWhitelist": [], "ipBlacklist": [], - "enableXForwardedFor": false + "enableXForwardedFor": false, + "allowOrigins": [] } \ No newline at end of file diff --git a/snowcat-launch-9937.ps1 b/snowcat-launch-9937.ps1 index 9e38443..aab086d 100644 --- a/snowcat-launch-9937.ps1 +++ b/snowcat-launch-9937.ps1 @@ -39,5 +39,5 @@ $env:NAPCAT_INJECT_PATH = Join-Path $NapcatDir 'NapCatWinBootHook.dll' $env:NAPCAT_MAIN_PATH = $mainJs Write-Host ("[OK] qqnt=" + $app.version + " build=" + $qqnt['buildVersion']) $logFile = Join-Path $NapcatDir ('snowcat-boot5-' + (Get-Date -Format 'HHmmss') + '.log') -Start-Process -FilePath (Join-Path $NapcatDir 'NapCatWinBootMain.exe') -ArgumentList ('"' + $QQPath + '"', '"' + (Join-Path $NapcatDir 'NapCatWinBootHook.dll') + '"', '-q3430961870') -WindowStyle Hidden -RedirectStandardError $logFile +Start-Process -FilePath (Join-Path $NapcatDir 'NapCatWinBootMain.exe') -ArgumentList ('"' + $QQPath + '"', '"' + (Join-Path $NapcatDir 'NapCatWinBootHook.dll') + '"', '-q') -WindowStyle Hidden -RedirectStandardError $logFile Write-Host 'launched' diff --git a/snowcat-launch.ps1 b/snowcat-launch.ps1 index 83046f0..59dc785 100644 --- a/snowcat-launch.ps1 +++ b/snowcat-launch.ps1 @@ -4,11 +4,11 @@ # ① 自动发现 QQ:注册表 → 常见路径 → D:/OneKey 便携 → 找不到给一键安装提示 # ② 自动读真实版本:versions/*/resources/app/package.json(废手工 qqnt.json 钉版) # ③ 自动生成 qqnt.json + loadNapCat.js 到运行时目录 -# 用法: .\snowcat-launch.ps1 [-Account 3430961870] [-NapcatDir C:\dsh\.tmp\napcat-shell] [-QQPath 自定义QQ.exe] +# 用法: .\snowcat-launch.ps1 [-Account ] [-NapcatDir C:\dsh\.tmp\napcat-shell] [-QQPath 自定义QQ.exe] # 2026-11-11 立项 · SnowCat Project param( - [string]$Account = '3430961870', + [string]$Account = '', [string]$NapcatDir = 'C:\dsh\.tmp\napcat-shell', [string]$QQPath = '', [switch]$InstallIfMissing