@yezack/dsh-remote-mobile 1.9.2 → 1.10.2

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.
@@ -30,9 +30,13 @@
30
30
  * - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
31
31
  * - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
32
32
  */
33
+ import net from 'node:net';
33
34
  import { timingSafeEqual } from 'node:crypto';
34
- import { getClientIp, isTailscaleIp, isLanIp } from '../auth/tailscale.js';
35
- import { isLoopbackRequest } from '../auth/gate.js';
35
+ import zlib from 'node:zlib';
36
+ import { getPhysicalSocketIp, getClientIp, isTailscaleIp, isLanIp, getAllNetworkIps } from '../auth/tailscale.js';
37
+ import { getGatewaySecret, safeCompareSecret } from '../gateway/secret.js';
38
+ import { extractBareHostname, extractPortString, isLoopbackRequest, isTrustedHost, hasCrossSiteSignal, getRequestProtocol, } from '../auth/gate.js';
39
+ export { extractBareHostname, extractPortString };
36
40
  import { isMobileUserAgent, applyDataMobileAttr, buildMobileStyleTag, } from '../styles/style-snippets.js';
37
41
  /**
38
42
  * 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
@@ -331,12 +335,53 @@ export const MOBILE_DISMISS_SNIPPET = `
331
335
  setInterval(ensure, 800);
332
336
  })();
333
337
  `;
338
+ /**
339
+ * 向 HTML 文档的 <head> 顶部安全注入样式片段与 Polyfill 脚本(自带幂等防重守卫)
340
+ *
341
+ * @param html - 待注入的原始 HTML 字符串
342
+ * @param isMobile - 是否为移动端(用于追加 data-dsh-mobile 标记)
343
+ * @param mobileStyleCss - 动态收集的样式片段 CSS(支持按视口宽度生效)
344
+ */
345
+ export function injectHtmlPayload(html, isMobile = false, mobileStyleCss = '') {
346
+ if (!html || !html.includes('<head>'))
347
+ return html;
348
+ let result = html;
349
+ if (isMobile) {
350
+ result = applyDataMobileAttr(result);
351
+ }
352
+ // 1. 样式片段覆写(按视口宽度生效,优先且幂等)
353
+ if (mobileStyleCss && !result.includes('id="dsh-remote-mobile-style-snippets"')) {
354
+ result = result.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
355
+ }
356
+ // 2. 悬浮把手拖拽脚本(全端注入,内部自带仅折叠时生效守卫)
357
+ if (!result.includes('id="dsh-draggable-nav"')) {
358
+ result = result.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
359
+ }
360
+ // 3. 移动端抽屉「点外部收起」脚本
361
+ if (!result.includes('id="dsh-mobile-dismiss"')) {
362
+ result = result.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
363
+ }
364
+ // 4. 非安全上下文 Crypto Polyfill 脚本
365
+ if (!result.includes('id="dsh-crypto-polyfill"')) {
366
+ result = result.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
367
+ }
368
+ // 5. 非安全上下文 Clipboard Polyfill 脚本
369
+ if (!result.includes('id="dsh-clipboard-polyfill"')) {
370
+ result = result.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
371
+ }
372
+ // 6. 桌面端全家桶冲突防护脚本
373
+ if (!result.includes('id="dsh-force-desktop-suppression"')) {
374
+ result = result.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
375
+ }
376
+ return result;
377
+ }
334
378
  /**
335
379
  * 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
336
380
  *
337
381
  * @param server - 底层 Node.js http.Server 实例
338
382
  * @param gateMiddleware - 全局安全门禁拦截器函数
339
383
  * @param store - 会话与配置管理器实例
384
+ * @param styleStore - 样式片段管理器实例
340
385
  */
341
386
  export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, styleStore) {
342
387
  if (!server || server.__dsh_tailscale_gate_patched__)
@@ -357,7 +402,7 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
357
402
  if (req.socket && !req.socket.__dsh_real_remote_address__) {
358
403
  req.socket.__dsh_real_remote_address__ = req.socket.remoteAddress;
359
404
  }
360
- // 1. 响应层拦截:在主文档 index.html 响应流中的 <head> 首行注入 Crypto Polyfill
405
+ // 1. 响应层拦截:在主文档 index.html 响应流中注入样式片段与 Polyfill 脚本
361
406
  const originalEnd = res.end;
362
407
  let isHtml = false;
363
408
  res.writeHead = ((origWriteHead) => {
@@ -373,100 +418,174 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
373
418
  res.end = function (chunk, ...args) {
374
419
  const currentCt = this.getHeader?.('content-type') || this.getHeader?.('Content-Type') || '';
375
420
  const shouldInject = isHtml || String(currentCt).includes('text/html');
421
+ const contentEncoding = String(this.getHeader?.('content-encoding') || this.getHeader?.('Content-Encoding') || '').toLowerCase();
376
422
  // 是否实际改写了响应体(注入会改变 body 字节长度)
377
423
  let injected = false;
378
424
  if (shouldInject && chunk) {
379
425
  if (typeof chunk === 'string' && chunk.includes('<head>')) {
380
- let html = chunk;
381
- if (isMobile)
382
- html = applyDataMobileAttr(html);
383
- if (mobileStyleCss) {
384
- html = html.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
426
+ const updated = injectHtmlPayload(chunk, isMobile, mobileStyleCss);
427
+ if (updated !== chunk) {
428
+ chunk = updated;
429
+ injected = true;
385
430
  }
386
- // 悬浮把手拖拽脚本全端注入:脚本自带“仅折叠时工作”守卫,
387
- // 预设按视口宽度生效后 PC 窄窗口同样需要可拖动的展开把手
388
- html = html.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
389
- // 移动端抽屉「点外部收起」全端注入:脚本自带 web-ui 让位守卫,
390
- // web-ui 在场时完全交给它处理,未装时复刻等价行为
391
- html = html.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
392
- html = html.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
393
- html = html.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
394
- // 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
395
- html = html.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
396
- chunk = html;
397
- injected = true;
398
431
  }
399
432
  else if (Buffer.isBuffer(chunk)) {
400
- let str = chunk.toString('utf8');
401
- if (str.includes('<head>')) {
402
- if (isMobile)
403
- str = applyDataMobileAttr(str);
404
- if (mobileStyleCss) {
405
- str = str.replace('<head>', `<head>${buildMobileStyleTag(mobileStyleCss)}`);
433
+ // 判定是否经过压缩:Gzip (Magic Number 0x1f 0x8b) 或 Deflate
434
+ const isGzip = contentEncoding.includes('gzip') || (chunk.length >= 2 && chunk[0] === 0x1f && chunk[1] === 0x8b);
435
+ const isDeflate = contentEncoding.includes('deflate');
436
+ if (isGzip) {
437
+ try {
438
+ const decompressed = zlib.gunzipSync(chunk).toString('utf8');
439
+ if (decompressed.includes('<head>')) {
440
+ const updated = injectHtmlPayload(decompressed, isMobile, mobileStyleCss);
441
+ if (updated !== decompressed) {
442
+ chunk = zlib.gzipSync(Buffer.from(updated, 'utf8'));
443
+ injected = true;
444
+ }
445
+ }
446
+ }
447
+ catch { }
448
+ }
449
+ else if (isDeflate) {
450
+ try {
451
+ const decompressed = zlib.inflateSync(chunk).toString('utf8');
452
+ if (decompressed.includes('<head>')) {
453
+ const updated = injectHtmlPayload(decompressed, isMobile, mobileStyleCss);
454
+ if (updated !== decompressed) {
455
+ chunk = zlib.deflateSync(Buffer.from(updated, 'utf8'));
456
+ injected = true;
457
+ }
458
+ }
459
+ }
460
+ catch { }
461
+ }
462
+ else {
463
+ // 普通未压缩 Buffer
464
+ const str = chunk.toString('utf8');
465
+ if (str.includes('<head>')) {
466
+ const updated = injectHtmlPayload(str, isMobile, mobileStyleCss);
467
+ if (updated !== str) {
468
+ chunk = Buffer.from(updated, 'utf8');
469
+ injected = true;
470
+ }
406
471
  }
407
- str = str.replace('<head>', `<head><script id="dsh-draggable-nav">${DRAGGABLE_NAV_SNIPPET}</script>`);
408
- str = str.replace('<head>', `<head><script id="dsh-mobile-dismiss">${MOBILE_DISMISS_SNIPPET}</script>`);
409
- str = str.replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`);
410
- str = str.replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`);
411
- str = str.replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
412
- chunk = Buffer.from(str, 'utf8');
413
- injected = true;
414
472
  }
415
473
  }
416
474
  }
417
- // 防截断守卫:仅当头部尚未发出时移除显式的 Content-Length,
418
- // 让 Node 按最终 body 重新计算长度(writeHead 已发出后 removeHeader 无效,属不可挽救场景)
475
+ // 长度防截断守卫:仅当头部尚未发出时移除显式的 Content-Length,
476
+ // 让 Node 按最终 body 重新计算长度或转为分块传输
419
477
  if (injected && !this.headersSent) {
420
478
  try {
421
- if (typeof this.removeHeader === 'function')
479
+ if (typeof this.removeHeader === 'function') {
422
480
  this.removeHeader('content-length');
481
+ this.removeHeader('Content-Length');
482
+ }
423
483
  }
424
484
  catch { }
425
485
  }
426
486
  return originalEnd.apply(this, [chunk, ...args]);
427
487
  };
488
+ // 1.5 在门禁判定前校验受信网关 Secret。
489
+ // 网关转发请求同样来自物理回环,且带有 X-Forwarded-*,不能仅凭 Host/Forwarded
490
+ // 把它误判为同机反向代理;这里用 Secret 打上受信标记,供 isLoopbackRequest 识别。
491
+ try {
492
+ const trustSocketIp = getPhysicalSocketIp(req);
493
+ const trustIsLoopback = trustSocketIp === '127.0.0.1' || trustSocketIp === '::1';
494
+ const trustSecret = getGatewaySecret(server);
495
+ if (trustIsLoopback &&
496
+ trustSecret &&
497
+ safeCompareSecret(req.headers['x-dsh-gateway-secret'], trustSecret)) {
498
+ ;
499
+ req.__dsh_rm_trusted_gateway__ = true;
500
+ }
501
+ }
502
+ catch { }
428
503
  // 2. 请求层门禁判定与上下文虚拟化
429
504
  const allowed = gateMiddleware(req, res, () => {
430
- // 记录真实客户端来源信息(内部专用头,无条件覆盖避免伪造)
431
- const realIp = getClientIp(req);
432
- req.headers['x-dsh-real-ip'] = realIp;
433
- req.headers['x-real-ip'] = realIp;
505
+ // 物理 Socket 连接真实 IP(严格取底层物理连接,仅去除 ::ffff: 前缀,绝不受任何头篡改)
506
+ const physicalSocketIp = getPhysicalSocketIp(req);
507
+ const isLoopbackSocket = physicalSocketIp === '127.0.0.1' || physicalSocketIp === '::1' || physicalSocketIp === 'localhost';
508
+ // 网关受信密钥核验:仅当底层物理连接为本地回环且 Secret 比对一致时,才采纳网关上报的有效真实客户端 IP
509
+ const currentSecret = getGatewaySecret(server);
510
+ const candidateSecret = req.headers['x-dsh-gateway-secret'];
511
+ const candidateRealIp = req.headers['x-dsh-real-ip'];
512
+ const candidateIpStr = Array.isArray(candidateRealIp) ? candidateRealIp[0] : candidateRealIp;
513
+ const trustedGateway = isLoopbackSocket &&
514
+ Boolean(currentSecret) &&
515
+ safeCompareSecret(candidateSecret, currentSecret);
516
+ let effectiveClientIp = physicalSocketIp;
517
+ if (trustedGateway &&
518
+ typeof candidateIpStr === 'string' &&
519
+ candidateIpStr.trim() !== '' &&
520
+ net.isIP(candidateIpStr.replace(/^::ffff:/, '').trim()) !== 0) {
521
+ effectiveClientIp = candidateIpStr.replace(/^::ffff:/, '').trim();
522
+ }
523
+ else if (isLoopbackSocket && !isLoopbackRequest(req)) {
524
+ // 同机反向代理:物理来源是 127,但 Host/Forwarded 表明是外部流量;
525
+ // 采用代理透传的 X-Forwarded-For 首个合法 IP,恢复防暴破/审计真实来源。
526
+ const rawXff = req.headers['x-forwarded-for'];
527
+ const value = Array.isArray(rawXff) ? rawXff[0] : rawXff;
528
+ if (typeof value === 'string') {
529
+ const first = value.split(',')[0].trim().replace(/^::ffff:/, '');
530
+ if (net.isIP(first))
531
+ effectiveClientIp = first;
532
+ }
533
+ }
534
+ else if (!isLoopbackSocket && req.headers['x-forwarded-for']) {
535
+ // 反向代理位于 LAN/Tailscale 网段:同样使用代理透传的真实 IP
536
+ const rawXff = req.headers['x-forwarded-for'];
537
+ const value = Array.isArray(rawXff) ? rawXff[0] : rawXff;
538
+ if (typeof value === 'string') {
539
+ const first = value.split(',')[0].trim().replace(/^::ffff:/, '');
540
+ if (net.isIP(first))
541
+ effectiveClientIp = first;
542
+ }
543
+ }
544
+ // 同步有效客户端 IP 到三头(外部伪造的头在此被强制净化,下游生态插件与审计均可准确读取)
545
+ req.headers['x-dsh-real-ip'] = effectiveClientIp;
546
+ req.headers['x-real-ip'] = effectiveClientIp;
547
+ req.headers['x-forwarded-for'] = effectiveClientIp;
548
+ // 消费完毕后立即抹除内部私密密钥头,防止向下游泄露
549
+ delete req.headers['x-dsh-gateway-secret'];
434
550
  if (!req.headers['x-forwarded-host'] && req.headers.host) {
435
551
  req.headers['x-forwarded-host'] = req.headers.host;
436
552
  }
437
- // 回环 socket(本机浏览器 / 本机程序)不做 Host/Origin/sec-fetch-site 洗白:
438
- // 保留浏览器原始同源信号,让下游自身的 CSRF 与 DNS-Rebinding 校验继续生效,
439
- // 防御恶意网页借道本机发起的 drive-by 跨站写与 Rebinding 攻击;虚拟化仅服务于外部合法流量。
440
- const isLoopbackSocket = realIp === '127.0.0.1' || realIp === '::1';
553
+ // 仅当底层物理 socket 为外部来源(Direct 模式直接对公网开放)时才由底座补丁进行外部虚拟化;
554
+ // 若底层物理 socket 为本地回环(包括本机直接访问或来自受信 Gateway 的回环转发),底座绝不无条件洗白 Origin,
555
+ // 保证下游生态插件的 CSRF / DNS-Rebinding 校验全额生效。
441
556
  if (!isLoopbackSocket) {
442
- // 虚拟化 Host 与 Origin(解决外部合法流量的 DNS-Rebinding 400 阻断与同源 host === origin 严格检查)
557
+ // Direct 外部直连模式下的 Host/Origin 虚拟化
558
+ const externalProto = getRequestProtocol(req);
443
559
  const hostHeader = req.headers.host || '';
444
- const port = hostHeader.includes(':') ? hostHeader.split(':')[1] : '3080';
560
+ const port = extractPortString(hostHeader) || '3080';
445
561
  req.headers.host = `127.0.0.1:${port}`;
446
562
  if (req.headers.origin) {
447
- req.headers.origin = `http://127.0.0.1:${port}`;
563
+ req.headers.origin = `${externalProto}://127.0.0.1:${port}`;
448
564
  }
449
- // 补齐浏览器同源探针标记(解决 task-board 等插件对 sec-fetch-site 的检查)
450
565
  if (!req.headers['sec-fetch-site'] || req.headers['sec-fetch-site'] === 'cross-site') {
451
566
  req.headers['sec-fetch-site'] = 'same-origin';
452
567
  }
453
568
  }
454
569
  else {
455
- // 仅归一化本地 Host 变体(localhost / [::1] → 127.0.0.1),其余原样保留供下游校验
570
+ // 本地回环连接:仅成对规范化本地 Host 变体(localhost / [::1] → 127.0.0.1)
571
+ // 关键安全原则:仅当 Origin 自身也是本地回环变体时才成对规范化;恶意的跨站 Origin(如 http://evil.com)必须 100% 原样保留!
456
572
  const hostHeader = req.headers.host || '';
457
- let bareHost = hostHeader;
458
- if (bareHost.startsWith('[')) {
459
- bareHost = bareHost.slice(1).split(']')[0];
460
- }
461
- else {
462
- bareHost = bareHost.split(':')[0];
463
- }
464
- bareHost = bareHost.toLowerCase();
465
- if (bareHost === 'localhost' || bareHost === '::1' || /^127\./.test(bareHost)) {
466
- const portMatch = hostHeader.match(/:(\d+)$/);
467
- req.headers.host = `127.0.0.1:${portMatch ? portMatch[1] : '3080'}`;
468
- if (req.headers.origin) {
469
- req.headers.origin = `http://${req.headers.host}`;
573
+ const bareHost = extractBareHostname(hostHeader);
574
+ const isLocalHostVariant = bareHost === 'localhost' || bareHost === '::1' || /^127\./.test(bareHost);
575
+ if (isLocalHostVariant) {
576
+ const targetPort = extractPortString(hostHeader) || '3080';
577
+ req.headers.host = `127.0.0.1:${targetPort}`;
578
+ if (req.headers.origin && typeof req.headers.origin === 'string') {
579
+ try {
580
+ const origUrl = new URL(req.headers.origin);
581
+ const origHostname = origUrl.hostname.toLowerCase();
582
+ const isLocalOrigin = origHostname === 'localhost' || origHostname === '::1' || /^127\./.test(origHostname);
583
+ if (isLocalOrigin) {
584
+ req.headers.origin = `${origUrl.protocol}//127.0.0.1:${targetPort}`;
585
+ }
586
+ // 非本地 Origin(如 evil.com)原样保留,供下游 CSRF 检查
587
+ }
588
+ catch { }
470
589
  }
471
590
  }
472
591
  }
@@ -492,27 +611,54 @@ export function patchHttpServerWithVirtualizer(server, gateMiddleware, _store, s
492
611
  // 请求未通过安全门禁,已在门禁层执行重定向或返回 401 拦截
493
612
  }
494
613
  });
614
+ // WebSocket Upgrade 不经过上面的 request 监听器;同样需要在校验网关 Secret 后打受信标记,
615
+ // 否则网关转发的 WS 会因为 X-Forwarded-* 被误判为同机反代,从而在底座再次要求 token。
616
+ server.prependListener('upgrade', (req) => {
617
+ try {
618
+ const upgradeSocketIp = getPhysicalSocketIp(req);
619
+ const upgradeIsLoopback = upgradeSocketIp === '127.0.0.1' || upgradeSocketIp === '::1';
620
+ const upgradeSecret = getGatewaySecret(server);
621
+ if (upgradeIsLoopback &&
622
+ upgradeSecret &&
623
+ safeCompareSecret(req.headers['x-dsh-gateway-secret'], upgradeSecret)) {
624
+ ;
625
+ req.__dsh_rm_trusted_gateway__ = true;
626
+ delete req.headers['x-dsh-gateway-secret'];
627
+ }
628
+ }
629
+ catch { }
630
+ });
495
631
  }
496
632
  /**
497
- * 注册 WebServer HTML 模板层的 Polyfill 注入钩子
633
+ * 注册 WebServer HTML 模板层的样式与 Polyfill 注入钩子(在 Gzip 压缩前执行)
498
634
  *
499
635
  * @param ctx - Cordis 上下文对象
636
+ * @param styleStore - 样式片段管理器实例
500
637
  */
501
- export function mountIndexInjections(ctx) {
638
+ export function mountIndexInjections(ctx, styleStore) {
639
+ if (!ctx || ctx.__dsh_rm_index_mounted__)
640
+ return;
641
+ ctx.__dsh_rm_index_mounted__ = true;
642
+ const injectFn = (html) => {
643
+ const mobileStyleCss = styleStore ? styleStore.collectCss() : '';
644
+ // tapIndex 阶段在服务端模板渲染期执行,尚未绑定单次请求的 UA,
645
+ // 因此 isMobile 设为 false,样式的 collectCss() 自带 @media 视口查询,
646
+ // PC 与移动端均能完美自适应生效。
647
+ return injectHtmlPayload(html, false, mobileStyleCss);
648
+ };
502
649
  if (ctx.webServer && typeof ctx.webServer.tapIndex === 'function') {
503
- ctx.webServer.tapIndex((html) => {
504
- if (html.includes('<head>')) {
505
- return html
506
- .replace('<head>', `<head><script id="dsh-crypto-polyfill">${CRYPTO_POLYFILL_SNIPPET}</script>`)
507
- .replace('<head>', `<head><script id="dsh-clipboard-polyfill">${CLIPBOARD_POLYFILL_SNIPPET}</script>`)
508
- // 冲突防护脚本后 replace → 位于 <head> 最前,优先写入 sessionStorage 避免小鲸鱼重复挂载
509
- .replace('<head>', `<head><script id="dsh-force-desktop-suppression">${FORCE_DESKTOP_SUPPRESSION_SNIPPET}</script>`);
510
- }
511
- return html;
512
- });
650
+ ctx.webServer.tapIndex(injectFn);
513
651
  }
514
652
  ctx.on?.('webserver/index-inject', (table) => {
515
653
  if (Array.isArray(table)) {
654
+ const mobileStyleCss = styleStore ? styleStore.collectCss() : '';
655
+ if (mobileStyleCss) {
656
+ table.push({
657
+ kind: 'style',
658
+ placement: 'head',
659
+ text: mobileStyleCss,
660
+ });
661
+ }
516
662
  table.push({
517
663
  kind: 'script',
518
664
  placement: 'head',
@@ -528,6 +674,16 @@ export function mountIndexInjections(ctx) {
528
674
  placement: 'head',
529
675
  text: FORCE_DESKTOP_SUPPRESSION_SNIPPET,
530
676
  });
677
+ table.push({
678
+ kind: 'script',
679
+ placement: 'head',
680
+ text: DRAGGABLE_NAV_SNIPPET,
681
+ });
682
+ table.push({
683
+ kind: 'script',
684
+ placement: 'head',
685
+ text: MOBILE_DISMISS_SNIPPET,
686
+ });
531
687
  }
532
688
  });
533
689
  }
@@ -755,16 +911,30 @@ function safeStringEqual(a, b) {
755
911
  export function isRequestAuthorizedByGate(req, store) {
756
912
  if (!req)
757
913
  return false;
914
+ // 0. WebSocket Upgrade 直连(direct 模式)必须先通过 Host 白名单与 Origin 同源检查。
915
+ // 网关模式在 GatewayServer.handleUpgrade 中已完成同样的检查,并打上 __dsh_rm_host_allowed__,
916
+ // 此处避免重复校验,同时确保 direct 模式的核心 WS 不会被 DNS-Rebinding / CSWSH 绕过。
917
+ const isWebSocketUpgrade = String(req.headers?.upgrade || '').toLowerCase().includes('websocket');
918
+ if (isWebSocketUpgrade && !req.__dsh_rm_host_allowed__) {
919
+ if (!isTrustedHost(req.headers.host, store) || hasCrossSiteSignal(req)) {
920
+ return false;
921
+ }
922
+ }
758
923
  // 1. 本机 127.0.0.1 / ::1 回环无条件放行
759
924
  if (isLoopbackRequest(req))
760
925
  return true;
761
926
  const clientIp = getClientIp(req);
762
927
  const options = store.getOptions();
928
+ // 反向代理位于 LAN/Tailscale 网段时不能被当作真实客户端免密放行
929
+ const hasForwardedHeaders = Boolean(req.headers?.['x-forwarded-for'] ||
930
+ req.headers?.['x-forwarded-host'] ||
931
+ req.headers?.['x-forwarded-proto'] ||
932
+ req.headers?.['x-real-ip']);
763
933
  // 2. Tailscale 私网免密直连放行
764
- if (options.allowTailscale && isTailscaleIp(clientIp))
934
+ if (!hasForwardedHeaders && options.allowTailscale && isTailscaleIp(clientIp))
765
935
  return true;
766
936
  // 3. 局域网 LAN 免密直连放行
767
- if (options.allowLan && isLanIp(clientIp))
937
+ if (!hasForwardedHeaders && options.allowLan && isLanIp(clientIp))
768
938
  return true;
769
939
  // 4. 检查是否携带有效的授权会话 Token (Cookie / Header / URL Token)
770
940
  const token = store.extractTokenFromRequest(req);
@@ -834,4 +1004,77 @@ export function takeOverConnectionAuth(connection, store) {
834
1004
  return origAuthorizeIndex(req, res);
835
1005
  };
836
1006
  }
1007
+ // 4. 动态向 DSH 底座 connection.trustedHosts 注入当前本机所有可用 IP 与网关端口
1008
+ // (适配 DSH 0.2.1+ 底层 Host/Origin 信任围栏,使外部网关合规直通,免去伪造 Host 头带来的副作用)
1009
+ injectTrustedHosts(connection, [store.getActivePort(), 3080, 3081], store.getOptions().trustedHosts);
1010
+ }
1011
+ /**
1012
+ * 向 DSH 运行时(webRuntime、connection、webStartup)的 trustedHosts 数组注入受信任的主机与端口
1013
+ *
1014
+ * @param target - Cordis 上下文对象或包含 trustedHosts 的服务对象
1015
+ * @param ports - 允许访问的活跃端口(包含底座端口与网关实际绑定端口)
1016
+ * @param staticTrustedHosts - 用户静态配置的白名单(如 Tailscale MagicDNS 域名等)
1017
+ */
1018
+ export function injectTrustedHosts(target, ports = [3080, 3081], staticTrustedHosts = []) {
1019
+ if (!target)
1020
+ return;
1021
+ const arraysToInject = [];
1022
+ // 1. 如果 target 本身带有 trustedHosts 数组(如 connection 或 webRuntime)
1023
+ if (Array.isArray(target.trustedHosts)) {
1024
+ arraysToInject.push(target.trustedHosts);
1025
+ }
1026
+ // 2. 如果 target 是 Cordis 上下文 ctx,检查并提取各子服务的 trustedHosts
1027
+ try {
1028
+ const wr = (typeof target.get === 'function' ? target.get('webRuntime') : undefined) || target.webRuntime;
1029
+ if (wr && Array.isArray(wr.trustedHosts) && !arraysToInject.includes(wr.trustedHosts)) {
1030
+ arraysToInject.push(wr.trustedHosts);
1031
+ }
1032
+ const conn = (typeof target.get === 'function' ? target.get('connection') : undefined) || target.connection;
1033
+ if (conn && Array.isArray(conn.trustedHosts) && !arraysToInject.includes(conn.trustedHosts)) {
1034
+ arraysToInject.push(conn.trustedHosts);
1035
+ }
1036
+ const ws = (typeof target.get === 'function' ? target.get('webStartup') : undefined) || target.webStartup;
1037
+ if (ws && Array.isArray(ws.trustedHosts) && !arraysToInject.includes(ws.trustedHosts)) {
1038
+ arraysToInject.push(ws.trustedHosts);
1039
+ }
1040
+ }
1041
+ catch { }
1042
+ if (arraysToInject.length === 0)
1043
+ return;
1044
+ try {
1045
+ const netIps = getAllNetworkIps().map((i) => i.ip);
1046
+ // 规范化静态配置的主机名:去除端口、去除末尾点、过滤通配符
1047
+ const cleanStatics = (staticTrustedHosts || [])
1048
+ .map((h) => {
1049
+ let raw = String(h).trim().toLowerCase().replace(/\.$/, '');
1050
+ if (raw.startsWith('[')) {
1051
+ const closeBracket = raw.indexOf(']');
1052
+ if (closeBracket !== -1)
1053
+ raw = raw.slice(1, closeBracket);
1054
+ }
1055
+ else if (raw.includes(':')) {
1056
+ raw = raw.split(':')[0];
1057
+ }
1058
+ return raw.trim();
1059
+ })
1060
+ .filter((h) => h && h !== '0.0.0.0' && h !== '::');
1061
+ const candidates = ['127.0.0.1', 'localhost', '::1', ...netIps, ...cleanStatics];
1062
+ for (const arr of arraysToInject) {
1063
+ for (const hostItem of candidates) {
1064
+ if (!arr.includes(hostItem)) {
1065
+ arr.push(hostItem);
1066
+ }
1067
+ for (const port of ports) {
1068
+ if (!port)
1069
+ continue;
1070
+ const isIpv6 = hostItem.includes(':') && !hostItem.startsWith('[');
1071
+ const withPort = isIpv6 ? `[${hostItem}]:${port}` : `${hostItem}:${port}`;
1072
+ if (!arr.includes(withPort)) {
1073
+ arr.push(withPort);
1074
+ }
1075
+ }
1076
+ }
1077
+ }
1078
+ }
1079
+ catch { }
837
1080
  }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * DSH 目录选择器(Directory Picker)配置检测与幂等补丁管理模块
3
+ *
4
+ * 【背景与问题】
5
+ * DSH 官方默认的 `id: directory-picker`(`@deepseek-ai/dsh-host-directory-picker-auto`)
6
+ * 仅在进程启动时根据 `webServer.host` 是否为 127.0.0.1 回环地址来判断“本机 / 远程”:
7
+ * - 本插件默认采用安全接入代理模式(DSH 核心保持 127.0.0.1:3080 回环,由 3081 代理对外);
8
+ * - `auto` 因此将所有来自手机端 3081 的请求误判为“本机桌面操作”,直接调用 PC 的系统原生文件选择窗(native);
9
+ * - 最终导致弹窗出现在 PC 屏幕上,手机浏览器一直转圈等待,无法在手机端选择文件夹或添加工作区。
10
+ *
11
+ * 【解决方案】
12
+ * 1. 运行时检测:结合 Cordis loader 激活树与 `~/.dsh/profiles/web/cordis.patch.yml` 配置文件,
13
+ * 精准判断当前是否已启用网页内目录选择器(`browse` 模式);
14
+ * 2. 幂等补丁写入:支持在设置页一键开启/恢复,写入时对 `directory-picker`、`directory-picker-browse`、
15
+ * `ui-directory-picker-browse` 三项分别执行严格结构化去重,绝不产生重复条目,且完整保留用户其它配置。
16
+ */
17
+ export declare const AUTO_DIRECTORY_PICKER_ID = "directory-picker";
18
+ export declare const AUTO_DIRECTORY_PICKER_PKG = "@deepseek-ai/dsh-host-directory-picker-auto";
19
+ export declare const HOST_DIRECTORY_PICKER_BROWSE_ID = "directory-picker-browse";
20
+ export declare const HOST_DIRECTORY_PICKER_BROWSE_PKG = "@deepseek-ai/dsh-host-directory-picker-browse";
21
+ export declare const UI_DIRECTORY_PICKER_BROWSE_ID = "ui-directory-picker-browse";
22
+ export declare const UI_DIRECTORY_PICKER_BROWSE_PKG = "@deepseek-ai/dsh-client-ui-directory-picker-browse";
23
+ /** 标准可复制/可注入的 YAML 配置片段 */
24
+ export declare const DIRECTORY_PICKER_YAML_SNIPPET: string;
25
+ export interface DirectoryPickerPatchInspection {
26
+ /** `- id: directory-picker` 是否已设置 `disabled: true` */
27
+ autoDisabled: boolean;
28
+ /** 是否存在 `- id: directory-picker` 条目 */
29
+ hasAutoEntry: boolean;
30
+ /** `@deepseek-ai/dsh-host-directory-picker-browse` 是否已插入且未禁用 */
31
+ hostBrowseInserted: boolean;
32
+ /** `@deepseek-ai/dsh-client-ui-directory-picker-browse` 是否已插入且未禁用 */
33
+ uiBrowseInserted: boolean;
34
+ /** 三项配置是否已在 patch 文件中全部就绪 */
35
+ configured: boolean;
36
+ /** 是否仅配置了其中一部分(缺失其余项) */
37
+ partial: boolean;
38
+ }
39
+ export interface DirectoryPickerRuntimeInspection {
40
+ /** 是否能够从 Cordis loader 探测到目录选择器相关条目 */
41
+ detected: boolean;
42
+ /** 运行时中 `browse` 目录选择器(前后端)是否已激活生效 */
43
+ browseActive: boolean;
44
+ /** 运行时中 `auto` 目录选择器是否仍处于启用状态 */
45
+ autoActive: boolean;
46
+ }
47
+ export interface DirectoryPickerStatus {
48
+ /** 配置文件中是否已完整开启 browse 补丁 */
49
+ patchEnabled: boolean;
50
+ /** 配置文件中是否仅开启了部分项 */
51
+ patchPartial: boolean;
52
+ /** 当前运行时中网页目录选择器是否已生效 */
53
+ runtimeActive: boolean;
54
+ /** 配置已变更但当前进程尚未生效(需要重启 DSH 或等待 HMR 重载) */
55
+ restartRequired: boolean;
56
+ /** 当前是否需要向用户展示醒目提醒(回环核心且未开启 browse 时为 true) */
57
+ needsWarning: boolean;
58
+ /** 目标 cordis.patch.yml 的绝对路径 */
59
+ patchFilePath: string;
60
+ /** 标准可复制的 YAML 配置片段 */
61
+ yamlSnippet: string;
62
+ }
63
+ /**
64
+ * 绑定 Cordis 运行时上下文与回环核心判定函数(供 /status 路由实时查询)
65
+ */
66
+ export declare function bindDirectoryPickerRuntime(ctx: any, isLoopbackCore?: () => boolean): void;
67
+ /**
68
+ * 定位当前 DSH 的 web profile `cordis.patch.yml` 路径
69
+ *
70
+ * 优先使用 `~/.dsh/profiles/web/cordis.patch.yml`;若存在其他单 profile 则按优先级兜底。
71
+ */
72
+ export declare function resolveDirectoryPickerPatchPath(customPath?: string): string;
73
+ /**
74
+ * 解析 `cordis.patch.yml` 文本内容,检查目录选择器相关配置状态
75
+ */
76
+ export declare function inspectDirectoryPickerPatchContent(content: string): DirectoryPickerPatchInspection;
77
+ /**
78
+ * 检查 Cordis 运行时 loader 中的目录选择器插件激活状态
79
+ */
80
+ export declare function inspectRuntimeDirectoryPicker(ctx?: any): DirectoryPickerRuntimeInspection;
81
+ /**
82
+ * 幂等合并网页目录选择器配置到 `cordis.patch.yml` 内容中(严格去重)
83
+ *
84
+ * 去重规则:
85
+ * 1. 若已存在一个或多个 `- id: directory-picker`:就地将其设为 `disabled: true`,合并重复项;
86
+ * 2. 若已存在 `directory-picker-browse` 或 `ui-directory-picker-browse`(无论在哪个 `insert` 块下):
87
+ * 不重复插入,仅补充缺失的那一项(若原项被设为 `disabled: true` 则移除禁用标记);
88
+ * 3. 若三项均已就绪,原样返回,`changed: false`。
89
+ */
90
+ export declare function applyDirectoryPickerBrowsePatch(rawContent: string): {
91
+ content: string;
92
+ changed: boolean;
93
+ };
94
+ /**
95
+ * 从 `cordis.patch.yml` 内容中安全移除网页目录选择器补丁(恢复 PC 原生目录选择器)
96
+ *
97
+ * 仅移除与 directory-picker 相关的禁用项与两个 browse 插入项,100% 保留用户其他配置。
98
+ */
99
+ export declare function removeDirectoryPickerBrowsePatch(rawContent: string): {
100
+ content: string;
101
+ changed: boolean;
102
+ };
103
+ /**
104
+ * 获取目录选择器当前综合状态(供 `/api/remote-mobile/status` 与设置页消费)
105
+ */
106
+ export declare function getDirectoryPickerStatus(customPatchPath?: string): DirectoryPickerStatus;
107
+ /**
108
+ * 在目标 `cordis.patch.yml` 中一键开启网页目录选择器(幂等去重)
109
+ */
110
+ export declare function enableDirectoryPickerBrowse(customPatchPath?: string): DirectoryPickerStatus & {
111
+ changed: boolean;
112
+ };
113
+ /**
114
+ * 从目标 `cordis.patch.yml` 中移除网页目录选择器配置(恢复 PC 原生目录选择器)
115
+ */
116
+ export declare function disableDirectoryPickerBrowse(customPatchPath?: string): DirectoryPickerStatus & {
117
+ changed: boolean;
118
+ };