@wenbin_wb/dsh-bridge 2.7.1 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ // 跨平台文件系统与工作区路径安全校验模块
2
+ // 防范任意路径遍历、系统关键目录越权与符号链接逃逸
3
+
4
+ import { resolve, normalize, isAbsolute, basename } from 'node:path';
5
+ import { homedir, platform } from 'node:os';
6
+ import { stat, realpath } from 'node:fs/promises';
7
+
8
+ const isWin = platform() === 'win32';
9
+
10
+ // 敏感隐藏文件夹 / 文件名黑名单(全平台)
11
+ const SENSITIVE_NAMES = new Set([
12
+ '.ssh',
13
+ '.gnupg',
14
+ '.aws',
15
+ '.azure',
16
+ '.kube',
17
+ '.git',
18
+ '.svn',
19
+ '.hg',
20
+ '.bash_history',
21
+ '.zsh_history',
22
+ '.profile',
23
+ '.bash_profile',
24
+ '.bashrc',
25
+ '.zshrc',
26
+ '.netrc',
27
+ 'id_rsa',
28
+ 'id_ed25519',
29
+ 'id_ecdsa',
30
+ 'id_dsa',
31
+ 'credentials',
32
+ 'shadow',
33
+ 'passwd',
34
+ '.env',
35
+ '.npmrc',
36
+ ]);
37
+
38
+ // Windows 系统敏感前缀
39
+ const WIN_SYSTEM_PREFIXES = [
40
+ '\\\\', // UNC 路径限制
41
+ 'windows',
42
+ 'winnt',
43
+ 'program files',
44
+ 'program files (x86)',
45
+ 'system volume information',
46
+ '$recycle.bin',
47
+ 'recovery',
48
+ 'perflogs',
49
+ 'boot',
50
+ 'programdata\\microsoft',
51
+ ];
52
+
53
+ // POSIX 系统敏感前缀
54
+ const POSIX_SYSTEM_PREFIXES = [
55
+ '/etc',
56
+ '/root',
57
+ '/sys',
58
+ '/proc',
59
+ '/dev',
60
+ '/boot',
61
+ '/lib',
62
+ '/lib64',
63
+ '/lib32',
64
+ '/usr/bin',
65
+ '/usr/sbin',
66
+ '/bin',
67
+ '/sbin',
68
+ '/private',
69
+ '/var/run',
70
+ '/var/root',
71
+ ];
72
+
73
+ /**
74
+ * 判断文件名/单级目录名是否属于敏感目录
75
+ */
76
+ export function isSensitiveFolderName(name) {
77
+ if (!name || typeof name !== 'string') return true;
78
+ const lower = name.toLowerCase().trim();
79
+ if (SENSITIVE_NAMES.has(lower)) return true;
80
+ if (/^(\$recycle\.bin|system volume information|\.ssh|\.gnupg|\.aws|\.git)$/i.test(lower)) return true;
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * 校验路径是否安全可作为工作区或被远程浏览
86
+ * @param {string} targetPath 目标路径
87
+ * @param {object} options 配置项
88
+ * @returns {Promise<{ valid: boolean, error?: string, path?: string, realPath?: string }>}
89
+ */
90
+ export async function isSafeWorkspacePath(targetPath, options = {}) {
91
+ if (!targetPath || typeof targetPath !== 'string') {
92
+ return { valid: false, error: '路径不能为空' };
93
+ }
94
+
95
+ const trimmed = targetPath.trim();
96
+ if (!trimmed || trimmed.includes('\0')) {
97
+ return { valid: false, error: '非法路径字符 (Null byte)' };
98
+ }
99
+
100
+ // 1. 基础路径归一化
101
+ let normalized;
102
+ try {
103
+ let raw = trimmed;
104
+ if (isWin && /^[A-Za-z]:$/.test(raw)) {
105
+ raw = `${raw}\\`;
106
+ }
107
+ normalized = normalize(resolve(raw));
108
+ } catch (err) {
109
+ return { valid: false, error: `路径解析失败: ${err.message}` };
110
+ }
111
+
112
+ const lowerNormalized = normalized.toLowerCase();
113
+
114
+ // 2. 检查 Windows 敏感路径
115
+ if (isWin) {
116
+ // 提取盘符后面的相对部分
117
+ const driveMatch = normalized.match(/^[A-Za-z]:\\(.*)$/);
118
+ if (driveMatch) {
119
+ const rest = driveMatch[1].toLowerCase();
120
+ for (const prefix of WIN_SYSTEM_PREFIXES) {
121
+ if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
122
+ return { valid: false, error: `安全拦截:禁止访问系统敏感目录 (${prefix})` };
123
+ }
124
+ }
125
+ } else if (normalized.startsWith('\\\\')) {
126
+ return { valid: false, error: '安全拦截:禁止访问 UNC 网络共享路径' };
127
+ }
128
+ } else {
129
+ // 3. 检查 POSIX 敏感路径
130
+ for (const prefix of POSIX_SYSTEM_PREFIXES) {
131
+ if (lowerNormalized === prefix || lowerNormalized.startsWith(`${prefix}/`)) {
132
+ return { valid: false, error: `安全拦截:禁止访问系统关键目录 (${prefix})` };
133
+ }
134
+ }
135
+ }
136
+
137
+ // 4. 检查敏感隐藏目录片段(如路径中包含 /.ssh/ 或 \.ssh\)
138
+ const pathParts = normalized.split(/[\\/]/).filter(Boolean);
139
+ for (const part of pathParts) {
140
+ if (isSensitiveFolderName(part)) {
141
+ return { valid: false, error: `安全拦截:禁止访问敏感配置目录「${part}」` };
142
+ }
143
+ }
144
+
145
+ // 5. 校验物理文件系统状态与符号链接(Symlink)真实目标
146
+ let realTargetPath;
147
+ try {
148
+ const s = await stat(normalized);
149
+ if (!s.isDirectory()) {
150
+ return { valid: false, error: `指定路径不是文件夹: ${normalized}` };
151
+ }
152
+ realTargetPath = await realpath(normalized);
153
+ } catch (err) {
154
+ // 若仅是做路径规范性预校验(未必须已存在)
155
+ if (options.allowNonExistent) {
156
+ return { valid: true, path: normalized };
157
+ }
158
+ return { valid: false, error: `无法访问指定路径 (${err.message})` };
159
+ }
160
+
161
+ // 6. 对符号链接解析后的真实物理路径进行二次黑名单校验(防范符号链接逃逸)
162
+ if (realTargetPath && realTargetPath !== normalized) {
163
+ const realLower = realTargetPath.toLowerCase();
164
+ if (isWin) {
165
+ const realDriveMatch = realTargetPath.match(/^[A-Za-z]:\\(.*)$/);
166
+ if (realDriveMatch) {
167
+ const rest = realDriveMatch[1].toLowerCase();
168
+ for (const prefix of WIN_SYSTEM_PREFIXES) {
169
+ if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
170
+ return { valid: false, error: `安全拦截:符号链接指向系统敏感目录 (${prefix})` };
171
+ }
172
+ }
173
+ }
174
+ } else {
175
+ for (const prefix of POSIX_SYSTEM_PREFIXES) {
176
+ if (realLower === prefix || realLower.startsWith(`${prefix}/`)) {
177
+ return { valid: false, error: `安全拦截:符号链接指向系统关键目录 (${prefix})` };
178
+ }
179
+ }
180
+ }
181
+
182
+ const realParts = realTargetPath.split(/[\\/]/).filter(Boolean);
183
+ for (const part of realParts) {
184
+ if (isSensitiveFolderName(part)) {
185
+ return { valid: false, error: `安全拦截:符号链接指向敏感配置目录「${part}」` };
186
+ }
187
+ }
188
+ }
189
+
190
+ return {
191
+ valid: true,
192
+ path: normalized,
193
+ realPath: realTargetPath || normalized,
194
+ };
195
+ }
@@ -0,0 +1,93 @@
1
+ // 滑动窗口请求速率限制器 (Rate Limiter)
2
+ // 防范暴力破解、高频遍历与 DoS 攻击
3
+
4
+ export class RateLimiter {
5
+ /**
6
+ * @param {object} options
7
+ * @param {number} options.maxRequests 窗口期内允许的最大请求数(默认 30)
8
+ * @param {number} options.windowMs 时间窗口毫秒数(默认 60000ms = 1分钟)
9
+ */
10
+ constructor({ maxRequests = 30, windowMs = 60000 } = {}) {
11
+ this.maxRequests = maxRequests;
12
+ this.windowMs = windowMs;
13
+ /** @type {Map<string, number[]>} key -> timestamp[] */
14
+ this.records = new Map();
15
+
16
+ // 定期清理过期记录(每 5 分钟清理一次)
17
+ this.cleanupTimer = setInterval(() => this.cleanup(), 5 * 60 * 1000);
18
+ if (this.cleanupTimer.unref) this.cleanupTimer.unref();
19
+ }
20
+
21
+ /**
22
+ * 检查指定 Key(如 IP / 用户 / Session)是否允许本次请求
23
+ * @param {string} key 限制标识
24
+ * @param {number} [customMax] 单次自定义最大请求数
25
+ * @returns {{ allowed: boolean, remaining: number, retryAfterSec: number }}
26
+ */
27
+ check(key = 'default', customMax) {
28
+ const limit = customMax || this.maxRequests;
29
+ const now = Date.now();
30
+ const windowStart = now - this.windowMs;
31
+
32
+ let timestamps = this.records.get(key);
33
+ if (!timestamps) {
34
+ timestamps = [];
35
+ this.records.set(key, timestamps);
36
+ }
37
+
38
+ // 过滤掉当前窗口期之前的记录
39
+ const valid = timestamps.filter(t => t > windowStart);
40
+ this.records.set(key, valid);
41
+
42
+ if (valid.length >= limit) {
43
+ const oldestInWindow = valid[0];
44
+ const resetTime = oldestInWindow + this.windowMs;
45
+ const retryAfterSec = Math.max(1, Math.ceil((resetTime - now) / 1000));
46
+ return {
47
+ allowed: false,
48
+ remaining: 0,
49
+ retryAfterSec,
50
+ };
51
+ }
52
+
53
+ valid.push(now);
54
+ return {
55
+ allowed: true,
56
+ remaining: limit - valid.length,
57
+ retryAfterSec: 0,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * 重置指定 key 或全部记录
63
+ * @param {string} [key]
64
+ */
65
+ reset(key) {
66
+ if (key) this.records.delete(key);
67
+ else this.records.clear();
68
+ }
69
+
70
+ /**
71
+ * 清理所有过期记录
72
+ */
73
+ cleanup() {
74
+ const now = Date.now();
75
+ const windowStart = now - this.windowMs;
76
+ for (const [key, list] of this.records.entries()) {
77
+ const valid = list.filter(t => t > windowStart);
78
+ if (valid.length === 0) {
79
+ this.records.delete(key);
80
+ } else {
81
+ this.records.set(key, valid);
82
+ }
83
+ }
84
+ }
85
+
86
+ dispose() {
87
+ if (this.cleanupTimer) {
88
+ clearInterval(this.cleanupTimer);
89
+ this.cleanupTimer = null;
90
+ }
91
+ this.records.clear();
92
+ }
93
+ }
@@ -175,7 +175,10 @@ export class TelegramService extends Platform {
175
175
  async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs, botToken, proxy } = {}) {
176
176
  if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
177
177
  if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
178
- if (maxMessageChars != null) this.node.config.maxMessageChars = Number(maxMessageChars)
178
+ if (maxMessageChars != null) {
179
+ const val = Number(maxMessageChars)
180
+ this.node.config.maxMessageChars = (val >= 200) ? val : 4096
181
+ }
179
182
  if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
180
183
 
181
184
  if (botToken !== undefined || proxy !== undefined) {
@@ -222,7 +222,10 @@ export class WechatService extends Platform {
222
222
  async setConfig({ digestIntervalSec, approvalTimeoutSec, maxMessageChars, sendChunkDelayMs } = {}) {
223
223
  if (digestIntervalSec != null) this.node.config.digestIntervalSec = Number(digestIntervalSec)
224
224
  if (approvalTimeoutSec != null) this.node.config.approvalTimeoutSec = Number(approvalTimeoutSec)
225
- if (maxMessageChars != null) this.node.config.maxMessageChars = Number(maxMessageChars)
225
+ if (maxMessageChars != null) {
226
+ const val = Number(maxMessageChars)
227
+ this.node.config.maxMessageChars = (val >= 200) ? val : gatewayConstants.MAX_MESSAGE_CHARS
228
+ }
226
229
  if (sendChunkDelayMs != null) this.node.config.sendChunkDelayMs = Number(sendChunkDelayMs)
227
230
  await this.persist({
228
231
  digestIntervalSec: this.node.config.digestIntervalSec,
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@wenbin_wb/dsh-bridge",
3
- "version": "2.7.1",
3
+ "version": "2.8.1",
4
4
  "description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
5
- "releaseNotes": "【v2.7.1 Windows 一键升级与自建协议修复】\n• 🛠️ 修复 Windows 平台下一键升级触发 `spawn EINVAL` 的问题\n• 🌐 修复自建隧道 WebSocket (ws/wss) 协议诊断报错的问题\n• 🛡️ 强化 Gateway 启动并发锁与会话恢复防抖",
5
+ "releaseNotes": "【v2.8.1 微信/IM 消息分块保护与流式体验修复】\n• 🛡️ 修复微信/IM 平台单条消息分块字符数因异常配置被切成 20 字符碎片的问题\n• ⚙️ 增加消息分块字符数全局安全下限防御(自动纠偏并保底为平台标准容量)\n• 💬 恢复微信/各平台完整段落与 Markdown 代码块的原生一次性下发",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
8
8
  "exports": {