@wenbin_wb/dsh-bridge 2.7.0 → 2.8.0
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 +1023 -5
- package/client/index.js +1120 -7
- 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/index.js +269 -29
- package/lib/platform/conversation-bridge.js +39 -1
- package/lib/security/path-validator.js +195 -0
- package/lib/security/rate-limiter.js +93 -0
- 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/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenbin_wb/dsh-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "手机扫码即可在移动端/公网继续用 DeepSeek Harness,人不在电脑前也能接着干。一键局域网二维码、Cloudflare 公网隧道、自建隧道与微信 / QQ / 飞书 / Telegram Bot(多工作区/会话持久化/媒体/卡片审批/流式输出),无需自己搭公网服务器。",
|
|
5
|
-
"releaseNotes": "【v2.
|
|
5
|
+
"releaseNotes": "【v2.8.0 移动端体验革新、远程工作区管理与全面安全加固】\n• 🗂️ 远程工作区网页选择器:移动端/远程点击添加工作区自动呼出网页端目录浏览器,本机电脑智能无感分流原生系统选择器\n• 📱 移动端视觉与布局体验深度优化:顶部导航栏居中动态标题、第二行徽标与极简下载图标两端排布、底部工具栏防重叠自适应\n• 🔐 全方位安全加固:RPC 端点鉴权、路径穿越与系统核心目录防御、滑动窗口限流与 IM 权限拦截",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
8
8
|
"exports": {
|