@wenbin_wb/dsh-bridge 2.10.2 → 2.10.3
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 +27 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +25 -1
- package/client/index.js +13 -0
- package/lib/feishu/node.js +10 -4
- package/lib/index.js +91 -14
- package/lib/platform/conversation-bridge.js +8 -3
- package/lib/platform/message-split.js +39 -1
- package/lib/qq/node.js +539 -532
- package/lib/session-strip.js +57 -0
- package/lib/telegram/node.js +0 -27
- package/lib/tunnel-client.mjs +8 -29
- package/lib/wechat/gateway.js +17 -4
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,33 @@
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [v2.10.3] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
### ✨ 新功能
|
|
10
|
+
|
|
11
|
+
- **兼容 DSH 原生端口直连(3080)**(issue #28):此前本机用 `http://127.0.0.1:3080`(DSH 原生端口)打开面板时,因 loopback-token 端点仅暴露在代理端口 3082、且 CORS 白名单不含原生端口来源,管理功能会被误判为「远程」而锁定(local_only 策略下)。现在原生端口页面可跨域回读管理员令牌,直连与走代理行为一致;CORS 仍收敛,仅回显白名单来源。
|
|
12
|
+
|
|
13
|
+
### 🐞 修复
|
|
14
|
+
|
|
15
|
+
- **修复代理响应头透传问题**:反向代理与 WebSocket 升级路径现会剥离 hop-by-hop 头(transfer-encoding/connection 等),并对 WS 101 响应做白名单过滤,避免上游注入任意头或客户端解析异常。
|
|
16
|
+
- **修复 QQ 流式消息发送**:首片失败时不再补发「全新流式消息」造成重复,直接降级单条 Markdown;引用机器人消息自动开新会话不再依赖固定 100ms 等待(消除竞态)。
|
|
17
|
+
- **修复微信网关轮询竞态**:stop/restart 时通过 AbortController 立即中断进行中的长轮询,避免新旧 poller 并存触发 iLink 独占锁(403)。
|
|
18
|
+
- **修复飞书群聊审批按钮被误拦**:群聊按「群」整体授权(与 QQ 群模型一致),群内成员可点按钮决议;单聊仍严格校验发起者本人。
|
|
19
|
+
- **会话列表/历史大字段剥离覆盖全部公网入口**:session.list / session.history 的大字段剥离不再仅限自建隧道,Cloudflare 隧道、局域网、外部登记隧道同样生效(社区 PR #27 扩展)。
|
|
20
|
+
|
|
21
|
+
### 🔒 安全
|
|
22
|
+
|
|
23
|
+
- **[SEND_FILE] 外发白名单**:AI 输出携带的 `[SEND_FILE: 路径]` 指令现仅允许发送会话工作目录内的文件,并拦截 `.ssh`/`.credentials`/`.env` 等敏感路径,防止模型被诱导后外发任意本地文件。
|
|
24
|
+
|
|
25
|
+
### 🧹 内部优化
|
|
26
|
+
|
|
27
|
+
- **未设密码高危告警**:开启访问认证但尚未设置任何密码时,设置页顶部显示醒目风险提示。
|
|
28
|
+
- **自建隧道安全须知**:README(中/英)明确隧道公网入口无独立认证、必须配合本地访问认证使用。
|
|
29
|
+
- 版本探测失败改为 10 分钟退避重试;版本检查结果 10 分钟 TTL 缓存,减少外网请求。
|
|
30
|
+
- 清理 Telegram 死代码(无调用点的审批卡片方法)。
|
|
31
|
+
- 新增 issue #28 回归测试,全量单测通过。
|
|
32
|
+
---
|
|
33
|
+
|
|
7
34
|
## [v2.10.2] - 2026-09-01
|
|
8
35
|
|
|
9
36
|
### ✨ 新功能
|
package/README.en.md
CHANGED
|
@@ -138,6 +138,8 @@ Access DeepSeek Harness from anywhere outside your home network without public I
|
|
|
138
138
|
- **Mode 3: Custom WebSocket Tunnel**:
|
|
139
139
|
- Connect to your personal VPS reverse proxy server ([View Setup Guide](docs/custom-tunnel.md)), equipped with per-message gzip and SSE optimization.
|
|
140
140
|
|
|
141
|
+
> **Custom tunnel security note**: The tunnel server (`scripts/install-tunnel-server.sh`) only authenticates the *tunnel client control channel* with `TOKEN`; public HTTP/WebSocket requests forwarded through the tunnel domain are **not independently authenticated** — security relies entirely on the plugin's local Access Auth (the `x-dsh-internal-tunnel` marker prevents tunnel traffic from using the loopback exemption). Always enable **Access Auth** with a password/QR Token in the plugin settings (especially with `scope=all` or when exposed publicly); with no password set, anyone who knows the tunnel URL can reach your DSH.
|
|
142
|
+
|
|
141
143
|
---
|
|
142
144
|
|
|
143
145
|
### 3. 📱 Mobile Experience & Standalone PWA
|
package/README.md
CHANGED
|
@@ -146,6 +146,8 @@ dsh plugin --profile web add @wenbin_wb/dsh-bridge@latest
|
|
|
146
146
|
- **模式 3:自建 WebSocket 隧道**
|
|
147
147
|
* 支持连接个人 VPS 隧道中转服务器([查看自建隧道部署教程](docs/custom-tunnel.md)),具备数据端到端 gzip 压缩与 SSE 响应优化。
|
|
148
148
|
|
|
149
|
+
> **自建隧道安全须知**:隧道服务端(`scripts/install-tunnel-server.sh`)只对「隧道客户端控制通道」校验 `TOKEN`;公网访客对隧道域名的 HTTP/WebSocket 转发**不再做独立认证**,安全完全依赖插件本地的「访问认证」(`x-dsh-internal-tunnel` 标识使隧道流量无法享受本机回环保留)。请务必在插件设置中开启「安全认证」并设置访问密码/二维码 Token(尤其 `scope=all` 或公网使用时);未设置任何密码时,任何知道隧道地址的访客都能直接访问您的 DSH。
|
|
150
|
+
|
|
149
151
|
---
|
|
150
152
|
|
|
151
153
|
### 3. 📱 移动端交互与 PWA 独立全屏 App
|
package/client/client.js
CHANGED
|
@@ -1993,7 +1993,31 @@ var AccessAuthCard = React.memo(function AccessAuthCard2({ auth, rpcCall, onUpda
|
|
|
1993
1993
|
background: topMsg.ok ? "var(--dsw-alias-state-success-bg,#ecfdf5)" : "var(--dsw-alias-state-error-bg,#fef2f2)",
|
|
1994
1994
|
color: topMsg.ok ? "var(--dsw-alias-state-success-primary,#059669)" : "var(--dsw-alias-state-error-primary,#dc2626)"
|
|
1995
1995
|
}
|
|
1996
|
-
}, topMsg.text)
|
|
1996
|
+
}, topMsg.text),
|
|
1997
|
+
// 访问认证已开启但尚未设置任何密码/Token 保护:局域网/公网访客可免密进入,高危提示
|
|
1998
|
+
enabled && !auth?.hasPassword && !auth?.hasAdminPassword && React.createElement(
|
|
1999
|
+
"div",
|
|
2000
|
+
{
|
|
2001
|
+
style: {
|
|
2002
|
+
marginTop: 12,
|
|
2003
|
+
padding: "10px 14px",
|
|
2004
|
+
borderRadius: 6,
|
|
2005
|
+
fontSize: 12,
|
|
2006
|
+
background: "var(--dsw-alias-state-error-bg,#fef2f2)",
|
|
2007
|
+
border: "1px solid var(--dsw-alias-state-error-border,#fecaca)",
|
|
2008
|
+
color: "var(--dsw-alias-state-error-primary,#dc2626)",
|
|
2009
|
+
lineHeight: 1.6
|
|
2010
|
+
}
|
|
2011
|
+
},
|
|
2012
|
+
"\u26A0\uFE0F ",
|
|
2013
|
+
React.createElement("strong", null, "\u5C1A\u672A\u8BBE\u7F6E\u4EFB\u4F55\u8BBF\u95EE\u5BC6\u7801\u6216\u7BA1\u7406\u5BC6\u7801"),
|
|
2014
|
+
" \u2014\u2014 \u6B64\u65F6\u201C\u8BBF\u95EE\u8BA4\u8BC1\u5DF2\u5F00\u542F\u201D\u4F46\u4EFB\u4F55\u77E5\u9053\u5C40\u57DF\u7F51 IP / \u96A7\u9053\u5730\u5740\u7684\u4EBA\u90FD\u80FD\u76F4\u63A5\u8FDB\u5165\uFF08\u96A7\u9053\u5165\u53E3\u81EA\u8EAB\u4E0D\u8BBE\u9632\uFF0C\u8BE6\u89C1\u4E0B\u65B9\u5B89\u5168\u987B\u77E5\uFF09\u3002",
|
|
2015
|
+
"\u5EFA\u8BAE\u7ACB\u5373\u5728\u4E0B\u65B9\u8BBE\u7F6E",
|
|
2016
|
+
React.createElement("strong", null, "\u8BBF\u5BA2\u8BBF\u95EE\u5BC6\u7801"),
|
|
2017
|
+
"\u6216",
|
|
2018
|
+
React.createElement("strong", null, "\u7BA1\u7406\u5BC6\u7801"),
|
|
2019
|
+
"\u540E\u518D\u5BF9\u5916\u5F00\u653E\u3002"
|
|
2020
|
+
)
|
|
1997
2021
|
),
|
|
1998
2022
|
React.createElement(
|
|
1999
2023
|
React.Fragment,
|
package/client/index.js
CHANGED
|
@@ -943,6 +943,19 @@ const AccessAuthCard = React.memo(function AccessAuthCard({ auth, rpcCall, onUpd
|
|
|
943
943
|
color: topMsg.ok ? 'var(--dsw-alias-state-success-primary,#059669)' : 'var(--dsw-alias-state-error-primary,#dc2626)',
|
|
944
944
|
},
|
|
945
945
|
}, topMsg.text),
|
|
946
|
+
|
|
947
|
+
// 访问认证已开启但尚未设置任何密码/Token 保护:局域网/公网访客可免密进入,高危提示
|
|
948
|
+
enabled && !auth?.hasPassword && !auth?.hasAdminPassword && React.createElement('div', {
|
|
949
|
+
style: {
|
|
950
|
+
marginTop: 12, padding: '10px 14px', borderRadius: 6, fontSize: 12,
|
|
951
|
+
background: 'var(--dsw-alias-state-error-bg,#fef2f2)',
|
|
952
|
+
border: '1px solid var(--dsw-alias-state-error-border,#fecaca)',
|
|
953
|
+
color: 'var(--dsw-alias-state-error-primary,#dc2626)',
|
|
954
|
+
lineHeight: 1.6,
|
|
955
|
+
},
|
|
956
|
+
}, '⚠️ ', React.createElement('strong', null, '尚未设置任何访问密码或管理密码'),
|
|
957
|
+
' —— 此时“访问认证已开启”但任何知道局域网 IP / 隧道地址的人都能直接进入(隧道入口自身不设防,详见下方安全须知)。',
|
|
958
|
+
'建议立即在下方设置', React.createElement('strong', null, '访客访问密码'), '或', React.createElement('strong', null, '管理密码'), '后再对外开放。'),
|
|
946
959
|
),
|
|
947
960
|
|
|
948
961
|
React.createElement(React.Fragment, null,
|
package/lib/feishu/node.js
CHANGED
|
@@ -182,8 +182,9 @@ export class FeishuConversationNode extends ConversationBridge {
|
|
|
182
182
|
const outcome = action === 'approve' ? 'allowed-once' : 'rejected'
|
|
183
183
|
const pending = this.pending.get(approvalId)
|
|
184
184
|
if (pending) {
|
|
185
|
-
//
|
|
186
|
-
|
|
185
|
+
// 决议者校验:群聊按"群"整体授权(群内成员均可按钮决议,与 QQ 群模型一致),
|
|
186
|
+
// 不校验成员级 operatorId;单聊严格限定发起者本人(operatorId === 发起者 open_id)。
|
|
187
|
+
if (!pending.isGroup && pending.peerId && operatorId && pending.peerId !== operatorId) {
|
|
187
188
|
this.logger?.warn?.('[dsh-bridge feishu] approval #%d blocked: operator %s is not the initiator %s', approvalId, operatorId, pending.peerId)
|
|
188
189
|
return
|
|
189
190
|
}
|
|
@@ -390,8 +391,13 @@ export class FeishuConversationNode extends ConversationBridge {
|
|
|
390
391
|
}
|
|
391
392
|
req.signal?.addEventListener('abort', onSignalAbort, { once: true })
|
|
392
393
|
|
|
393
|
-
// peerId
|
|
394
|
-
|
|
394
|
+
// peerId 记录发起者:单聊 /yes 与卡片按钮都校验决议者身份;
|
|
395
|
+
// 群聊把"群"整体作为授权主体(首个 @ 即授权整群),群内成员均可决议,不校验成员级 operatorId
|
|
396
|
+
this.registerApproval(number, {
|
|
397
|
+
number, request: req, resolve: resolveIm, timer,
|
|
398
|
+
peerId: initiator,
|
|
399
|
+
isGroup: Boolean(peer.isGroup),
|
|
400
|
+
})
|
|
395
401
|
|
|
396
402
|
let outcome
|
|
397
403
|
try {
|
package/lib/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { TelegramService } from './telegram/index.js';
|
|
|
26
26
|
import { AuthManager } from './auth/manager.js';
|
|
27
27
|
import { renderLoginPage } from './auth/login-template.js';
|
|
28
28
|
import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
|
|
29
|
+
import { stripSessionProjections } from './session-strip.js';
|
|
29
30
|
import { installAbortSignalCompat, BROWSER_ABORT_SIGNAL_POLYFILL } from './compat.js';
|
|
30
31
|
|
|
31
32
|
const name = 'dsh-bridge';
|
|
@@ -245,6 +246,36 @@ function loopbackHeaders(headers, targetPort) {
|
|
|
245
246
|
return out;
|
|
246
247
|
}
|
|
247
248
|
|
|
249
|
+
// hop-by-hop 响应头黑名单:这些头描述的是"当前一跳"的传输语义,反向代理
|
|
250
|
+
// 透传会干扰客户端对响应体的解析(transfer-encoding/connection 并存、
|
|
251
|
+
// keep-alive 连接复用错乱等)。Node http 层会自动生成正确的传输头,故统一剥离。
|
|
252
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
253
|
+
'connection',
|
|
254
|
+
'keep-alive',
|
|
255
|
+
'proxy-authenticate',
|
|
256
|
+
'proxy-authorization',
|
|
257
|
+
'te',
|
|
258
|
+
'trailer',
|
|
259
|
+
'transfer-encoding',
|
|
260
|
+
'upgrade',
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 净化上游响应头:剔除 hop-by-hop 头,返回可安全 writeHead 的干净头对象。
|
|
265
|
+
* 保留原始键大小写(Node writeHead 会归一化处理)。
|
|
266
|
+
* @param {object} headers 上游响应头(IncomingMessage.headers 形式)
|
|
267
|
+
* @param {Set<string>} [extra] 额外需剔除的头(小写)
|
|
268
|
+
*/
|
|
269
|
+
function sanitizeProxyHeaders(headers, extra) {
|
|
270
|
+
const out = {};
|
|
271
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
272
|
+
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
|
|
273
|
+
if (extra && extra.has(k.toLowerCase())) continue;
|
|
274
|
+
out[k] = v;
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
248
279
|
/**
|
|
249
280
|
* HTTP + WebSocket 代理服务器(带安全认证守门)
|
|
250
281
|
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
@@ -431,27 +462,42 @@ class ProxyServer {
|
|
|
431
462
|
{ host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
|
|
432
463
|
(proxyRes) => {
|
|
433
464
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
434
|
-
//
|
|
435
|
-
|
|
465
|
+
// 会话投影剥离(session.list / session.history):这两个端点响应体可能达
|
|
466
|
+
// 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
|
|
467
|
+
// 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
|
|
468
|
+
const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
|
|
469
|
+
const shouldBuffer = contentType.includes('text/html')
|
|
470
|
+
|| (isSessionProjection && proxyRes.statusCode === 200 && !isCompressed(proxyRes.headers));
|
|
471
|
+
if (shouldBuffer) {
|
|
436
472
|
const chunks = [];
|
|
437
473
|
proxyRes.on('data', (c) => chunks.push(c));
|
|
438
474
|
proxyRes.on('end', () => {
|
|
439
|
-
let
|
|
440
|
-
if (!html.includes(INJECT_MARK)) {
|
|
441
|
-
html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
|
|
442
|
-
}
|
|
443
|
-
const out = Buffer.from(html, 'utf8');
|
|
475
|
+
let out = Buffer.concat(chunks);
|
|
444
476
|
const outHeaders = { ...proxyRes.headers };
|
|
477
|
+
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
478
|
+
let html = out.toString('utf8');
|
|
479
|
+
if (!html.includes(INJECT_MARK)) {
|
|
480
|
+
html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
|
|
481
|
+
}
|
|
482
|
+
out = Buffer.from(html, 'utf8');
|
|
483
|
+
} else if (isSessionProjection) {
|
|
484
|
+
const { body, stripped } = stripSessionProjections(pathname, out);
|
|
485
|
+
if (stripped) out = body;
|
|
486
|
+
}
|
|
487
|
+
// 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
|
|
488
|
+
// transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
|
|
445
489
|
delete outHeaders['content-length'];
|
|
446
490
|
delete outHeaders['transfer-encoding'];
|
|
447
491
|
outHeaders['content-length'] = String(out.length);
|
|
448
|
-
res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
|
|
492
|
+
res.writeHead(proxyRes.statusCode ?? 200, sanitizeProxyHeaders(outHeaders));
|
|
449
493
|
res.end(out);
|
|
450
494
|
});
|
|
451
495
|
proxyRes.on('error', () => res.destroy());
|
|
452
496
|
return;
|
|
453
497
|
}
|
|
454
|
-
|
|
498
|
+
// 直通分支同样剔除 hop-by-hop 头(transfer-encoding/connection 等),
|
|
499
|
+
// 防止透传给客户端导致解析错误;Node 会按 writeHead+pipe 自动生成正确的传输头
|
|
500
|
+
res.writeHead(proxyRes.statusCode ?? 502, sanitizeProxyHeaders(proxyRes.headers));
|
|
455
501
|
proxyRes.pipe(res);
|
|
456
502
|
res.on('close', () => proxyRes.destroy());
|
|
457
503
|
proxyRes.on('error', () => res.destroy());
|
|
@@ -481,8 +527,15 @@ class ProxyServer {
|
|
|
481
527
|
});
|
|
482
528
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
483
529
|
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
|
|
530
|
+
// WS 升级成功:手写原始 socket 响应,只放行必要的 101 头(白名单),
|
|
531
|
+
// 避免上游注入任意头(如 set-cookie / 自定义敏感头)透传给浏览器
|
|
532
|
+
const WS_101_ALLOW = new Set([
|
|
533
|
+
'connection', 'upgrade', 'sec-websocket-accept',
|
|
534
|
+
'sec-websocket-extensions', 'sec-websocket-protocol',
|
|
535
|
+
]);
|
|
484
536
|
const raw = [];
|
|
485
537
|
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
538
|
+
if (!WS_101_ALLOW.has(k.toLowerCase())) continue;
|
|
486
539
|
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
487
540
|
}
|
|
488
541
|
socket.write(`${raw.join('\r\n')}\r\n\r\n`);
|
|
@@ -500,7 +553,8 @@ class ProxyServer {
|
|
|
500
553
|
if (proxyRes.statusCode === 101) return;
|
|
501
554
|
try {
|
|
502
555
|
const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
|
|
503
|
-
|
|
556
|
+
// 非 101(如后端拒绝升级):剔除 hop-by-hop 头后再回写
|
|
557
|
+
for (const [k, v] of Object.entries(sanitizeProxyHeaders(proxyRes.headers))) {
|
|
504
558
|
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
505
559
|
}
|
|
506
560
|
socket.end(raw.join('\r\n') + '\r\n\r\n');
|
|
@@ -574,6 +628,10 @@ class BridgeService {
|
|
|
574
628
|
// DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
|
|
575
629
|
this._dshVersion = null;
|
|
576
630
|
this._dshVersionLoaded = false;
|
|
631
|
+
// 版本检查结果缓存(10 分钟 TTL):客户端轮询/多处面板重复调用时不反复打 npm registry
|
|
632
|
+
this._versionCheckCache = null;
|
|
633
|
+
this._versionCheckCachedAt = 0;
|
|
634
|
+
this._versionCheckTtlMs = 10 * 60 * 1000;
|
|
577
635
|
}
|
|
578
636
|
|
|
579
637
|
/**
|
|
@@ -583,7 +641,9 @@ class BridgeService {
|
|
|
583
641
|
*/
|
|
584
642
|
async getDshVersion() {
|
|
585
643
|
if (this._dshVersionLoaded) return this._dshVersion;
|
|
586
|
-
|
|
644
|
+
// 上次探测失败后的退避期未到:不 spawn,直接返回 null(面板仍可正常加载)
|
|
645
|
+
if (this._dshVersionRetryAt && Date.now() < this._dshVersionRetryAt) return this._dshVersion;
|
|
646
|
+
this._dshVersionLoaded = true; // 尝试期间置位;成功保持,失败在 catch 里复位并设退避
|
|
587
647
|
|
|
588
648
|
const isWin = process.platform === 'win32';
|
|
589
649
|
const nodeDir = dirname(process.execPath);
|
|
@@ -624,6 +684,10 @@ class BridgeService {
|
|
|
624
684
|
} catch (e) {
|
|
625
685
|
this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
|
|
626
686
|
this._dshVersion = null;
|
|
687
|
+
// 失败:复位已加载标记并设 10 分钟退避期。退避期内 getStatus 不再 spawn,
|
|
688
|
+
// 到期后允许重试一次(用户修复 PATH 后无需重启即可恢复版本显示)。
|
|
689
|
+
this._dshVersionLoaded = false;
|
|
690
|
+
this._dshVersionRetryAt = Date.now() + 10 * 60 * 1000;
|
|
627
691
|
}
|
|
628
692
|
return this._dshVersion;
|
|
629
693
|
}
|
|
@@ -644,9 +708,14 @@ class BridgeService {
|
|
|
644
708
|
targetPort: this.dshPort,
|
|
645
709
|
authManager: this.authManager,
|
|
646
710
|
logger: this.logger,
|
|
647
|
-
// loopback-token
|
|
711
|
+
// loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
|
|
712
|
+
// 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
|
|
713
|
+
// 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
|
|
648
714
|
allowedOrigins: () => {
|
|
649
|
-
const origins = [
|
|
715
|
+
const origins = [
|
|
716
|
+
`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`,
|
|
717
|
+
`http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
|
|
718
|
+
];
|
|
650
719
|
try {
|
|
651
720
|
for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
|
|
652
721
|
if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
|
|
@@ -915,6 +984,10 @@ class BridgeService {
|
|
|
915
984
|
|
|
916
985
|
// 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
|
|
917
986
|
async checkVersion() {
|
|
987
|
+
// TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
|
|
988
|
+
if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
|
|
989
|
+
return this._versionCheckCache;
|
|
990
|
+
}
|
|
918
991
|
const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
|
|
919
992
|
const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
|
|
920
993
|
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
@@ -939,13 +1012,17 @@ class BridgeService {
|
|
|
939
1012
|
try {
|
|
940
1013
|
const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
|
|
941
1014
|
.catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
|
|
942
|
-
|
|
1015
|
+
const result = {
|
|
943
1016
|
current: VERSION,
|
|
944
1017
|
latest: latestData?.version ?? null,
|
|
945
1018
|
releaseNotes: latestData?.releaseNotes ?? null,
|
|
946
1019
|
dshVersion: await this.getDshVersion(),
|
|
947
1020
|
};
|
|
1021
|
+
this._versionCheckCache = result;
|
|
1022
|
+
this._versionCheckCachedAt = Date.now();
|
|
1023
|
+
return result;
|
|
948
1024
|
} catch (e) {
|
|
1025
|
+
// 失败不缓存(让下次调用可重试),但记录便于诊断
|
|
949
1026
|
return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
|
|
950
1027
|
}
|
|
951
1028
|
}
|
|
@@ -21,7 +21,7 @@ import { randomUUID } from 'node:crypto'
|
|
|
21
21
|
import { stat } from 'node:fs/promises'
|
|
22
22
|
import { normalize } from 'node:path'
|
|
23
23
|
import { resolveFilePath } from './message-split.js'
|
|
24
|
-
import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText } from './message-split.js'
|
|
24
|
+
import { splitForIM, textOfAssistantMessage, extractAndStripSendFileDirectives, extractFilePathsFromText, isPathAllowedForSend } from './message-split.js'
|
|
25
25
|
import { routeCommand } from './commands.js'
|
|
26
26
|
import { listSessions, listWorkspaces, validateWorkspacePath, renderSessions, sessionsInDisplayOrder, describeTurnEnd, helpText, fmtTime, fmtSessionId, sessionLabel } from './session-catalog.js'
|
|
27
27
|
|
|
@@ -665,9 +665,14 @@ export class ConversationBridge {
|
|
|
665
665
|
const uniqueFilesToSend = []
|
|
666
666
|
for (const f of rawFiles) {
|
|
667
667
|
const resolved = resolveFilePath(f, cwd)
|
|
668
|
-
if (resolved
|
|
669
|
-
|
|
668
|
+
if (!resolved || uniqueFilesToSend.includes(resolved)) continue
|
|
669
|
+
// 发送白名单:仅允许会话 cwd(及其子目录)内、且不命中敏感路径的文件,
|
|
670
|
+
// 防止模型被提示注入后借 [SEND_FILE] 外发 .ssh/.credentials/.env 等任意本地文件。
|
|
671
|
+
if (!isPathAllowedForSend(resolved, cwd)) {
|
|
672
|
+
this.logger?.warn?.(`[dsh-bridge ${this.platform.id}] blocked SEND_FILE outside allowed workspace: ${resolved}`)
|
|
673
|
+
continue
|
|
670
674
|
}
|
|
675
|
+
uniqueFilesToSend.push(resolved)
|
|
671
676
|
}
|
|
672
677
|
for (const resolved of uniqueFilesToSend) {
|
|
673
678
|
try {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// 自 conversation-bridge.js 拆出:按平台 maxMessageChars 分块、保留 fenced code block、
|
|
3
3
|
// [SEND_FILE: ...] 显式指令提取与路径解析。
|
|
4
4
|
import { statSync } from 'node:fs'
|
|
5
|
-
import { isAbsolute, normalize, resolve } from 'node:path'
|
|
5
|
+
import { isAbsolute, normalize, relative, resolve } from 'node:path'
|
|
6
6
|
|
|
7
7
|
const FENCE_RE = /^```([^\n`]*)\s*$/
|
|
8
8
|
|
|
@@ -149,6 +149,44 @@ export function resolveFilePath(rawPath, cwd = process.cwd()) {
|
|
|
149
149
|
return null
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/**
|
|
153
|
+
* 判断解析后的文件路径是否允许经 [SEND_FILE] 发送给 IM。
|
|
154
|
+
* 安全约束(防止模型被诱导后外发任意本地文件):
|
|
155
|
+
* 1. 必须位于 allowedRoots 中的某个根目录(含子目录)内 —— 默认仅会话 cwd;
|
|
156
|
+
* 2. 路径任何一段不得命中敏感名单(.ssh/.gnupg/.aws/.git/.env/.credentials 等)。
|
|
157
|
+
* @param {string} resolvedPath 已解析的绝对路径
|
|
158
|
+
* @param {string|string[]} allowedRoots 允许的根目录(绝对路径);默认 process.cwd()
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
export function isPathAllowedForSend(resolvedPath, allowedRoots = process.cwd()) {
|
|
162
|
+
if (typeof resolvedPath !== 'string' || !resolvedPath) return false
|
|
163
|
+
const roots = Array.isArray(allowedRoots) ? allowedRoots : [allowedRoots]
|
|
164
|
+
if (roots.length === 0) return false
|
|
165
|
+
|
|
166
|
+
const normalized = resolve(resolvedPath)
|
|
167
|
+
const pathParts = normalized.split(/[\\/]/).filter(Boolean)
|
|
168
|
+
const SENSITIVE_PARTS = new Set([
|
|
169
|
+
'.ssh', '.gnupg', '.aws', '.azure', '.kube', '.git', '.svn', '.hg',
|
|
170
|
+
'.bash_history', '.zsh_history', '.profile', '.bash_profile', '.bashrc',
|
|
171
|
+
'.zshrc', '.netrc', '.env', '.npmrc', '.credentials', 'id_rsa', 'id_ed25519',
|
|
172
|
+
'id_ecdsa', 'id_dsa', 'shadow', 'passwd',
|
|
173
|
+
])
|
|
174
|
+
for (const part of pathParts) {
|
|
175
|
+
if (SENSITIVE_PARTS.has(part.toLowerCase())) return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const root of roots) {
|
|
179
|
+
if (typeof root !== 'string' || !root) continue
|
|
180
|
+
const normRoot = resolve(root)
|
|
181
|
+
if (normalized === normRoot) return true
|
|
182
|
+
// 用 relative 判断是否位于根内:越界时 rel 为 '..' 或以 '../' 开头
|
|
183
|
+
// (Windows 跨盘则 rel 是绝对路径,isAbsolute 拦截)。跨平台正确处理 \ 与 / 差异。
|
|
184
|
+
const rel = relative(normRoot, normalized)
|
|
185
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) return true
|
|
186
|
+
}
|
|
187
|
+
return false
|
|
188
|
+
}
|
|
189
|
+
|
|
152
190
|
/**
|
|
153
191
|
* 提取并过滤文本中的 [SEND_FILE: <path>] 显式发送指令
|
|
154
192
|
* 由 AI 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
|