@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.
- package/CHANGELOG.md +36 -0
- package/README.en.md +31 -8
- package/README.md +31 -8
- package/client/client.js +1024 -6
- package/client/index.js +1121 -8
- package/docs/feishu-usage.md +1 -0
- package/docs/qq-usage.md +1 -0
- package/docs/screenshots/mobile-chat.jpg +0 -0
- package/docs/screenshots/mobile-drawer.jpg +0 -0
- package/docs/screenshots/mobile-remote-settings.jpg +0 -0
- package/docs/screenshots/mobile-settings-im.jpg +0 -0
- package/docs/screenshots/mobile-settings-lan.jpg +0 -0
- package/docs/screenshots/mobile-settings-security.jpg +0 -0
- package/docs/screenshots/mobile-settings-tunnel.jpg +0 -0
- package/docs/screenshots/mobile-workspace-picker.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/telegram-usage.md +1 -0
- package/docs/wechat-usage.md +2 -0
- package/lib/bridge-rpc-constants.js +4 -0
- package/lib/bridge-rpc.js +41 -0
- package/lib/feishu/index.js +4 -1
- package/lib/index.js +252 -4
- package/lib/platform/conversation-bridge.js +42 -2
- package/lib/qq/index.js +4 -1
- package/lib/security/path-validator.js +195 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/telegram/index.js +4 -1
- package/lib/wechat/index.js +4 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/lib/telegram/index.js
CHANGED
|
@@ -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)
|
|
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) {
|
package/lib/wechat/index.js
CHANGED
|
@@ -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)
|
|
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.
|
|
3
|
+
"version": "2.8.1",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.
|
|
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": {
|