Merge pull request #51 from DandanLLab/fix/security-audit-2026-11-13

fix: 安全审计修复八条(社区发现致谢 #11 #13 #14)
This commit is contained in:
蛋蛋呀~ authored and GitHub committed 2026-11-13 04:48:34 +08:00
commit 72c3796c58
11 files changed
+111 -54

No files matched your search

+8 -1
View File
@@ -336,9 +336,16 @@ function textElementToText (textElement: any): string {
return '';
}
// 历史: 同 onebot/api/msg.ts 的特定会话特殊处理, 改为可配置数组, 默认空=不跳过。
// 如需旧行为: NAPCAT_SPECIAL_PEER_UINS='<REDACTED>,<REDACTED>'
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})`
}]`;
@@ -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<PayloadType, Retur
peerUid: '',
},
peerName: '',
peerUid: '284840486',
peerUin: '284840486',
peerUid: SPECIAL_PEER_UINS[0] ?? '',
peerUin: SPECIAL_PEER_UINS[0] ?? '',
recallTime: '0',
records: [],
sendNickName: '',
sendRemarkName: '',
senderUid: '',
senderUin: '1094950020',
senderUin: SPECIAL_PEER_UINS[1] ?? '',
sourceType: MsgSourceType.K_DOWN_SOURCETYPE_UNKNOWN,
subMsgType: 1,
};
+11 -3
View File
@@ -1,4 +1,12 @@
import { FileNapCatOneBotUUID } from 'snowcat-common/src/file-uuid';
// 历史: 特定会话(如文件传输助手/官方助手类号)走特殊回包与视频URL通道。
// 改造: 从硬编码字面量改为可配置数组(环境变量优先, 默认空=不做特殊处理), 保留原分支结构。
// 如需恢复旧行为, 配置 NAPCAT_SPECIAL_PEER_UINS='<REDACTED>,<REDACTED>' 即可。
const SPECIAL_PEER_UINS: string[] = (process.env['NAPCAT_SPECIAL_PEER_UINS'] ?? '')
.split(',')
.map(s => 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<ReturnType<typeof this.core.apis.FileApi.getVideoUrl>> | 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 = '临时会话';
}
@@ -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) => {
@@ -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;
@@ -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$<saltHex>$<hashHex>
* @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$<saltHex>$<hashHex>(逐盐校验,恒定时间比较)
* - 旧格式 单轮 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);
}
}
@@ -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 密钥
@@ -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 || '*';
// 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);
+2 -1
View File
@@ -7,5 +7,6 @@
"accessControlMode": "none",
"ipWhitelist": [],
"ipBlacklist": [],
"enableXForwardedFor": false
"enableXForwardedFor": false,
"allowOrigins": []
}
+1 -1
View File
@@ -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<Account>') -WindowStyle Hidden -RedirectStandardError $logFile
Write-Host 'launched'
+2 -2
View File
@@ -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 <REDACTED>] [-NapcatDir C:\dsh\.tmp\napcat-shell] [-QQPath 自定义QQ.exe]
# 2026-11-11 立项 · SnowCat Project
param(
[string]$Account = '3430961870',
[string]$Account = '<REDACTED>',
[string]$NapcatDir = 'C:\dsh\.tmp\napcat-shell',
[string]$QQPath = '',
[switch]$InstallIfMissing