@zhangfengshun/dsh-remote-ssh 2.3.6 → 2.3.8

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/index.js CHANGED
@@ -216,7 +216,7 @@ class CommandSession {
216
216
  });
217
217
  const onEnd = () => {
218
218
  this.alive = false;
219
- const errMsg = "ssh 会话已关闭" + (this.errBuf.trim() ? ": " + this.errBuf.trim().slice(-300) : "");
219
+ const errMsg = "ssh 会话已关闭" + (this.errBuf.trim() ? ": " + stripPqBanner(this.errBuf).trim().slice(-300) : "");
220
220
  if (this.current) { this.current.reject(new Error(errMsg)); this.current = null; }
221
221
  while (this.queue.length) this.queue.shift().reject(new Error(errMsg));
222
222
  };
@@ -336,7 +336,7 @@ class CommandSession {
336
336
  }
337
337
  }
338
338
 
339
- function sshArgv(p, remoteCmd, tty) {
339
+ function sshArgv(p, remoteCmd, tty, extraOpts) {
340
340
  const opts = ["ssh"];
341
341
  if (tty) opts.push("-tt");
342
342
  opts.push("-p", String(p.port || 22));
@@ -356,6 +356,7 @@ function sshArgv(p, remoteCmd, tty) {
356
356
  opts.push("-o", "BatchMode=yes");
357
357
  opts.push("-o", "PreferredAuthentications=publickey");
358
358
  }
359
+ if (Array.isArray(extraOpts) && extraOpts.length) opts.push(...extraOpts);
359
360
  const target = String(p.user || "") + "@" + String(p.host || "");
360
361
  const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
361
362
  const argv = head.concat(opts, [target]);
@@ -363,12 +364,23 @@ function sshArgv(p, remoteCmd, tty) {
363
364
  return argv;
364
365
  }
365
366
 
366
- async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
367
+ /** 过滤 OpenSSH 客户端的“后量子 KEX”警告横幅(stderr 噪音,与认证成败无关,
368
+ * 混进错误信息会误导用户以为是不支持加密算法)。 */
369
+ function stripPqBanner(text) {
370
+ return String(text || "")
371
+ .split("\n")
372
+ .filter(function (line) {
373
+ return !/^\s*\*\*\s*(WARNING: connection is not using a post-quantum|This session may be vulnerable to .?store now|The server may need to be upgraded)/i.test(line);
374
+ })
375
+ .join("\n");
376
+ }
377
+
378
+ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes, extraOpts) {
367
379
  const max = maxBytes || MAX_BYTES;
368
380
  let handle;
369
381
  try {
370
382
  handle = subprocess.spawn({
371
- argv: sshArgv(p, remoteCmd, false),
383
+ argv: sshArgv(p, remoteCmd, false, extraOpts),
372
384
  cwd: process.cwd(),
373
385
  stdio: {
374
386
  stdin: stdinData !== undefined ? { data: String(stdinData) } : "ignore",
@@ -389,7 +401,7 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
389
401
  const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
390
402
  const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
391
403
  const outText = so.text;
392
- const errText = se.text;
404
+ const errText = stripPqBanner(se.text);
393
405
  let error = "";
394
406
  if (outcome.exitCode !== 0 && !outText && !errText) {
395
407
  error = "ssh 退出码 " + outcome.exitCode + (outcome.signal ? " (signal " + outcome.signal + ")" : "");
@@ -933,13 +945,18 @@ function normExec(r) {
933
945
  // HTTP 工具
934
946
  // ---------------------------------------------------------------------------
935
947
 
936
- function isTrusted(req) {
948
+ function isTrusted(req, requireRequestedWith) {
937
949
  const secFetchSite = req.headers["sec-fetch-site"];
938
950
  if (secFetchSite === "cross-site") return false;
939
951
  const host = req.headers["host"];
940
952
  if (!host) return false;
941
953
  const hostname = String(host).split(":")[0].replace(/^\[/, "").replace(/\]$/, "");
942
- if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0") return true;
954
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "0.0.0.0") {
955
+ // CSRF 加固(PR #4 的服务端半边):/remote-ssh/api/* 仅由本插件客户端调用,
956
+ // 其请求必带 x-requested-with;跨站 no-cors POST 无法携带非简单头,预检也会被拒。
957
+ if (requireRequestedWith && String(req.headers["x-requested-with"] || "").trim() !== "XMLHttpRequest") return false;
958
+ return true;
959
+ }
943
960
  return false;
944
961
  }
945
962
 
@@ -1054,12 +1071,12 @@ function apply(ctx, config) {
1054
1071
  }
1055
1072
  /** 把 SSH 常见失败翻译成带修复建议的中文提示。 */
1056
1073
  function sshErrorHint(text) {
1057
- const t = String(text || "");
1074
+ const t = stripPqBanner(String(text || ""));
1058
1075
  if (/remote port forwarding failed for listen port (\d+)/i.test(t)) {
1059
1076
  const m = t.match(/remote port forwarding failed for listen port (\d+)/i);
1060
1077
  return "SSH 端口转发失败(本地端口 " + m[1] + " 已被占用)。这通常由 ~/.ssh/config 里的 RemoteForward 造成:远端该端口被上次会话占用时,ExitOnForwardFailure yes 会让 ssh 直接退出。本插件已对该连接加 ClearAllForwardings,若仍出现请检查 ssh config。原始信息: " + t.trim().slice(0, 300);
1061
1078
  }
1062
- if (/permission denied \(publickey/i.test(t)) return "公钥认证失败:请检查 keyPath 私钥路径是否正确、远端 ~/.ssh/authorized_keys 是否包含对应公钥。原始信息: " + t.trim().slice(0, 300);
1079
+ if (/permission denied \(publickey/i.test(t)) return "公钥认证失败(服务器拒绝了公钥)。请依次检查:① keyPath 指向的私钥是否正确、是否带口令——测试连接以批处理模式运行无法交互输口令,可先 ssh-add 加载或改用无口令密钥;② 远端是否收录对应公钥——Windows 主机且目标用户在 Administrators 组时需写入 C:\\ProgramData\\ssh\\administrators_authorized_keys;③ profile 的用户名写法与手动连接是否一致(user / .\\user / user@domain 在 Windows sshd 中解析不同)。原始信息: " + t.trim().slice(0, 300);
1063
1080
  if (/connection refused/i.test(t)) return "连接被拒绝:请确认远端 sshd 已启动且端口正确。原始信息: " + t.trim().slice(0, 300);
1064
1081
  if (/connection timed out|timed out/i.test(t)) return "连接超时:请确认网络可达、端口开放,或检查 ProxyJump 配置。原始信息: " + t.trim().slice(0, 300);
1065
1082
  if (/could not resolve hostname/i.test(t)) return "无法解析主机名:请检查连接配置中的 host 拼写。原始信息: " + t.trim().slice(0, 300);
@@ -1239,7 +1256,26 @@ function apply(ctx, config) {
1239
1256
  testConnection: async (args) => {
1240
1257
  const p = getProfile(args && args.id);
1241
1258
  if (!p) return { ok: false, error: "未找到连接配置" };
1242
- return await runRemote(subprocess, p, "echo __DSH_OK__; hostname; whoami; pwd; uname -a", undefined, 256 * 1024);
1259
+ // 认证探测只用跨平台 echo:Windows host uname/pwd,且默认 shell 语义各异
1260
+ // (旧命令尾部 uname -a 会让认证成功的 Windows host 也误报失败)。
1261
+ const r = await runRemote(subprocess, p, "echo __DSH_OK__", undefined, 16 * 1024);
1262
+ if (r.ok) return r;
1263
+ const baseErr = sshErrorHint([r.error, stripPqBanner(r.stderr).trim()].filter(Boolean).join(" ") || ("ssh 退出码 " + r.exitCode));
1264
+ // 认证类失败:带 ssh -v 重跑一次握手 + 认证,抽取关键诊断行
1265
+ // (私钥加载 / 公钥提供 / 服务器拒绝方法),定位 Windows 常见坑。
1266
+ let error = baseErr;
1267
+ if (/permission denied|publickey/i.test(baseErr + " " + stripPqBanner(r.stderr))) {
1268
+ const diag = await runRemote(subprocess, p, "exit 0", undefined, 16 * 1024, ["-v"]);
1269
+ const diagText = String(diag.stderr || "")
1270
+ .split("\n")
1271
+ .filter(function (l) {
1272
+ return /debug1: (Offering public key|Authentications that can continue|Server accepts key|Trying private key|no such identity)|Load key|Permission denied|passphrase/i.test(l);
1273
+ })
1274
+ .join("\n")
1275
+ .slice(0, 1200);
1276
+ if (diagText) error = baseErr + "\n\nssh -v 诊断:\n" + diagText;
1277
+ }
1278
+ return { ok: false, exitCode: r.exitCode, signal: r.signal, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, error: error };
1243
1279
  },
1244
1280
  listWorkspaces: async () => ({ ok: true, workspaces: settingsFace.read().workspaces }),
1245
1281
  createRemoteWorkspace: async (args) => {
@@ -1472,7 +1508,9 @@ function apply(ctx, config) {
1472
1508
  kind: "prefix",
1473
1509
  path: "/remote-ssh/api",
1474
1510
  handler: async (req, res) => {
1475
- if (!isTrusted(req)) { writeError(res, new Error("forbidden"), 403); return; }
1511
+ // CSRF 加固:/remote-ssh/api/* 仅由本插件客户端调用,强制校验其必带的
1512
+ // x-requested-with 头(跨站攻击者无法在 no-cors POST 中携带非简单头)。
1513
+ if (!isTrusted(req, true)) { writeJson(res, 403, { ok: false, error: { code: "csrf", message: "missing x-requested-with header" } }); return; }
1476
1514
  if (req.method !== "POST") { writeError(res, new Error("method not allowed"), 405); return; }
1477
1515
  const pathname = new URL(req.url || "/", "http://dsh.internal").pathname;
1478
1516
  if (!pathname.startsWith(API_BASE)) { writeError(res, new Error("not-found"), 404); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.3.6",
3
+ "version": "2.3.8",
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",