Merge pull request #51 from DandanLLab/fix/security-audit-2026-11-13
fix: 安全审计修复八条(社区发现致谢 #11 #13 #14)
This commit is contained in:
11 files changed
+115
-58
No files matched your search
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 || '*';
|
||||
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);
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"accessControlMode": "none",
|
||||
"ipWhitelist": [],
|
||||
"ipBlacklist": [],
|
||||
"enableXForwardedFor": false
|
||||
"enableXForwardedFor": false,
|
||||
"allowOrigins": []
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user