@mrrisega/dsh-remote 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/clients/dsh-remote/mobile-adapter.mjs +9 -3
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +25 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +20 -2
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +4 -2
- package/packages/dsh-remote-web/lib/index.js +121 -2
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +9 -3
- package/packages/dsh-remote-web/test/local-route-guard.test.mjs +101 -0
- package/packages/dsh-remote-web/test/upstream-url-guard.test.mjs +49 -0
|
@@ -1238,7 +1238,11 @@ const SCRIPT = `(() => {
|
|
|
1238
1238
|
· 失败(reject)/超时(>20s)/亚毫秒(同一帧内的缓存命中)都不进样本:
|
|
1239
1239
|
前者不是网络耗时,后者会算出 0ms 这种"谎言";
|
|
1240
1240
|
· **无样本时显示「测速中…」而不是 0ms** —— 0ms 会让人以为网络飞快。
|
|
1241
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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 {
|
|
@@ -641,6 +641,31 @@ test("遮罩点击:只关左抽屉,不碰第三列", () => {
|
|
|
641
641
|
// (不要求"清掉" —— 我们连碰都不该碰第三列。)
|
|
642
642
|
});
|
|
643
643
|
|
|
644
|
+
test("★ ③ CSS 打底:JS 没跑(或中途挂了)时,页面必须是官方原样、绝不被困住", () => {
|
|
645
|
+
// 这是「③ 纯 CSS 打底」唯一可被机器验证的硬性质:
|
|
646
|
+
// 适配层的**所有**劫持(把官方列改成浮层/移出屏外/隐藏/拦截点击)都必须挂在
|
|
647
|
+
// 我们自己 add 的 `dsh-ma-*` 类上 —— 那些类只有 JS 跑起来才会有。
|
|
648
|
+
// 于是"JS 没执行/中途抛错"⇒ 一个劫持类都不存在 ⇒ 页面就是官方原样。
|
|
649
|
+
// 反过来,任何**不依赖我们类名**就去 translate/隐藏官方内容列的规则,都是"JS 挂了也会困住用户"的地雷。
|
|
650
|
+
const { css } = injected();
|
|
651
|
+
const code = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
652
|
+
const OFFICIAL = /pI_x6G_|hHd-Xa_|VOzbGW_|YDXeBa_|_2ctAZa_|P3OORG_|wSkVaW_|uV2eYG_/;
|
|
653
|
+
const HIJACK = /(transform\s*:\s*[^;]*translate|visibility\s*:\s*hidden|display\s*:\s*none|pointer-events\s*:\s*none|translateX\s*\()/;
|
|
654
|
+
const bad = [];
|
|
655
|
+
for (const chunk of code.split("}")) {
|
|
656
|
+
const i = chunk.lastIndexOf("{");
|
|
657
|
+
if (i < 0) continue;
|
|
658
|
+
const sel = chunk.slice(0, i).replace(/\s+/g, " ").trim();
|
|
659
|
+
const body = chunk.slice(i);
|
|
660
|
+
if (!OFFICIAL.test(sel)) continue; // 只关心"碰官方元素"的规则
|
|
661
|
+
if (/dsh-ma-/.test(sel)) continue; // 有我们的类做闸门 ⇒ JS 不跑就不会生效
|
|
662
|
+
if (/::/.test(sel)) continue; // 伪元素(如 ::-webkit-scrollbar)困不住人,不算劫持
|
|
663
|
+
if (HIJACK.test(body)) bad.push(sel.slice(0, 120));
|
|
664
|
+
}
|
|
665
|
+
assert.deepEqual(bad, [],
|
|
666
|
+
"★ 这些规则没有 dsh-ma-* 闸门却会移动/隐藏/拦截官方元素 —— JS 一旦不跑,用户就被困住了");
|
|
667
|
+
});
|
|
668
|
+
|
|
644
669
|
test("★ 源码契约:适配层代码里不许再出现任何「驱动第三列」的符号(去注释后扫)", () => {
|
|
645
670
|
const src = readFileSync(ADAPTER, "utf8");
|
|
646
671
|
// 只看**代码**:STYLE 是模板字符串、注释也是文本,直接扫会命中"历史说明"
|
|
@@ -509,12 +509,30 @@ test("网络延迟:只在**已有**的 fetch 包装里采样,成功样本显
|
|
|
509
509
|
assert.equal(calls.length, 1, "适配层不得额外发探测请求(只包装、不加请求)");
|
|
510
510
|
assert.equal(rtt.hidden, false, "有成功样本后角标应出现");
|
|
511
511
|
assert.match(String(rtt.textContent), /^\d+ms$/, `角标应是「<数字>ms」,实际:${rtt.textContent}`);
|
|
512
|
-
assert.ok(["ok", "mid", "bad"].includes(rtt.getAttribute("data-rtt")), "角标应带分档(ok/mid/bad)供 CSS 上色");
|
|
512
|
+
assert.ok(["ok", "mid", "slow", "bad"].includes(rtt.getAttribute("data-rtt")), "角标应带分档(ok/mid/slow/bad)供 CSS 上色");
|
|
513
513
|
assert.equal(rtt.hidden === false, true);
|
|
514
514
|
// 菜单里的那一行也要跟着更新(同一个 renderRtt)
|
|
515
515
|
const menu = findMenu(env);
|
|
516
516
|
const net = menu.querySelector(".dsh-ma-menu-net");
|
|
517
|
-
assert.ok(["ok", "mid", "bad"].includes(net.getAttribute("data-rtt")), "菜单行也应按同一分档着色");
|
|
517
|
+
assert.ok(["ok", "mid", "slow", "bad"].includes(net.getAttribute("data-rtt")), "菜单行也应按同一分档着色");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程文本窗口的判定尺度,不得再苛)", async () => {
|
|
521
|
+
// 用户 2026-09-27 明确:这类远程访问的文本窗口,500ms 以内不叫事,1000ms 用起来也正常流畅。
|
|
522
|
+
// 这条锁把「顺畅/正常/偏慢/很慢」的边界钉死,防止以后再被改苛。
|
|
523
|
+
const cases = [[5, "ok"], [600, "mid"], [1500, "slow"], [2600, "bad"]];
|
|
524
|
+
for (const [delay, want] of cases) {
|
|
525
|
+
const env = harness({
|
|
526
|
+
width: 390,
|
|
527
|
+
fetchImpl: async () => { await new Promise((r) => setTimeout(r, delay)); return { status: 200, ok: true }; },
|
|
528
|
+
});
|
|
529
|
+
runAdapter(env);
|
|
530
|
+
const rtt = findFab(env).querySelector('[data-role="rtt"]');
|
|
531
|
+
await env.win.fetch("/api/session/list");
|
|
532
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
533
|
+
assert.equal(rtt.getAttribute("data-rtt"), want,
|
|
534
|
+
`≈${delay}ms 应判为 ${want},实际 ${rtt.getAttribute("data-rtt")}(显示 ${rtt.textContent})`);
|
|
535
|
+
}
|
|
518
536
|
});
|
|
519
537
|
|
|
520
538
|
test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrrisega/dsh-remote",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
3
|
+
"version": "0.6.18-beta.5",
|
|
4
4
|
"description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -1167,12 +1167,14 @@ window.__ModuleLoader__.load({
|
|
|
1167
1167
|
/**
|
|
1168
1168
|
* 延迟 → 人话。
|
|
1169
1169
|
* 🔒 无样本时必须说「测速中…」而**不是 0ms**:0ms 是句谎话(这个页面根本还没量过),
|
|
1170
|
-
* 而"测速中"如实表达了"还没有样本"
|
|
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 <=
|
|
1177
|
+
var tone = ms <= 500 ? "顺畅" : ms <= 1000 ? "正常" : ms <= 2000 ? "偏慢" : "很慢";
|
|
1176
1178
|
return "网络延迟 " + ms + "ms · " + tone;
|
|
1177
1179
|
}
|
|
1178
1180
|
|
|
@@ -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
|
-
|
|
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) });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.18-beta.
|
|
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
|
-
|
|
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
|
-
|
|
1253
|
-
|
|
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 {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地宿主路由的跨站防护(2026-09-27 安全审计)
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这层:/dsh-remote/* 全部注册在本机 dsh web 上且**没有鉴权**,
|
|
5
|
+
* 靠"只有本机能访问"来保证安全。但浏览器里任何网页都能向 127.0.0.1:3080 发请求:
|
|
6
|
+
* · 经典 CSRF —— evil.com 表单 POST /dsh-remote/self/uninstall
|
|
7
|
+
* · DNS rebinding —— evil.com 解析到 127.0.0.1 后浏览器视为同源,
|
|
8
|
+
* 于是能**读到** /dsh-remote/diag/bundle 与 /dsh-remote/access-key(可签发设备密钥)
|
|
9
|
+
* 这两条都能直达"控制用户电脑",所以判据必须被测试钉死。
|
|
10
|
+
*
|
|
11
|
+
* 同时钉住"不误伤手机":隧道请求被 bridge 剥掉 host/origin/sec-fetch-*,
|
|
12
|
+
* undici 补上 Host: 127.0.0.1:<port> —— 形态与第一条用例完全一致。
|
|
13
|
+
*/
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { localRequestBlocked } from "../lib/index.js";
|
|
18
|
+
|
|
19
|
+
const HERE = new URL("../lib/index.js", import.meta.url);
|
|
20
|
+
|
|
21
|
+
/** 造一个只有 headers 的伪请求(守卫是纯函数,只看这几个头)。 */
|
|
22
|
+
const req = (headers) => ({ headers });
|
|
23
|
+
|
|
24
|
+
test("守卫放行:手机隧道形态(Host=loopback、无 Origin、无 sec-fetch)", () => {
|
|
25
|
+
// 这正是 dsh-bridge 转发到 127.0.0.1:3080 之后本机看到的形态
|
|
26
|
+
assert.equal(localRequestBlocked(req({ host: "127.0.0.1:3080" })), null);
|
|
27
|
+
assert.equal(localRequestBlocked(req({ host: "localhost:3080" })), null);
|
|
28
|
+
assert.equal(localRequestBlocked(req({ host: "[::1]:3080" })), null);
|
|
29
|
+
// 桌面端面板:同源 fetch,Origin 与 Host 一致
|
|
30
|
+
assert.equal(localRequestBlocked(req({ host: "127.0.0.1:3080", origin: "http://127.0.0.1:3080" })), null);
|
|
31
|
+
assert.equal(localRequestBlocked(req({ host: "127.0.0.1:3080", "sec-fetch-site": "same-origin" })), null);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("守卫放行:局域网直连(插件自己 lan-bind 到 0.0.0.0,这是产品功能,不能一刀切挡掉)", () => {
|
|
35
|
+
// 2026-09-27 真机验收时发现:只认 loopback 会把「同一 Wi-Fi 下直连」整个挡死
|
|
36
|
+
for (const host of ["172.16.27.98:3080", "192.168.1.30:3080", "10.0.0.5:3080", "mac.local:3080"]) {
|
|
37
|
+
assert.equal(localRequestBlocked(req({ host })), null, `${host} 是私网/mDNS,应放行`);
|
|
38
|
+
}
|
|
39
|
+
// 但打这些地址的**跨站**请求仍必须拒(CSRF 与 Host 白名单是两条独立判据)
|
|
40
|
+
assert.equal(
|
|
41
|
+
localRequestBlocked(req({ host: "172.16.27.98:3080", origin: "http://evil.com" })),
|
|
42
|
+
"origin-mismatch",
|
|
43
|
+
"局域网地址上的跨站 POST 同样要挡"
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("DNS rebinding 必拒:Host 不是 loopback(即使 Origin 与该 Host 自洽)", () => {
|
|
48
|
+
// rebinding 的形态:页面来源与目标都是 evil.com,Origin/Host 互相自洽 —— 只有 Host 白名单能挡
|
|
49
|
+
assert.equal(localRequestBlocked(req({ host: "evil.com", origin: "http://evil.com" })), "bad-host");
|
|
50
|
+
assert.equal(localRequestBlocked(req({ host: "evil.com:3080" })), "bad-host");
|
|
51
|
+
assert.equal(localRequestBlocked(req({ host: "127.0.0.1.evil.com" })), "bad-host");
|
|
52
|
+
assert.equal(localRequestBlocked(req({ host: "172.16.27.98.evil.com" })), "bad-host", "私网前缀伪装也要拒");
|
|
53
|
+
assert.equal(localRequestBlocked(req({ host: "8.8.8.8:3080" })), "bad-host", "公网 IP 字面量不在白名单");
|
|
54
|
+
assert.equal(localRequestBlocked(req({})), "missing-host");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("CSRF 必拒:Origin 与 Host 不同源 / Sec-Fetch-Site= cross-site", () => {
|
|
58
|
+
assert.equal(
|
|
59
|
+
localRequestBlocked(req({ host: "127.0.0.1:3080", origin: "http://evil.com" })),
|
|
60
|
+
"origin-mismatch"
|
|
61
|
+
);
|
|
62
|
+
assert.equal(
|
|
63
|
+
localRequestBlocked(req({ host: "127.0.0.1:3080", origin: "https://n.risegao.cn" })),
|
|
64
|
+
"origin-mismatch"
|
|
65
|
+
);
|
|
66
|
+
assert.equal(
|
|
67
|
+
localRequestBlocked(req({ host: "127.0.0.1:3080", "sec-fetch-site": "cross-site" })),
|
|
68
|
+
"cross-site"
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("逃生舱:DSH_REMOTE_LOCAL_GUARD=0 全关、DSH_REMOTE_TRUSTED_HOSTS 追加反代域名", () => {
|
|
73
|
+
const saved = { g: process.env.DSH_REMOTE_LOCAL_GUARD, h: process.env.DSH_REMOTE_TRUSTED_HOSTS };
|
|
74
|
+
try {
|
|
75
|
+
process.env.DSH_REMOTE_LOCAL_GUARD = "0";
|
|
76
|
+
assert.equal(localRequestBlocked(req({ host: "evil.com" })), null, "关掉后必须完全放行(排障用)");
|
|
77
|
+
delete process.env.DSH_REMOTE_LOCAL_GUARD;
|
|
78
|
+
process.env.DSH_REMOTE_TRUSTED_HOSTS = "dsh.example.com";
|
|
79
|
+
assert.equal(localRequestBlocked(req({ host: "dsh.example.com" })), null, "反代域名应被信任");
|
|
80
|
+
assert.equal(
|
|
81
|
+
localRequestBlocked(req({ host: "dsh.example.com", origin: "http://dsh.example.com" })),
|
|
82
|
+
null
|
|
83
|
+
);
|
|
84
|
+
assert.equal(localRequestBlocked(req({ host: "evil.com" })), "bad-host", "白名单外仍然拒");
|
|
85
|
+
} finally {
|
|
86
|
+
if (saved.g === undefined) delete process.env.DSH_REMOTE_LOCAL_GUARD; else process.env.DSH_REMOTE_LOCAL_GUARD = saved.g;
|
|
87
|
+
if (saved.h === undefined) delete process.env.DSH_REMOTE_TRUSTED_HOSTS; else process.env.DSH_REMOTE_TRUSTED_HOSTS = saved.h;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("源码契约:守卫必须在路由分发处真的被调用(纯函数单测挡不住'忘了接上')", async () => {
|
|
92
|
+
const src = await readFile(HERE, "utf8");
|
|
93
|
+
assert.match(src, /const blocked = localRequestBlocked\(req\)/,
|
|
94
|
+
"路由分发处必须调用 localRequestBlocked(req)");
|
|
95
|
+
assert.match(src, /sendJson\(res, 403, \{ ok: false, error: "forbidden", reason: blocked \}\)/,
|
|
96
|
+
"被拦的请求必须回 403 且带上原因(便于排查误伤)");
|
|
97
|
+
// 拦截必须发生在 handler 之前:否则副作用已经产生
|
|
98
|
+
const guardAt = src.indexOf("const blocked = localRequestBlocked(req)");
|
|
99
|
+
const callAt = src.indexOf("Promise.resolve(route.handler(req, res))");
|
|
100
|
+
assert.ok(guardAt > 0 && callAt > guardAt, "守卫必须在 handler 调用之前");
|
|
101
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 上游代理地址拼接的安全契约(2026-09-27 安全审计 S4-C0)
|
|
3
|
+
*
|
|
4
|
+
* 漏洞形态:`/dsh-remote/feedback` 是**前缀路由**,后缀直接来自请求路径,
|
|
5
|
+
* 旧代码只剥一个前导斜杠就交给 `new URL()`:
|
|
6
|
+
* pathname = "/dsh-remote/feedback///evil.com/collect"
|
|
7
|
+
* suffix = "///evil.com/collect" → 剥一个 → "//evil.com/collect" ← 协议相对 URL!
|
|
8
|
+
* new URL("//evil.com/collect", "https://n.risegao.cn:13443/relay-api/") = https://evil.com/collect
|
|
9
|
+
* 而 proxyFeedback 在没有 Authorization 时会**自动附加本机账号 JWT + 真实手机号 + device_id**
|
|
10
|
+
* —— 一条 GET(`<img>` 即可触发)就把账号凭据送到攻击者主机,凭它能签发设备访问密钥、
|
|
11
|
+
* 配对到攻击者手机 = 用户电脑被完整接管。两斜杠/`%2f`/`..` 都被限制在中继域内,唯独三斜杠逃逸。
|
|
12
|
+
*/
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { safeUpstreamUrl } from "../lib/index.js";
|
|
17
|
+
|
|
18
|
+
const BASE = "https://n.risegao.cn:13443/relay-api/";
|
|
19
|
+
const ORIGIN = new URL(BASE).origin;
|
|
20
|
+
|
|
21
|
+
test("正常后缀:拼在基址路径之下,域名与路径前缀都不变", () => {
|
|
22
|
+
assert.equal(safeUpstreamUrl(BASE, "/").href, BASE);
|
|
23
|
+
assert.equal(safeUpstreamUrl(BASE, "/api/feedback").href, `${BASE}api/feedback`);
|
|
24
|
+
assert.equal(safeUpstreamUrl(BASE, "api/feedback?x=1").href, `${BASE}api/feedback?x=1`);
|
|
25
|
+
// 基址没有路径前缀时(自建反馈服务)也应正常
|
|
26
|
+
assert.equal(safeUpstreamUrl("https://fb.example.com", "/api/x").href, "https://fb.example.com/api/x");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("🔴 逃逸形态:三斜杠 / 两斜杠绝不换域(降级为基址下的路径段)", () => {
|
|
30
|
+
for (const suffix of ["///evil.com/collect", "//evil.com/x", "/////evil.com/y"]) {
|
|
31
|
+
const u = safeUpstreamUrl(BASE, suffix);
|
|
32
|
+
assert.equal(u.origin, ORIGIN, `${suffix} 不得逃到别的域,实际 ${u.origin}`);
|
|
33
|
+
assert.ok(u.pathname.startsWith("/relay-api/"), `${suffix} 必须仍在基址路径下,实际 ${u.pathname}`);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("🔴 逃逸形态:绝对 URL 与 .. 路径穿越必须抛错(而不是把凭据发出去)", () => {
|
|
38
|
+
assert.throws(() => safeUpstreamUrl(BASE, "https://evil.com/x"), /origin/);
|
|
39
|
+
assert.throws(() => safeUpstreamUrl("https://h/relay-api/", "/../admin/api/users"), /path/);
|
|
40
|
+
assert.throws(() => safeUpstreamUrl("https://h/relay-api/", "/%2e%2e/admin"), /path/);
|
|
41
|
+
assert.throws(() => safeUpstreamUrl("https://h/relay-api/", "/a/../../b"), /path/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("源码契约:proxyFeedback 必须走 safeUpstreamUrl(不得再裸 new URL(suffix))", async () => {
|
|
45
|
+
const src = await readFile(new URL("../lib/index.js", import.meta.url), "utf8");
|
|
46
|
+
assert.ok(!/new URL\(suffix\.replace/.test(src),
|
|
47
|
+
"不得再出现 `new URL(suffix.replace(/^\\//, \"\"), base)` —— 那正是 S4-C0 的漏洞写法");
|
|
48
|
+
assert.match(src, /url = safeUpstreamUrl\(base, suffix\)/, "proxyFeedback 必须调用 safeUpstreamUrl");
|
|
49
|
+
});
|