@zhangfengshun/dsh-remote-ssh 2.4.9 → 2.4.10

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.10] — 非 loopback 访问不再全线 403:信任判定改用宿主 webRuntime.trustedHosts(issue #12)
6
+ ### 修复
7
+ - **信任判定与 `/api` 网关、better-sidebar 同源**(感谢 @Linhaojing 的验证矩阵与自我更正后的方案):此前 `isTrusted()` 硬编码 loopback 白名单(`localhost` / `127.0.0.1` / `::1` / `0.0.0.0`),从局域网(例如经配对设备或反向代理访问)打开 GUI 时,本插件的 **5 类路由全部 403** —— `/remote-ssh/api/*` 全部 RPC、6 个被拦截的 `fs.*`、`git.*`、文件上传、`/sidebar/file`,也就是文件树 / 读写 / 搜索 / 重命名 / 删除 / git / 上传全线失效,插件几乎等于没装。而同一 Host 下 DSH 官方路由正常,说明是本插件单方面比宿主更严。
8
+ - 现在改为读宿主官方运行时能力 **`ctx.webRuntime.trustedHosts`**(启动时采样的局域网 IP 字面量 + `--trusted-host` 指定的 authority)——与 `/api` 网关的 fence 及 better-sidebar 的 `src/trust-fence.ts` 完全一致(后者是 `@deepseek-ai/dsh-client-connection` 的 `api-request-trust.ts` 的拷贝,上游未导出这些 helper,故按同样方式复刻并在注释中标注来源);
9
+ - **按请求实时读取** `trustedHosts`:用户改动信任列表无需重启插件;
10
+ - **软注入 `webRuntime`**(`ctx.inject(["webRuntime"])` 而非硬 `inject`):万一宿主将来改名/移除该服务,插件仍能挂载,只是远程访问退回 loopback-only,行为与 2.4.10 之前一致——**零回归**;
11
+ - 安全性不降低:`sec-fetch-site: cross-site` 与新增的 **Origin 围栏**(带 Origin 时必须与本请求 hostname 一致;`Origin: null` 拒绝)都在,Host 白名单依旧存在,只是信任源从硬编码换成宿主权威列表;用户未配置信任列表时 `trustedHosts` 只含 loopback,行为与之前完全相同。
12
+ - **403 文案不再误导**:此前无论何种拒绝原因都回 `missing x-requested-with header`,把排查方向带偏(提问者一开始就在查客户端是否漏带头)。现在按原因区分:缺头 → `missing x-requested-with header`(错误码仍为 `csrf`,保持兼容)、Host 不可信 → 明确提示 `untrusted host: … start DSH with --trusted-host <host[:port]> …`、跨站 → `cross-site request refused`、Origin 不符 → `origin does not match the request host`;`/sidebar/file` 的纯文本 403 也带准确原因。
13
+
14
+ ### 测试
15
+ - 新增 `tests/trust-fence.test.mjs`(**61 条断言**):loopback 判定(`localhost` / `[::1]` / `127.x.x.x` 整段 / 越界段拒绝 / `0.0.0.0` 历史放行)、authority 端口语义(条目带端口精确匹配、不带端口按主机名、无效条目跳过)、**逐条复现 issue #12 的验证矩阵**(`127.0.0.1:3080` ✅ / `localhost:3080` ✅ / 局域网 IP 无信任列表 ❌ / 局域网 IP + `trustedHosts` ✅)、cross-site 与 Origin 围栏(含 Edge 151 的「Origin 缺端口」特例与 `Origin: null` 拒绝)、文案区分(Host 被拒不再报缺头、并给出 `--trusted-host` 指引)、以及**组合行为**(提取真实 `requestTrust` + 桩 `trustedHosts`:可信局域网 + 缺头 → 报 `header`;信任列表被替换后立即生效)与接线断言(5 处调用点、软注入、实时读取、错误码兼容)。
16
+
5
17
  ## [2.4.9] — 「添加工作区」目录选择器支持新建目录(issue #11)
6
18
  ### 新增
7
19
  - **「添加工作区」弹窗的目录选择器新增「新建目录」**(感谢 @Linhaojing 的定位与接线建议):此前本地 / 远程两个 tab 都只有「打开 / 上级 / 选择此目录」,要把**尚不存在**的目录加为工作区(例如远端起新项目 `~/lhj/new-project`)必须先跳出 DSH 用别的终端 `mkdir`。现在底部操作区多一个「📁 新建目录」按钮:点开输入行 → 填名字(回车即提交)→ 在当前 `path` 下创建 → **自动进入新目录**,紧接着点「选择此目录」即可成为工作区,全程不离开 DSH(对齐 VSCode「新建文件夹」的交互预期)。
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  **一条命令安装**(无需 token、API Key 或额外配置):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.9
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
50
50
  ```
51
51
 
52
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -202,6 +202,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
202
202
  | `@` 补全只搜到镜像里那几个文件 | 2.4.7 起已修复:远程工作区会话的 `@` 补全改列远端文件(索引缓存 60s + 900ms 查询预算);若仍只有镜像文件,确认 2.4.7 已装入并重启 DSH |
203
203
  | 大仓里 `@` 搜不到真实文件(如根目录 `AGENTS.md`、`src/**`) | 2.4.8 起已修复:此前排除目录发生在截断之后,`node_modules/` 这类目录会吃光索引配额;现在排除由远端 `grep`/`-prune` 在截断前完成,并会在索引达上限时打 warn 提示 |
204
204
  | 想加的远程目录还不存在,「添加工作区」里没法创建 | 2.4.9 起「目录选择器」底部有「📁 新建目录」(本地 / 远程 tab 均有):输入名字即可就地创建并自动进入 |
205
+ | 从局域网 / 另一台设备访问时插件文件能力全部报 403 | 2.4.10 起已修复:信任判定改用宿主 `ctx.webRuntime.trustedHosts`(与 `/api` 网关同源)。把访问地址加进 DSH 信任列表即可:启动时加 `--trusted-host <host[:port]>`(或经配对设备访问);未配置时行为与之前一致(仅本机 loopback) |
205
206
  | 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
206
207
  | 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
207
208
  | 大文件读取被截断 | 单文件读取上限 4MB、下载池化路径约 6.29MB(更大自动回落一次性连接);用 `remote_ssh_exec` + `head`/`tail` 分段处理 |
package/README_EN.md CHANGED
@@ -46,7 +46,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
46
46
  **One command** (no token, API key or extra configuration needed):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.9
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
50
50
  ```
51
51
 
52
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -202,6 +202,7 @@ The plugin never patches DSH sources or injects into the profile dependency tree
202
202
  | `@` completion only finds the few files in the mirror | Fixed in 2.4.7: in a remote-workspace session `@` now lists remote files (60s index cache + 900ms query budget); if only mirror files show up, make sure 2.4.7 is installed and DSH restarted |
203
203
  | In a large repo `@` cannot find real files (e.g. root `AGENTS.md`, `src/**`) | Fixed in 2.4.8: exclusion used to run *after* truncation, so a `node_modules/` tree could exhaust the index quota; exclusion now happens remotely (`grep`/`-prune`) before truncation, and hitting the cap logs a warning |
204
204
  | The remote directory you want does not exist yet and "Add Workspace" cannot create it | Fixed in 2.4.9: the directory picker has a "📁 New folder" button (both tabs) — type a name to create it in place and enter it automatically |
205
+ | Accessing from the LAN / another device makes every file capability return 403 | Fixed in 2.4.10: the trust fence now reads the host's `ctx.webRuntime.trustedHosts` (same source as the `/api` gateway). Add the address to the DSH trust list — start with `--trusted-host <host[:port]>`, or access through your paired remote-access setup; with nothing configured the behaviour is unchanged (loopback only) |
205
206
  | Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
206
207
  | A command hangs forever | The 120s timeout discards the pooled session automatically; use `timeoutMs: 0` for long jobs and `remote_ssh_kill` at any time |
207
208
  | Large files are truncated | 4MB per read, ≈6.29MB on the pooled download path (larger files fall back to a one-shot connection); use `remote_ssh_exec` with `head`/`tail` to page through |
package/lib/index.js CHANGED
@@ -1003,19 +1003,90 @@ function normExec(r) {
1003
1003
  // HTTP 工具
1004
1004
  // ---------------------------------------------------------------------------
1005
1005
 
1006
- function isTrusted(req, requireRequestedWith) {
1007
- const secFetchSite = req.headers["sec-fetch-site"];
1008
- if (secFetchSite === "cross-site") return false;
1006
+ // ---------------------------------------------------------------------------
1007
+ // 浏览器信任判定(DNS-rebinding / 跨站防线,**不是**认证)
1008
+ //
1009
+ // 与 /api 网关、better-sidebar 的 fence 同源:Host 头为 loopback,或命中宿主
1010
+ // ctx.webRuntime.trustedHosts(启动时采样的局域网 IP 字面量 + `--trusted-host`)。
1011
+ // 实现逐条对照 @deepseek-ai/dsh-client-connection 的 api-request-trust.ts
1012
+ // (better-sidebar 的 src/trust-fence.ts 是同一份拷贝,BSD-3-Clause —— 上游未导出这些
1013
+ // helper,故按同样方式在此复刻),差异只有:本插件额外要求 x-requested-with(PR #4 的
1014
+ // CSRF 加固),且把拒绝原因回传给调用方以便给出准确文案(issue #12:此前无论何种原因都
1015
+ // 报 "missing x-requested-with header",把排查方向带偏)。
1016
+ // ---------------------------------------------------------------------------
1017
+
1018
+ /** Host 头 authority 的规范化 URL;不可解析时返回 undefined。 */
1019
+ function parseAuthority(authority) {
1020
+ try {
1021
+ return new URL("http://" + String(authority));
1022
+ } catch (e) {
1023
+ return undefined;
1024
+ }
1025
+ }
1026
+
1027
+ /** hostname 是否指向本机 loopback(localhost / [::1] / 127.x.x.x)。
1028
+ * 额外放行 0.0.0.0:它是本插件历史行为,且浏览器实际不会把它当目的地址发送。 */
1029
+ function isLoopbackHostname(hostname) {
1030
+ if (hostname === "localhost" || hostname === "[::1]" || hostname === "0.0.0.0") return true;
1031
+ const parts = String(hostname).split(".");
1032
+ return parts.length === 4
1033
+ && parts[0] === "127"
1034
+ && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
1035
+ }
1036
+
1037
+ /** authority 的规范形式:写了端口则 hostname:port,否则仅 hostname。 */
1038
+ function canonicalAuthority(entry, entryUrl) {
1039
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL("https://" + String(entry)).port;
1040
+ return port === "" ? entryUrl.hostname : entryUrl.hostname + ":" + port;
1041
+ }
1042
+
1043
+ /** 请求 authority 是否命中 trustedHosts(条目带端口则精确比对 host,否则只比 hostname)。 */
1044
+ function isTrustedAuthority(hostUrl, trustedHosts) {
1045
+ return (trustedHosts || []).some((entry) => {
1046
+ const entryUrl = parseAuthority(entry);
1047
+ if (entryUrl === undefined) return false;
1048
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
1049
+ ? entryUrl.hostname === hostUrl.hostname
1050
+ : entryUrl.host === hostUrl.host;
1051
+ });
1052
+ }
1053
+
1054
+ /** 本插件额外要求的非简单头(PR #4 的 CSRF 加固)。 */
1055
+ function hasRequestedWithHeader(req) {
1056
+ return String(req.headers["x-requested-with"] || "").trim() === "XMLHttpRequest";
1057
+ }
1058
+
1059
+ /** 判定一次请求是否可信:返回 "ok" 或具体拒绝原因(供调用方给出准确文案)。 */
1060
+ function trustVerdict(req, trustedHosts) {
1009
1061
  const host = req.headers["host"];
1010
- if (!host) return false;
1011
- const hostname = String(host).split(":")[0].replace(/^\[/, "").replace(/\]$/, "");
1012
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0") {
1013
- // CSRF 加固(PR #4 的服务端半边):/remote-ssh/api/* 仅由本插件客户端调用,
1014
- // 其请求必带 x-requested-with;跨站 no-cors POST 无法携带非简单头,预检也会被拒。
1015
- if (requireRequestedWith && String(req.headers["x-requested-with"] || "").trim() !== "XMLHttpRequest") return false;
1016
- return true;
1062
+ if (!host) return "no-host";
1063
+ const hostUrl = parseAuthority(host);
1064
+ if (hostUrl === undefined) return "bad-host";
1065
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return "bad-host";
1066
+ if (req.headers["sec-fetch-site"] === "cross-site") return "cross-site";
1067
+ // Origin 围栏:浏览器带 Origin 时必须与本请求 hostname 一致(比较 hostname 而非 host ——
1068
+ // 部分 Chromium(Edge 151)对非默认端口的 loopback 页面会把 Origin 序列化成不带端口)。
1069
+ // 无 Origin 视为通过(Host 围栏已限定 authority);字面量 "null"(沙箱 iframe / file:)拒绝。
1070
+ const origin = req.headers["origin"];
1071
+ if (origin === undefined) return "ok";
1072
+ try {
1073
+ return new URL(origin).hostname === hostUrl.hostname ? "ok" : "bad-origin";
1074
+ } catch (e) {
1075
+ return "bad-origin";
1076
+ }
1077
+ }
1078
+
1079
+ /** 拒绝原因 → 面向用户/模型的文案(issue #12:区分「缺头」与「Host 不可信」)。 */
1080
+ function trustErrorMessage(reason) {
1081
+ switch (reason) {
1082
+ case "header": return "missing x-requested-with header";
1083
+ case "cross-site": return "cross-site request refused";
1084
+ case "bad-origin": return "origin does not match the request host";
1085
+ case "no-host": return "missing Host header";
1086
+ default:
1087
+ return "untrusted host: this deployment only serves loopback or hosts trusted by the DSH web runtime "
1088
+ + "(start DSH with --trusted-host <host[:port]>, or expose it through your remote-access setup)";
1017
1089
  }
1018
- return false;
1019
1090
  }
1020
1091
 
1021
1092
  async function readJsonBody(req) {
@@ -1285,6 +1356,38 @@ function apply(ctx, config) {
1285
1356
  const terminals = new Map();
1286
1357
  let nextTerminalId = 1;
1287
1358
 
1359
+ // ---- 宿主信任源(issue #12)----
1360
+ // ctx.webRuntime.trustedHosts 是 DSH 官方运行时能力:启动时采样的局域网 IP 字面量
1361
+ // 加 `--trusted-host` 指定的 authority,`/api` 网关的 fence 正以它为准。
1362
+ // 按请求实时读取(服务值可被替换,无需重启插件);服务缺失时保持 loopback-only,
1363
+ // 行为与 2.4.9 完全一致 —— 因此用软注入(ctx.inject)而非硬 inject:万一将来宿主
1364
+ // 改名/移除该服务,插件仍能挂载,只是远程访问退回本机可用。
1365
+ let webRuntimeFace = null;
1366
+ ctx.inject(["webRuntime"], (sctx) => {
1367
+ try { webRuntimeFace = (sctx && sctx.webRuntime) || (sctx && typeof sctx.get === "function" ? sctx.get("webRuntime") : null) || null; } catch (e) { webRuntimeFace = null; }
1368
+ });
1369
+ function trustedHostsNow() {
1370
+ try {
1371
+ const list = webRuntimeFace && webRuntimeFace.trustedHosts;
1372
+ return Array.isArray(list) ? list : [];
1373
+ } catch (e) {
1374
+ return [];
1375
+ }
1376
+ }
1377
+
1378
+ /** 一次请求的信任判定:返回 "ok" 或拒绝原因("header" / "bad-host" / …)。 */
1379
+ function requestTrust(req, requireRequestedWith) {
1380
+ const verdict = trustVerdict(req, trustedHostsNow());
1381
+ if (verdict !== "ok") return verdict;
1382
+ if (requireRequestedWith && !hasRequestedWithHeader(req)) return "header";
1383
+ return "ok";
1384
+ }
1385
+
1386
+ /** 统一的 403 输出(带准确原因,避免把 Host 不可信误报成缺头)。 */
1387
+ function denyRequest(res, reason) {
1388
+ writeJson(res, 403, { ok: false, error: { code: reason === "header" ? "csrf" : "forbidden", message: trustErrorMessage(reason) } });
1389
+ }
1390
+
1288
1391
  // ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
1289
1392
  const sessions = new Map(); // profileKey -> CommandSession
1290
1393
  const SESSION_IDLE_MS = 10 * 60 * 1000;
@@ -1757,7 +1860,9 @@ function apply(ctx, config) {
1757
1860
  handler: async (req, res) => {
1758
1861
  // CSRF 加固:/remote-ssh/api/* 仅由本插件客户端调用,强制校验其必带的
1759
1862
  // x-requested-with 头(跨站攻击者无法在 no-cors POST 中携带非简单头)。
1760
- if (!isTrusted(req, true)) { writeJson(res, 403, { ok: false, error: { code: "csrf", message: "missing x-requested-with header" } }); return; }
1863
+ // 信任判定走宿主 webRuntime.trustedHosts(issue #12),失败时回传具体原因。
1864
+ const apiTrust = requestTrust(req, true);
1865
+ if (apiTrust !== "ok") { denyRequest(res, apiTrust); return; }
1761
1866
  if (req.method !== "POST") { writeError(res, new Error("method not allowed"), 405); return; }
1762
1867
  const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
1763
1868
  if (!pathname.startsWith(API_BASE)) { writeError(res, new Error("not-found"), 404); return; }
@@ -2412,7 +2517,8 @@ function apply(ctx, config) {
2412
2517
  // webServer prefix 匹配要求 pathname 以 prefix+"/" 开头,但 fs.tree 用的是点分隔符,
2413
2518
  // 所以无法用 prefix 拦截,必须用 exact。
2414
2519
  async function interceptFsHandler(req, res, method) {
2415
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2520
+ const fsTrust = requestTrust(req, false);
2521
+ if (fsTrust !== "ok") { denyRequest(res, fsTrust); return; }
2416
2522
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2417
2523
  let payload;
2418
2524
  try { payload = await readJsonBody(req); }
@@ -2667,7 +2773,8 @@ function apply(ctx, config) {
2667
2773
  }
2668
2774
 
2669
2775
  async function interceptGitHandler(req, res, method) {
2670
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2776
+ const gitTrust = requestTrust(req, false);
2777
+ if (gitTrust !== "ok") { denyRequest(res, gitTrust); return; }
2671
2778
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2672
2779
  let payload;
2673
2780
  try { payload = await readJsonBody(req); }
@@ -2849,7 +2956,8 @@ function apply(ctx, config) {
2849
2956
  }
2850
2957
 
2851
2958
  async function handleRemoteUpload(req, res) {
2852
- if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
2959
+ const upTrust = requestTrust(req, false);
2960
+ if (upTrust !== "ok") { denyRequest(res, upTrust); return; }
2853
2961
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
2854
2962
  try {
2855
2963
  const url = new URL(req.url || "/", "http://dsh.internal");
@@ -2930,7 +3038,8 @@ function apply(ctx, config) {
2930
3038
  }
2931
3039
 
2932
3040
  async function handleSidebarFile(req, res) {
2933
- if (!isTrusted(req)) { res.writeHead(403); res.end("forbidden"); return; }
3041
+ const fileTrust = requestTrust(req, false);
3042
+ if (fileTrust !== "ok") { res.writeHead(403, { "content-type": "text/plain; charset=utf-8" }); res.end(trustErrorMessage(fileTrust)); return; }
2934
3043
  if (req.method !== "GET") { res.writeHead(405); res.end(); return; }
2935
3044
  try {
2936
3045
  const url = new URL(req.url || "/", "http://dsh.internal");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.9",
3
+ "version": "2.4.10",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",