dsh-bridge-gateway 0.1.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 +250 -0
- package/LICENSE +21 -0
- package/README.en.md +408 -0
- package/README.md +430 -0
- package/client/build.mjs +43 -0
- package/client/client.js +5209 -0
- package/client/index.js +4967 -0
- package/cordis.patch.yml +4 -0
- package/docs/banner.jpg +0 -0
- package/docs/custom-tunnel.md +220 -0
- package/docs/feishu-usage.md +116 -0
- package/docs/fix-tunnel-sse-and-session-list.md +134 -0
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/qq-usage.md +387 -0
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -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/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +93 -0
- package/docs/wechat-usage.md +104 -0
- package/lib/auth/login-template.js +382 -0
- package/lib/auth/manager.js +470 -0
- package/lib/bridge-rpc-constants.js +55 -0
- package/lib/bridge-rpc.js +553 -0
- package/lib/cloudflared-manager.mjs +345 -0
- package/lib/feishu/gateway.js +550 -0
- package/lib/feishu/index.js +222 -0
- package/lib/feishu/lark-bundled.mjs +125547 -0
- package/lib/feishu/node.js +409 -0
- package/lib/gateway/cert.js +214 -0
- package/lib/index.js +1963 -0
- package/lib/platform/base.js +156 -0
- package/lib/platform/conversation-bridge.js +1570 -0
- package/lib/platform/index.js +10 -0
- package/lib/platform/manager.js +74 -0
- package/lib/qq/gateway.js +616 -0
- package/lib/qq/index.js +309 -0
- package/lib/qq/node.js +533 -0
- package/lib/security/path-validator.js +208 -0
- package/lib/security/rate-limiter.js +93 -0
- package/lib/telegram/gateway.js +630 -0
- package/lib/telegram/index.js +213 -0
- package/lib/telegram/node.js +351 -0
- package/lib/tunnel-client.mjs +402 -0
- package/lib/wechat/gateway.js +960 -0
- package/lib/wechat/index.js +241 -0
- package/lib/wechat/media.js +281 -0
- package/lib/wechat/node.js +350 -0
- package/package.json +102 -0
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
// 即使当前非 Windows,也拦截用户传入的 Windows 系统敏感路径(如 C:\Windows),
|
|
116
|
+
// 避免在 POSIX 上被 resolve() 拼成相对路径后仅返回 ENOENT 而绕过黑名单。
|
|
117
|
+
const winDriveMatch = trimmed.match(/^([A-Za-z]):[\\/](.*)$/);
|
|
118
|
+
if (winDriveMatch) {
|
|
119
|
+
const rest = winDriveMatch[2].toLowerCase();
|
|
120
|
+
for (const prefix of WIN_SYSTEM_PREFIXES) {
|
|
121
|
+
if (rest === prefix || rest.startsWith(`${prefix}\\`) || rest.startsWith(`${prefix}/`)) {
|
|
122
|
+
return { valid: false, error: `安全拦截:禁止访问系统敏感目录 (${prefix})` };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 3. 检查 Windows 敏感路径(在当前为 Windows 时)
|
|
128
|
+
if (isWin) {
|
|
129
|
+
// 提取盘符后面的相对部分
|
|
130
|
+
const driveMatch = normalized.match(/^[A-Za-z]:\\(.*)$/);
|
|
131
|
+
if (driveMatch) {
|
|
132
|
+
const rest = driveMatch[1].toLowerCase();
|
|
133
|
+
for (const prefix of WIN_SYSTEM_PREFIXES) {
|
|
134
|
+
if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
|
|
135
|
+
return { valid: false, error: `安全拦截:禁止访问系统敏感目录 (${prefix})` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else if (normalized.startsWith('\\\\')) {
|
|
139
|
+
return { valid: false, error: '安全拦截:禁止访问 UNC 网络共享路径' };
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// 3. 检查 POSIX 敏感路径
|
|
143
|
+
for (const prefix of POSIX_SYSTEM_PREFIXES) {
|
|
144
|
+
if (lowerNormalized === prefix || lowerNormalized.startsWith(`${prefix}/`)) {
|
|
145
|
+
return { valid: false, error: `安全拦截:禁止访问系统关键目录 (${prefix})` };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 4. 检查敏感隐藏目录片段(如路径中包含 /.ssh/ 或 \.ssh\)
|
|
151
|
+
const pathParts = normalized.split(/[\\/]/).filter(Boolean);
|
|
152
|
+
for (const part of pathParts) {
|
|
153
|
+
if (isSensitiveFolderName(part)) {
|
|
154
|
+
return { valid: false, error: `安全拦截:禁止访问敏感配置目录「${part}」` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 5. 校验物理文件系统状态与符号链接(Symlink)真实目标
|
|
159
|
+
let realTargetPath;
|
|
160
|
+
try {
|
|
161
|
+
const s = await stat(normalized);
|
|
162
|
+
if (!s.isDirectory()) {
|
|
163
|
+
return { valid: false, error: `指定路径不是文件夹: ${normalized}` };
|
|
164
|
+
}
|
|
165
|
+
realTargetPath = await realpath(normalized);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// 若仅是做路径规范性预校验(未必须已存在)
|
|
168
|
+
if (options.allowNonExistent) {
|
|
169
|
+
return { valid: true, path: normalized };
|
|
170
|
+
}
|
|
171
|
+
return { valid: false, error: `无法访问指定路径 (${err.message})` };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 6. 对符号链接解析后的真实物理路径进行二次黑名单校验(防范符号链接逃逸)
|
|
175
|
+
if (realTargetPath && realTargetPath !== normalized) {
|
|
176
|
+
const realLower = realTargetPath.toLowerCase();
|
|
177
|
+
if (isWin) {
|
|
178
|
+
const realDriveMatch = realTargetPath.match(/^[A-Za-z]:\\(.*)$/);
|
|
179
|
+
if (realDriveMatch) {
|
|
180
|
+
const rest = realDriveMatch[1].toLowerCase();
|
|
181
|
+
for (const prefix of WIN_SYSTEM_PREFIXES) {
|
|
182
|
+
if (rest === prefix || rest.startsWith(`${prefix}\\`)) {
|
|
183
|
+
return { valid: false, error: `安全拦截:符号链接指向系统敏感目录 (${prefix})` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
for (const prefix of POSIX_SYSTEM_PREFIXES) {
|
|
189
|
+
if (realLower === prefix || realLower.startsWith(`${prefix}/`)) {
|
|
190
|
+
return { valid: false, error: `安全拦截:符号链接指向系统关键目录 (${prefix})` };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const realParts = realTargetPath.split(/[\\/]/).filter(Boolean);
|
|
196
|
+
for (const part of realParts) {
|
|
197
|
+
if (isSensitiveFolderName(part)) {
|
|
198
|
+
return { valid: false, error: `安全拦截:符号链接指向敏感配置目录「${part}」` };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
valid: true,
|
|
205
|
+
path: normalized,
|
|
206
|
+
realPath: realTargetPath || normalized,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -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
|
+
}
|