@wenbin_wb/dsh-bridge 2.10.2 → 2.10.4
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 +45 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client/client.js +86 -14
- package/client/index.js +58 -14
- package/client/mobile-styles.js +834 -802
- package/lib/auth/login-template.js +417 -382
- package/lib/bridge-rpc.js +2 -2
- package/lib/feishu/node.js +10 -4
- package/lib/index.js +109 -15
- 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 +67 -0
- package/lib/telegram/gateway.js +28 -26
- package/lib/telegram/node.js +0 -27
- package/lib/tunnel-client.mjs +130 -35
- package/lib/wechat/gateway.js +17 -4
- package/package.json +2 -2
package/lib/bridge-rpc.js
CHANGED
|
@@ -157,8 +157,8 @@ export function installBridgeRpc(ctx, { service, authManager, platformManager, l
|
|
|
157
157
|
if (adminErr) return adminErr;
|
|
158
158
|
|
|
159
159
|
// 未提供的字段保持 undefined 透传:服务端视为"保留现值"
|
|
160
|
-
const { serverUrl, accessToken } = payload;
|
|
161
|
-
await saveCustomTunnelConfig(serverUrl, accessToken);
|
|
160
|
+
const { serverUrl, accessToken, sseStreaming } = payload;
|
|
161
|
+
await saveCustomTunnelConfig(serverUrl, accessToken, sseStreaming);
|
|
162
162
|
const status = await service.getStatus();
|
|
163
163
|
return ok(status);
|
|
164
164
|
}
|
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';
|
|
@@ -183,6 +184,7 @@ const HTML_HEAD_INJECTIONS = `<meta name="viewport" content="width=device-width,
|
|
|
183
184
|
<link rel="manifest" href="/manifest.webmanifest">
|
|
184
185
|
<link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">
|
|
185
186
|
<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">
|
|
187
|
+
<style data-dsh-bridge-overscroll="1">html,body{overscroll-behavior-y:none;-webkit-overflow-scrolling:touch}</style>
|
|
186
188
|
<script data-dsh-bridge-polyfill="1">!function(){try{if(self.crypto&&!self.crypto.randomUUID){self.crypto.randomUUID=function(){var b=new Uint8Array(16);self.crypto.getRandomValues(b);b[6]=b[6]&15|64;b[8]=b[8]&63|128;var h="";for(var i=0;i<16;i++){var x=b[i].toString(16);h+=(x.length<2?"0":"")+x;if(i===3||i===5||i===7||i===9)h+="-";}return h;}}}catch(e){}}();</script>
|
|
187
189
|
${BROWSER_ABORT_SIGNAL_POLYFILL}`;
|
|
188
190
|
const INJECT_MARK = 'data-dsh-bridge-polyfill="1"';
|
|
@@ -245,6 +247,36 @@ function loopbackHeaders(headers, targetPort) {
|
|
|
245
247
|
return out;
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// hop-by-hop 响应头黑名单:这些头描述的是"当前一跳"的传输语义,反向代理
|
|
251
|
+
// 透传会干扰客户端对响应体的解析(transfer-encoding/connection 并存、
|
|
252
|
+
// keep-alive 连接复用错乱等)。Node http 层会自动生成正确的传输头,故统一剥离。
|
|
253
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
254
|
+
'connection',
|
|
255
|
+
'keep-alive',
|
|
256
|
+
'proxy-authenticate',
|
|
257
|
+
'proxy-authorization',
|
|
258
|
+
'te',
|
|
259
|
+
'trailer',
|
|
260
|
+
'transfer-encoding',
|
|
261
|
+
'upgrade',
|
|
262
|
+
]);
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 净化上游响应头:剔除 hop-by-hop 头,返回可安全 writeHead 的干净头对象。
|
|
266
|
+
* 保留原始键大小写(Node writeHead 会归一化处理)。
|
|
267
|
+
* @param {object} headers 上游响应头(IncomingMessage.headers 形式)
|
|
268
|
+
* @param {Set<string>} [extra] 额外需剔除的头(小写)
|
|
269
|
+
*/
|
|
270
|
+
function sanitizeProxyHeaders(headers, extra) {
|
|
271
|
+
const out = {};
|
|
272
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
273
|
+
if (HOP_BY_HOP_HEADERS.has(k.toLowerCase())) continue;
|
|
274
|
+
if (extra && extra.has(k.toLowerCase())) continue;
|
|
275
|
+
out[k] = v;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
248
280
|
/**
|
|
249
281
|
* HTTP + WebSocket 代理服务器(带安全认证守门)
|
|
250
282
|
* 关键:改写 Host + Origin,注入 crypto.randomUUID polyfill
|
|
@@ -412,6 +444,10 @@ class ProxyServer {
|
|
|
412
444
|
const isLocked = this.authManager?.isIpBlocked(clientIp);
|
|
413
445
|
const html = renderLoginPage({
|
|
414
446
|
hasPassword: this.authManager?.hasPassword,
|
|
447
|
+
// 管理员是否从未设置过任何密码(含独立管理密码):登录页需如实提示,
|
|
448
|
+
// 避免出现"输入任意密码都能进"的假门禁
|
|
449
|
+
noPasswordConfigured: !this.authManager?.hasPassword && !this.authManager?.hasAdminPassword,
|
|
450
|
+
mode: this.authManager?.mode,
|
|
415
451
|
locked: isLocked,
|
|
416
452
|
error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
|
|
417
453
|
});
|
|
@@ -431,27 +467,51 @@ class ProxyServer {
|
|
|
431
467
|
{ host: '127.0.0.1', port: this.targetPort, method: req.method, path: req.url, headers, agent: false },
|
|
432
468
|
(proxyRes) => {
|
|
433
469
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
434
|
-
//
|
|
435
|
-
|
|
470
|
+
// 会话投影剥离(session.list / session.history):这两个端点响应体可能达
|
|
471
|
+
// 数十 MB(contextHeaders 投影无 UI 读取),剥离后显著降低公网传输体积。
|
|
472
|
+
// 覆盖所有入口:局域网 / Cloudflare 隧道 / 外部隧道登记 / 自建隧道。
|
|
473
|
+
const isSessionProjection = pathname.startsWith('/api/session.list') || pathname.startsWith('/api/session.history');
|
|
474
|
+
// session 投影剥离须覆盖压缩(gzip)响应:v2.10.4 起 stripSessionProjections 支持
|
|
475
|
+
// gunzip 解压,因此压缩的 session 响应也应进入缓冲分支进行剥离(此前 !isCompressed
|
|
476
|
+
// 门控会跳过压缩响应,导致 DSH 启用 gzip 后局域网/CF/外部隧道入口剥离失效——只有
|
|
477
|
+
// 自建隧道入口正确剥离)。HTML 注入仍只在未压缩时进行(注入逻辑处理明文)。
|
|
478
|
+
const sessionProjectionBuffered = isSessionProjection && proxyRes.statusCode === 200;
|
|
479
|
+
const shouldBuffer = (contentType.includes('text/html') && !isCompressed(proxyRes.headers))
|
|
480
|
+
|| sessionProjectionBuffered;
|
|
481
|
+
if (shouldBuffer) {
|
|
436
482
|
const chunks = [];
|
|
437
483
|
proxyRes.on('data', (c) => chunks.push(c));
|
|
438
484
|
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');
|
|
485
|
+
let out = Buffer.concat(chunks);
|
|
444
486
|
const outHeaders = { ...proxyRes.headers };
|
|
487
|
+
if (contentType.includes('text/html') && !isCompressed(proxyRes.headers)) {
|
|
488
|
+
let html = out.toString('utf8');
|
|
489
|
+
if (!html.includes(INJECT_MARK)) {
|
|
490
|
+
html = html.replace(/<head[^>]*>/i, (m) => `${m}${HTML_HEAD_INJECTIONS}`);
|
|
491
|
+
}
|
|
492
|
+
out = Buffer.from(html, 'utf8');
|
|
493
|
+
} else if (isSessionProjection) {
|
|
494
|
+
const { body, stripped } = stripSessionProjections(pathname, out, proxyRes.headers['content-encoding']);
|
|
495
|
+
if (stripped) {
|
|
496
|
+
out = body;
|
|
497
|
+
// 已修改 body,清除原始压缩编码标记
|
|
498
|
+
delete outHeaders['content-encoding'];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// 缓冲改写过 body:必须去掉原始传输头,避免 content-length 与
|
|
502
|
+
// transfer-encoding 并存导致客户端解析错误(HPE_INVALID_CONTENT_LENGTH)
|
|
445
503
|
delete outHeaders['content-length'];
|
|
446
504
|
delete outHeaders['transfer-encoding'];
|
|
447
505
|
outHeaders['content-length'] = String(out.length);
|
|
448
|
-
res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
|
|
506
|
+
res.writeHead(proxyRes.statusCode ?? 200, sanitizeProxyHeaders(outHeaders));
|
|
449
507
|
res.end(out);
|
|
450
508
|
});
|
|
451
509
|
proxyRes.on('error', () => res.destroy());
|
|
452
510
|
return;
|
|
453
511
|
}
|
|
454
|
-
|
|
512
|
+
// 直通分支同样剔除 hop-by-hop 头(transfer-encoding/connection 等),
|
|
513
|
+
// 防止透传给客户端导致解析错误;Node 会按 writeHead+pipe 自动生成正确的传输头
|
|
514
|
+
res.writeHead(proxyRes.statusCode ?? 502, sanitizeProxyHeaders(proxyRes.headers));
|
|
455
515
|
proxyRes.pipe(res);
|
|
456
516
|
res.on('close', () => proxyRes.destroy());
|
|
457
517
|
proxyRes.on('error', () => res.destroy());
|
|
@@ -481,8 +541,15 @@ class ProxyServer {
|
|
|
481
541
|
});
|
|
482
542
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
483
543
|
socket.write('HTTP/1.1 101 Switching Protocols\r\n');
|
|
544
|
+
// WS 升级成功:手写原始 socket 响应,只放行必要的 101 头(白名单),
|
|
545
|
+
// 避免上游注入任意头(如 set-cookie / 自定义敏感头)透传给浏览器
|
|
546
|
+
const WS_101_ALLOW = new Set([
|
|
547
|
+
'connection', 'upgrade', 'sec-websocket-accept',
|
|
548
|
+
'sec-websocket-extensions', 'sec-websocket-protocol',
|
|
549
|
+
]);
|
|
484
550
|
const raw = [];
|
|
485
551
|
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
552
|
+
if (!WS_101_ALLOW.has(k.toLowerCase())) continue;
|
|
486
553
|
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
487
554
|
}
|
|
488
555
|
socket.write(`${raw.join('\r\n')}\r\n\r\n`);
|
|
@@ -500,7 +567,8 @@ class ProxyServer {
|
|
|
500
567
|
if (proxyRes.statusCode === 101) return;
|
|
501
568
|
try {
|
|
502
569
|
const raw = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage ?? ''}`.trim()];
|
|
503
|
-
|
|
570
|
+
// 非 101(如后端拒绝升级):剔除 hop-by-hop 头后再回写
|
|
571
|
+
for (const [k, v] of Object.entries(sanitizeProxyHeaders(proxyRes.headers))) {
|
|
504
572
|
raw.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
505
573
|
}
|
|
506
574
|
socket.end(raw.join('\r\n') + '\r\n\r\n');
|
|
@@ -574,6 +642,10 @@ class BridgeService {
|
|
|
574
642
|
// DSH 宿主版本:惰性探测一次并缓存(进程生命周期内不变,避免频繁 spawn 子进程)
|
|
575
643
|
this._dshVersion = null;
|
|
576
644
|
this._dshVersionLoaded = false;
|
|
645
|
+
// 版本检查结果缓存(10 分钟 TTL):客户端轮询/多处面板重复调用时不反复打 npm registry
|
|
646
|
+
this._versionCheckCache = null;
|
|
647
|
+
this._versionCheckCachedAt = 0;
|
|
648
|
+
this._versionCheckTtlMs = 10 * 60 * 1000;
|
|
577
649
|
}
|
|
578
650
|
|
|
579
651
|
/**
|
|
@@ -583,7 +655,9 @@ class BridgeService {
|
|
|
583
655
|
*/
|
|
584
656
|
async getDshVersion() {
|
|
585
657
|
if (this._dshVersionLoaded) return this._dshVersion;
|
|
586
|
-
|
|
658
|
+
// 上次探测失败后的退避期未到:不 spawn,直接返回 null(面板仍可正常加载)
|
|
659
|
+
if (this._dshVersionRetryAt && Date.now() < this._dshVersionRetryAt) return this._dshVersion;
|
|
660
|
+
this._dshVersionLoaded = true; // 尝试期间置位;成功保持,失败在 catch 里复位并设退避
|
|
587
661
|
|
|
588
662
|
const isWin = process.platform === 'win32';
|
|
589
663
|
const nodeDir = dirname(process.execPath);
|
|
@@ -624,6 +698,10 @@ class BridgeService {
|
|
|
624
698
|
} catch (e) {
|
|
625
699
|
this.logger?.debug?.('dsh-bridge: 探测 DSH 版本失败: %s', e.message);
|
|
626
700
|
this._dshVersion = null;
|
|
701
|
+
// 失败:复位已加载标记并设 10 分钟退避期。退避期内 getStatus 不再 spawn,
|
|
702
|
+
// 到期后允许重试一次(用户修复 PATH 后无需重启即可恢复版本显示)。
|
|
703
|
+
this._dshVersionLoaded = false;
|
|
704
|
+
this._dshVersionRetryAt = Date.now() + 10 * 60 * 1000;
|
|
627
705
|
}
|
|
628
706
|
return this._dshVersion;
|
|
629
707
|
}
|
|
@@ -644,9 +722,14 @@ class BridgeService {
|
|
|
644
722
|
targetPort: this.dshPort,
|
|
645
723
|
authManager: this.authManager,
|
|
646
724
|
logger: this.logger,
|
|
647
|
-
// loopback-token
|
|
725
|
+
// loopback-token 允许跨域的面板来源:DSH 原生端口(直连 3080 场景)、代理端口、
|
|
726
|
+
// 当前局域网 IP、隧道公网地址。直连原生端口时页面相对路径拿不到 token,
|
|
727
|
+
// 必须允许它跨域回读 3082 代理端口的 loopback-token 端点(见 issue #28)。
|
|
648
728
|
allowedOrigins: () => {
|
|
649
|
-
const origins = [
|
|
729
|
+
const origins = [
|
|
730
|
+
`http://127.0.0.1:${this.proxyPort}`, `http://localhost:${this.proxyPort}`,
|
|
731
|
+
`http://127.0.0.1:${this.dshPort}`, `http://localhost:${this.dshPort}`,
|
|
732
|
+
];
|
|
650
733
|
try {
|
|
651
734
|
for (const iface of listAllLanIPv4()) origins.push(`http://${iface.address}:${this.proxyPort}`);
|
|
652
735
|
if (this.selectedLanIp) origins.push(`http://${this.selectedLanIp}:${this.proxyPort}`);
|
|
@@ -732,6 +815,7 @@ class BridgeService {
|
|
|
732
815
|
configured: !!(this.customTunnelConfig?.serverUrl && this.customTunnelConfig?.accessToken),
|
|
733
816
|
serverUrl: this.customTunnelConfig?.serverUrl ?? '',
|
|
734
817
|
running: !!this.customTunnel?.connected,
|
|
818
|
+
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
735
819
|
url: customUrl,
|
|
736
820
|
rawUrl: baseCustomUrl,
|
|
737
821
|
qr: customUrl
|
|
@@ -827,6 +911,7 @@ class BridgeService {
|
|
|
827
911
|
accessToken,
|
|
828
912
|
localPort: this.proxyPort,
|
|
829
913
|
internalTunnelSecret: this.authManager?.internalTunnelSecret,
|
|
914
|
+
sseStreaming: Boolean(this.customTunnelConfig?.sseStreaming),
|
|
830
915
|
onStateChange: (state) => {
|
|
831
916
|
this.customTunnelState = state;
|
|
832
917
|
},
|
|
@@ -915,6 +1000,10 @@ class BridgeService {
|
|
|
915
1000
|
|
|
916
1001
|
// 检查 npm 上是否有新版本(优先国内高速镜像 npmmirror,降级 npmjs 官方源)
|
|
917
1002
|
async checkVersion() {
|
|
1003
|
+
// TTL 缓存:窗口内直接返回上次结果,避免重复请求外网 registry
|
|
1004
|
+
if (this._versionCheckCache && Date.now() - this._versionCheckCachedAt < this._versionCheckTtlMs) {
|
|
1005
|
+
return this._versionCheckCache;
|
|
1006
|
+
}
|
|
918
1007
|
const fetchRegistry = (url, timeoutMs = 4000) => new Promise((resolve, reject) => {
|
|
919
1008
|
const req = httpsGet(url, { timeout: timeoutMs, headers: { 'User-Agent': 'dsh-bridge' } }, (res) => {
|
|
920
1009
|
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
|
|
@@ -939,13 +1028,17 @@ class BridgeService {
|
|
|
939
1028
|
try {
|
|
940
1029
|
const latestData = await fetchRegistry('https://registry.npmmirror.com/@wenbin_wb/dsh-bridge/latest', 3500)
|
|
941
1030
|
.catch(() => fetchRegistry('https://registry.npmjs.org/@wenbin_wb/dsh-bridge/latest', 5000));
|
|
942
|
-
|
|
1031
|
+
const result = {
|
|
943
1032
|
current: VERSION,
|
|
944
1033
|
latest: latestData?.version ?? null,
|
|
945
1034
|
releaseNotes: latestData?.releaseNotes ?? null,
|
|
946
1035
|
dshVersion: await this.getDshVersion(),
|
|
947
1036
|
};
|
|
1037
|
+
this._versionCheckCache = result;
|
|
1038
|
+
this._versionCheckCachedAt = Date.now();
|
|
1039
|
+
return result;
|
|
948
1040
|
} catch (e) {
|
|
1041
|
+
// 失败不缓存(让下次调用可重试),但记录便于诊断
|
|
949
1042
|
return { current: VERSION, latest: null, error: e.message ?? '检查失败', dshVersion: await this.getDshVersion() };
|
|
950
1043
|
}
|
|
951
1044
|
}
|
|
@@ -1775,13 +1868,14 @@ function apply(ctx, config = {}) {
|
|
|
1775
1868
|
telegram,
|
|
1776
1869
|
platformManager,
|
|
1777
1870
|
logger,
|
|
1778
|
-
saveCustomTunnelConfig: async (serverUrl, accessToken) => {
|
|
1871
|
+
saveCustomTunnelConfig: async (serverUrl, accessToken, sseStreaming) => {
|
|
1779
1872
|
const stored = await updateConfig((current) => {
|
|
1780
1873
|
const prev = service.customTunnelConfig ?? {};
|
|
1781
1874
|
const next = { ...prev };
|
|
1782
1875
|
// 与 saveCloudflaredConfig 同契约:undefined/掩码保留现值,空串清除
|
|
1783
1876
|
if (serverUrl !== undefined) next.serverUrl = String(serverUrl).trim();
|
|
1784
1877
|
if (accessToken !== undefined) next.accessToken = accessToken === '******' ? (prev.accessToken ?? '') : accessToken;
|
|
1878
|
+
if (sseStreaming !== undefined) next.sseStreaming = Boolean(sseStreaming);
|
|
1785
1879
|
current.customTunnel = next;
|
|
1786
1880
|
return current;
|
|
1787
1881
|
});
|
|
@@ -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 根据用户意图显式决定何时向用户发送文件附件,杜绝底层盲目扫描与误发。
|