dsh-pocket 2.9.1 → 2.10.1
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/client/client.js +1 -99
- package/client/index.jsx +1 -4
- package/client/mobile/mobile-apply.tsx +0 -9
- package/client/pocket-locales.js +0 -2
- package/lib/proxy.mjs +167 -35
- package/lib/service.mjs +0 -3
- package/package.json +1 -1
- package/client/mobile/sessionGuard.ts +0 -91
- package/lib/session.mjs +0 -35
package/client/client.js
CHANGED
|
@@ -503,92 +503,6 @@ function startFileGuard(readFile) {
|
|
|
503
503
|
};
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
// client/mobile/sessionGuard.ts
|
|
507
|
-
var SESSION_META = "dsh-pocket-session";
|
|
508
|
-
var ACCESS_META = "dsh-pocket-access";
|
|
509
|
-
function readFingerprint() {
|
|
510
|
-
const meta = document.querySelector(`meta[name="${SESSION_META}"]`);
|
|
511
|
-
const fp = meta?.content?.trim();
|
|
512
|
-
return fp && fp.length > 0 ? fp : null;
|
|
513
|
-
}
|
|
514
|
-
function readAccess() {
|
|
515
|
-
const meta = document.querySelector(`meta[name="${ACCESS_META}"]`);
|
|
516
|
-
return meta?.content?.trim() ?? null;
|
|
517
|
-
}
|
|
518
|
-
function mountBadge(fp) {
|
|
519
|
-
const badge = document.createElement("div");
|
|
520
|
-
badge.setAttribute("data-mobile-nav", "session-fp");
|
|
521
|
-
badge.textContent = `\u{1F512} \u4F1A\u8BDD\u6307\u7EB9 ${fp}`;
|
|
522
|
-
badge.style.cssText = [
|
|
523
|
-
"position:fixed",
|
|
524
|
-
"left:50%",
|
|
525
|
-
"bottom:8px",
|
|
526
|
-
"transform:translateX(-50%)",
|
|
527
|
-
"z-index:2147483646",
|
|
528
|
-
"padding:4px 10px",
|
|
529
|
-
"border-radius:999px",
|
|
530
|
-
"background:rgba(17,24,39,.82)",
|
|
531
|
-
"color:#fff",
|
|
532
|
-
'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
533
|
-
"letter-spacing:.5px",
|
|
534
|
-
"pointer-events:none",
|
|
535
|
-
"user-select:none",
|
|
536
|
-
"white-space:nowrap"
|
|
537
|
-
].join(";");
|
|
538
|
-
badge.title = "\u672C\u9875\u4F1A\u8BDD\u6307\u7EB9\uFF0C\u5E94\u4E0E\u7535\u8111\u300C\u624B\u673A\u8BBF\u95EE\u300D\u8BBE\u7F6E\u9875\u663E\u793A\u7684\u4E00\u81F4\uFF1B\u4E0D\u4E00\u81F4\u8BF4\u660E\u94FE\u63A5\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528";
|
|
539
|
-
document.body.appendChild(badge);
|
|
540
|
-
return () => badge.remove();
|
|
541
|
-
}
|
|
542
|
-
function mountWarning() {
|
|
543
|
-
const overlay = document.createElement("div");
|
|
544
|
-
overlay.setAttribute("data-mobile-nav", "session-warn");
|
|
545
|
-
overlay.style.cssText = [
|
|
546
|
-
"position:fixed",
|
|
547
|
-
"inset:0",
|
|
548
|
-
"z-index:2147483647",
|
|
549
|
-
"display:flex",
|
|
550
|
-
"align-items:center",
|
|
551
|
-
"justify-content:center",
|
|
552
|
-
"padding:24px",
|
|
553
|
-
"background:rgba(0,0,0,.72)",
|
|
554
|
-
"color:#fff",
|
|
555
|
-
'font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
556
|
-
"text-align:center"
|
|
557
|
-
].join(";");
|
|
558
|
-
overlay.style.whiteSpace = "pre-line";
|
|
559
|
-
overlay.textContent = "\u26A0\uFE0F \u6B64\u9875\u9762\u4E0D\u662F\u4F60\u7684 DSH\uFF1A\u94FE\u63A5\u53EF\u80FD\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528\u3002\n\u8BF7\u52FF\u5728\u6B64\u8F93\u5165\u4EFB\u4F55\u5BC6\u7801\u3002\n\u8BF7\u91CD\u65B0\u5728\u7535\u8111\u300C\u624B\u673A\u8BBF\u95EE\u300D\u8BBE\u7F6E\u9875\u626B\u5F53\u524D\u4E8C\u7EF4\u7801\u3002";
|
|
560
|
-
document.documentElement.appendChild(overlay);
|
|
561
|
-
return () => overlay.remove();
|
|
562
|
-
}
|
|
563
|
-
function startSessionGuard() {
|
|
564
|
-
if (readAccess() !== "public") return () => {
|
|
565
|
-
};
|
|
566
|
-
let cleanupBadge = null;
|
|
567
|
-
let cleanupWarn = null;
|
|
568
|
-
const evaluate = () => {
|
|
569
|
-
const fp = readFingerprint();
|
|
570
|
-
if (fp) {
|
|
571
|
-
cleanupWarn?.();
|
|
572
|
-
cleanupWarn = null;
|
|
573
|
-
if (!cleanupBadge) cleanupBadge = mountBadge(fp);
|
|
574
|
-
} else {
|
|
575
|
-
cleanupBadge?.();
|
|
576
|
-
cleanupBadge = null;
|
|
577
|
-
if (!cleanupWarn) cleanupWarn = mountWarning();
|
|
578
|
-
}
|
|
579
|
-
};
|
|
580
|
-
evaluate();
|
|
581
|
-
const timer = window.setTimeout(evaluate, 300);
|
|
582
|
-
const observer = new MutationObserver(evaluate);
|
|
583
|
-
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
584
|
-
return () => {
|
|
585
|
-
window.clearTimeout(timer);
|
|
586
|
-
observer.disconnect();
|
|
587
|
-
cleanupBadge?.();
|
|
588
|
-
cleanupWarn?.();
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
506
|
// client/mobile/mobile.css.ts
|
|
593
507
|
var MOBILE_CSS = `
|
|
594
508
|
/* ---------- base control styles (rendered at any width, hidden where unused) ---------- */
|
|
@@ -1747,11 +1661,6 @@ function mobileApply(ctx) {
|
|
|
1747
1661
|
);
|
|
1748
1662
|
return startFileGuard(readFile);
|
|
1749
1663
|
}, "dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)");
|
|
1750
|
-
ctx.effect(() => {
|
|
1751
|
-
if (!narrow.matches) return () => {
|
|
1752
|
-
};
|
|
1753
|
-
return startSessionGuard();
|
|
1754
|
-
}, "dsh-mobile-nav: session fingerprint guard (issue #82)");
|
|
1755
1664
|
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({
|
|
1756
1665
|
name: "conversation.session.header.actions",
|
|
1757
1666
|
id: "mobile-nav-toggle",
|
|
@@ -1852,7 +1761,6 @@ var zh2 = {
|
|
|
1852
1761
|
"wanPin": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u6BCF\u6B21\u5F00\u542F\u516C\u7F51\u53D8\u65B0\uFF1B\u624B\u673A\u6253\u5F00\u94FE\u63A5\u9700\u8F93\u5165\u6B64\u5BC6\u7801\uFF09",
|
|
1853
1762
|
"wanPinCustom": "\u{1F510} \u8BBF\u95EE\u5BC6\u7801\uFF1A{pin}\uFF08\u81EA\u5B9A\u4E49\uFF0C\u5F00\u542F\u516C\u7F51\u4E0D\u518D\u81EA\u52A8\u6362\u65B0\uFF09",
|
|
1854
1763
|
"wanEphemeralWarn": "\u26A0\uFE0F \u516C\u7F51\u94FE\u63A5\u4EC5\u5728\u672C\u6B21\u5F00\u542F\u671F\u95F4\u6709\u6548\uFF1A\u5173\u95ED\u6216\u91CD\u542F\u540E\u5931\u6548\uFF0C\u5E76\u53EF\u80FD\u88AB\u4ED6\u4EBA\u590D\u7528\u4E3A\u964C\u751F\u7F51\u7AD9\u3002\u8BF7\u52FF\u6536\u85CF\uFF0C\u6BCF\u6B21\u4ECE\u672C\u9875\u626B\u300C\u5F53\u524D\u300D\u4E8C\u7EF4\u7801\u3002\u9700\u8981\u56FA\u5B9A\u4E0D\u53D8\u7684\u5730\u5740\u8BF7\u7528\u4E0B\u65B9\u300C\u56FA\u5B9A\u57DF\u540D\u300D\u3002",
|
|
1855
|
-
"wanSessionFp": "\u672C\u9875\u4F1A\u8BDD\u6307\u7EB9\uFF1A{fp}\uFF08\u624B\u673A\u767B\u5F55\u9875\u4F1A\u663E\u793A\u540C\u6837\u7684\uFF1B\u4E0D\u4E00\u81F4\u8BF4\u660E\u94FE\u63A5\u5DF2\u88AB\u4ED6\u4EBA\u590D\u7528\uFF0C\u8BF7\u52FF\u8F93\u5165\u5BC6\u7801\uFF09",
|
|
1856
1764
|
"stopTunnel": "\u5173\u95ED\u516C\u7F51",
|
|
1857
1765
|
"enable": "\u5F00\u542F\u516C\u7F51\u8BBF\u95EE",
|
|
1858
1766
|
"opening": "\u5F00\u542F\u4E2D\u2026",
|
|
@@ -1949,7 +1857,6 @@ var en2 = {
|
|
|
1949
1857
|
"wanPin": "\u{1F510} PIN: {pin} (changes each time the tunnel is enabled; required on the phone)",
|
|
1950
1858
|
"wanPinCustom": "\u{1F510} PIN: {pin} (custom \u2014 not rotated on tunnel start)",
|
|
1951
1859
|
"wanEphemeralWarn": '\u26A0\uFE0F The public link is valid only for this session: it stops working after you close or restart, and may be reused by someone else for an unrelated site. Do not bookmark it \u2014 scan the CURRENT QR code from this page each time. For a permanent address use "Fixed domain" below.',
|
|
1952
|
-
"wanSessionFp": "Session fingerprint: {fp} (the phone login page shows the same; if it differs, the link has been reused \u2014 do not enter your PIN)",
|
|
1953
1860
|
"stopTunnel": "Stop",
|
|
1954
1861
|
"enable": "Enable anywhere",
|
|
1955
1862
|
"opening": "Enabling\u2026",
|
|
@@ -2450,13 +2357,8 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
2450
2357
|
"div",
|
|
2451
2358
|
null,
|
|
2452
2359
|
qrArea(status.tunnelQr, tunnelUrl, namedMode ? t("namedRunningHint") : t("wanHint")),
|
|
2453
|
-
// 防钓鱼 / 别收藏(issue #82
|
|
2360
|
+
// 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示
|
|
2454
2361
|
(0, import_react2.createElement)("div", { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: "4px solid var(--dsw-alias-state-warn-primary,#b45309)", background: "var(--dsw-alias-bg-layer-2,#f3f4f6)", borderRadius: 8, padding: "8px 10px" } }, t("wanEphemeralWarn")),
|
|
2455
|
-
status?.sessionFingerprint ? (0, import_react2.createElement)(
|
|
2456
|
-
"div",
|
|
2457
|
-
{ style: { marginTop: 8, fontSize: 12, color: "var(--dsw-alias-label-secondary,#6b7280)", lineHeight: 1.5 } },
|
|
2458
|
-
fmt(t, "wanSessionFp", { fp: status.sessionFingerprint })
|
|
2459
|
-
) : null,
|
|
2460
2362
|
// 地址模式行(随机/固定;固定域名选中或编辑时高亮)
|
|
2461
2363
|
row(
|
|
2462
2364
|
t("modeLabel"),
|
package/client/index.jsx
CHANGED
|
@@ -487,11 +487,8 @@ function PocketSettingsTab({ rpcCall, t }) {
|
|
|
487
487
|
tunnelUrl
|
|
488
488
|
? h('div', null,
|
|
489
489
|
qrArea(status.tunnelQr, tunnelUrl, namedMode ? t('namedRunningHint') : t('wanHint')),
|
|
490
|
-
// 防钓鱼 / 别收藏(issue #82
|
|
490
|
+
// 防钓鱼 / 别收藏(issue #82):公网链接仅本次有效、勿收藏提示
|
|
491
491
|
h('div', { style: { marginTop: 8, fontSize: 12, lineHeight: 1.5, borderLeft: '4px solid var(--dsw-alias-state-warn-primary,#b45309)', background: 'var(--dsw-alias-bg-layer-2,#f3f4f6)', borderRadius: 8, padding: '8px 10px' } }, t('wanEphemeralWarn')),
|
|
492
|
-
status?.sessionFingerprint ? h('div', { style: { marginTop: 8, fontSize: 12, color: 'var(--dsw-alias-label-secondary,#6b7280)', lineHeight: 1.5 } },
|
|
493
|
-
fmt(t, 'wanSessionFp', { fp: status.sessionFingerprint }),
|
|
494
|
-
) : null,
|
|
495
492
|
// 地址模式行(随机/固定;固定域名选中或编辑时高亮)
|
|
496
493
|
row(t('modeLabel'),
|
|
497
494
|
h('span', { style: { display: 'inline-flex', gap: 6 } },
|
|
@@ -4,7 +4,6 @@ import { MobileNavToggle } from './MobileNavToggle.tsx'
|
|
|
4
4
|
import { MobileNavOverlay } from './MobileNavOverlay.tsx'
|
|
5
5
|
import { MobileDrawerFooter } from './MobileDrawerFooter.tsx'
|
|
6
6
|
import { startFileGuard } from './fileGuard.ts'
|
|
7
|
-
import { startSessionGuard } from './sessionGuard.ts'
|
|
8
7
|
import { MOBILE_CSS } from './mobile.css.ts'
|
|
9
8
|
import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS } from '../api.js'
|
|
10
9
|
import { NS, en, zh } from './locales.ts'
|
|
@@ -304,14 +303,6 @@ export function mobileApply(ctx): void {
|
|
|
304
303
|
return startFileGuard(readFile)
|
|
305
304
|
}, 'dsh-mobile-nav: file open guard + copy button + hide add-workspace (issue #17)')
|
|
306
305
|
|
|
307
|
-
// 公网会话指纹校验(issue #82,防密码被钓):页面带指纹 meta 即我们的页面、
|
|
308
|
-
// 显示交叉核对徽标;不带则极可能不是我们的页面(链接被复用/克隆登录页钓鱼),
|
|
309
|
-
// 弹红屏警告并拦截交互。只挂窄屏(登录页是 server-render、无本脚本,主要靠
|
|
310
|
-
// 登录页与设置页的可见指纹做人工交叉核对;此层是应用页的兜底)。
|
|
311
|
-
ctx.effect(() => {
|
|
312
|
-
if (!narrow.matches) return () => {}
|
|
313
|
-
return startSessionGuard()
|
|
314
|
-
}, 'dsh-mobile-nav: session fingerprint guard (issue #82)')
|
|
315
306
|
|
|
316
307
|
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
|
317
308
|
name: 'conversation.session.header.actions',
|
package/client/pocket-locales.js
CHANGED
|
@@ -71,7 +71,6 @@ export const zh = {
|
|
|
71
71
|
'wanPin': '🔐 访问密码:{pin}(每次开启公网变新;手机打开链接需输入此密码)',
|
|
72
72
|
'wanPinCustom': '🔐 访问密码:{pin}(自定义,开启公网不再自动换新)',
|
|
73
73
|
'wanEphemeralWarn': '⚠️ 公网链接仅在本次开启期间有效:关闭或重启后失效,并可能被他人复用为陌生网站。请勿收藏,每次从本页扫「当前」二维码。需要固定不变的地址请用下方「固定域名」。',
|
|
74
|
-
'wanSessionFp': '本页会话指纹:{fp}(手机登录页会显示同样的;不一致说明链接已被他人复用,请勿输入密码)',
|
|
75
74
|
'stopTunnel': '关闭公网',
|
|
76
75
|
'enable': '开启公网访问',
|
|
77
76
|
'opening': '开启中…',
|
|
@@ -170,7 +169,6 @@ export const en = {
|
|
|
170
169
|
'wanPin': '🔐 PIN: {pin} (changes each time the tunnel is enabled; required on the phone)',
|
|
171
170
|
'wanPinCustom': '🔐 PIN: {pin} (custom — not rotated on tunnel start)',
|
|
172
171
|
'wanEphemeralWarn': '⚠️ The public link is valid only for this session: it stops working after you close or restart, and may be reused by someone else for an unrelated site. Do not bookmark it — scan the CURRENT QR code from this page each time. For a permanent address use "Fixed domain" below.',
|
|
173
|
-
'wanSessionFp': 'Session fingerprint: {fp} (the phone login page shows the same; if it differs, the link has been reused — do not enter your PIN)',
|
|
174
172
|
'stopTunnel': 'Stop',
|
|
175
173
|
'enable': 'Enable anywhere',
|
|
176
174
|
'opening': 'Enabling…',
|
package/lib/proxy.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import { createServer } from 'node:http';
|
|
|
15
15
|
import { request as httpRequest } from 'node:http';
|
|
16
16
|
import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
|
-
import { ensureSessionFingerprint } from './session.mjs';
|
|
19
18
|
|
|
20
19
|
const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
|
|
21
20
|
|
|
@@ -37,8 +36,6 @@ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!funct
|
|
|
37
36
|
已回退;该问题属 DSH 客户端限制(location.hostname 是 unforgeable 属性)无法安全绕过。*/</script>`;
|
|
38
37
|
|
|
39
38
|
const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
|
|
40
|
-
/** 防密码被钓(issue #82):本代理注入到每个 HTML 的会话指纹标记。 */
|
|
41
|
-
const SESSION_MARK = 'name="dsh-pocket-session"';
|
|
42
39
|
|
|
43
40
|
/**
|
|
44
41
|
* DSH Desktop(桌面版)渲染进程兼容补丁(issue #3/#4,已于 issue #76 停用)。
|
|
@@ -168,20 +165,15 @@ function parseCookies(header) {
|
|
|
168
165
|
return out;
|
|
169
166
|
}
|
|
170
167
|
|
|
171
|
-
/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。
|
|
172
|
-
|
|
173
|
-
function loginPageHtml(error, isPublic, retryAfter = 0, fp = '') {
|
|
168
|
+
/** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
|
|
169
|
+
function loginPageHtml(error, isPublic, retryAfter = 0) {
|
|
174
170
|
const where = isPublic ? '此公网地址' : '此局域网地址';
|
|
175
171
|
const whereEn = isPublic ? 'This public address' : 'This LAN address';
|
|
176
172
|
const errMsg = error === 'locked'
|
|
177
173
|
? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
|
|
178
174
|
: error ? '密码错误,请重试 | Wrong PIN, try again' : '';
|
|
179
|
-
const fpLine = fp
|
|
180
|
-
? `<p style="margin:14px 0 0;font-size:12px;line-height:1.6;color:#6b7280">本页会话指纹:<b style="font-family:ui-monospace,Menlo,monospace;letter-spacing:1px;color:#111827">${fp}</b><br>与电脑「手机访问」设置页显示的<b>一致</b>,才是你的 DSH;不一致请勿输入密码。<br><span style="color:#9ca3af">Session fingerprint — only enter your PIN if it matches the one shown on your computer's "Phone access" settings.</span></p>`
|
|
181
|
-
: '';
|
|
182
175
|
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
183
176
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
184
|
-
<meta name="dsh-pocket-session" content="${fp}">
|
|
185
177
|
<title>DSH Pocket · 访问验证</title>
|
|
186
178
|
<style>
|
|
187
179
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
|
@@ -199,7 +191,7 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
|
|
|
199
191
|
<form method="post" action="/pocket-login">
|
|
200
192
|
<input name="token" type="password" maxlength="8" autocomplete="one-time-code" autofocus required>
|
|
201
193
|
<button type="submit">进入 | Enter</button>
|
|
202
|
-
</form
|
|
194
|
+
</form>
|
|
203
195
|
</div></body></html>`;
|
|
204
196
|
}
|
|
205
197
|
|
|
@@ -304,7 +296,14 @@ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#
|
|
|
304
296
|
/** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
|
|
305
297
|
function isHtmlRequest(req) {
|
|
306
298
|
const accept = String(req.headers.accept ?? '');
|
|
307
|
-
|
|
299
|
+
if (accept.includes('text/html')) return true;
|
|
300
|
+
const url = String(req.url ?? '');
|
|
301
|
+
// 按 pathname 判断,别用 `url === '/'` 严格比 —— 根路径常带 query
|
|
302
|
+
// (`/?dsh-pocket-auth=1`、`/?dsh-pocket-retry=1`、`/?token=…`),
|
|
303
|
+
// 那些同样是浏览器导航,漏判会让它们拿到 401/303 而不是该给的页面。
|
|
304
|
+
let pathname = url;
|
|
305
|
+
try { pathname = new URL(url || '/', 'http://dsh.invalid').pathname; } catch { /* 用原值兜底 */ }
|
|
306
|
+
return pathname === '/' || /\.html?$/i.test(pathname);
|
|
308
307
|
}
|
|
309
308
|
|
|
310
309
|
/** 校验请求是否已认证。返回 { ok, rawQueryToken }:
|
|
@@ -415,6 +414,101 @@ export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launch
|
|
|
415
414
|
return `${u.pathname}${u.search}`;
|
|
416
415
|
}
|
|
417
416
|
|
|
417
|
+
// ---------- 会话握手重试计数(issue #91) ----------
|
|
418
|
+
// Safari(iOS/macOS)不持久化「http:// + 纯 IP 源」上由 3xx 响应下发的 cookie,
|
|
419
|
+
// 于是 dsh web 的 launch-token→cookie 握手永远收敛不了:代理每次 `GET /`
|
|
420
|
+
// 都补 `?token=`,上游每次 303 回 `/`,浏览器每次都不带 cookie → 无限重定向
|
|
421
|
+
// (Safari 报「发生了太多重定位」)。
|
|
422
|
+
//
|
|
423
|
+
// 两道防线:
|
|
424
|
+
// 1) 代理把这次 303 改写成 200 过渡页(Set-Cookie 照发 + meta refresh 跳回 `/`),
|
|
425
|
+
// 200 响应上的 cookie 不会被 Safari 的重定向 cookie 策略丢掉;
|
|
426
|
+
// 2) 万一 1) 也不管用,用下面的计数器在若干次尝试后停止注入 token 并给出
|
|
427
|
+
// 可操作提示页——宁可给用户一句人话,也不要无限转圈。
|
|
428
|
+
//
|
|
429
|
+
// 只按客户端 IP 计数(无需 cookie 支持,正适合「cookie 用不了」的这个场景)。
|
|
430
|
+
export const DEFAULT_HANDSHAKE_LIMIT = 3;
|
|
431
|
+
export const HANDSHAKE_WINDOW_MS = 60_000;
|
|
432
|
+
/** 提示页「重试」按钮用的查询参数:命中即清空该 IP 的失败计数,且不往上游透传。 */
|
|
433
|
+
export const HANDSHAKE_RETRY_PARAM = 'dsh-pocket-retry';
|
|
434
|
+
|
|
435
|
+
/** 摘掉某个查询参数后重新拼路径;解析失败或本来就没有则原样返回。 */
|
|
436
|
+
export function stripQueryParam(reqUrl, name) {
|
|
437
|
+
let u;
|
|
438
|
+
try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
|
|
439
|
+
if (!u.searchParams.has(name)) return reqUrl;
|
|
440
|
+
u.searchParams.delete(name);
|
|
441
|
+
return `${u.pathname}${u.search}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function createHandshakeTracker({ max = DEFAULT_HANDSHAKE_LIMIT, windowMs = HANDSHAKE_WINDOW_MS } = {}) {
|
|
445
|
+
/** ip -> { count, start } */
|
|
446
|
+
const hits = new Map();
|
|
447
|
+
return {
|
|
448
|
+
/** 记一次握手注入,返回窗口内的累计次数。 */
|
|
449
|
+
record(ip, now = Date.now()) {
|
|
450
|
+
const rec = hits.get(ip);
|
|
451
|
+
if (!rec || now - rec.start > windowMs) {
|
|
452
|
+
hits.set(ip, { count: 1, start: now });
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
rec.count += 1;
|
|
456
|
+
return rec.count;
|
|
457
|
+
},
|
|
458
|
+
/** 握手成功(拿到会话 cookie 的请求)→ 清零。 */
|
|
459
|
+
clear(ip) {
|
|
460
|
+
hits.delete(ip);
|
|
461
|
+
},
|
|
462
|
+
/** 该 IP 是否已达重试上限。 */
|
|
463
|
+
exhausted(ip) {
|
|
464
|
+
const rec = hits.get(ip);
|
|
465
|
+
return !!rec && rec.count >= max;
|
|
466
|
+
},
|
|
467
|
+
/** 清理过期条目,防长期运行内存膨胀。 */
|
|
468
|
+
prune(now = Date.now()) {
|
|
469
|
+
for (const [ip, rec] of hits) {
|
|
470
|
+
if (now - rec.start > windowMs) hits.delete(ip);
|
|
471
|
+
}
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 握手过渡页:200 + Set-Cookie(由调用方带上)+ meta refresh 跳回干净根路径。 */
|
|
477
|
+
export function handshakePageHtml() {
|
|
478
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
479
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
480
|
+
<meta http-equiv="refresh" content="0; url=/">
|
|
481
|
+
<title>DSH Pocket · 正在进入 | opening…</title>
|
|
482
|
+
<style>
|
|
483
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
|
484
|
+
p{font-size:13px;color:#6b7280;margin:0}
|
|
485
|
+
</style></head><body><p>正在进入… | opening…</p></body></html>`;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 握手反复失败时的提示页(issue #91):说清原因并给出可操作的规避办法。 */
|
|
489
|
+
export function handshakeBlockedPageHtml() {
|
|
490
|
+
return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
|
|
491
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
492
|
+
<title>DSH Pocket · 无法完成登录握手</title>
|
|
493
|
+
<style>
|
|
494
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
|
|
495
|
+
.card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px 22px;max-width:380px;width:calc(100% - 40px)}
|
|
496
|
+
h1{font-size:15px;margin:0 0 10px;color:#111827}
|
|
497
|
+
p{font-size:13px;color:#6b7280;margin:0 0 10px;line-height:1.7}
|
|
498
|
+
code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px}
|
|
499
|
+
a{color:#4f6ef7}
|
|
500
|
+
</style></head><body><div class="card">
|
|
501
|
+
<h1>🔁 无法完成登录握手</h1>
|
|
502
|
+
<p>浏览器没有保存 DSH 下发的会话 cookie,代理反复重试后仍未成功,因此停在这里而不是无限跳转。</p>
|
|
503
|
+
<p><strong>Safari(iOS/macOS)</strong> 在 <code>http://</code> 纯 IP 地址上不会保存这类 cookie,局域网入口因此进不去。</p>
|
|
504
|
+
<p>可以试试:<br>
|
|
505
|
+
① 换 Chromium 系浏览器(Chrome / Edge)打开局域网地址;<br>
|
|
506
|
+
② 改用<strong>公网入口</strong>(设置页开启公网访问,拿到 <code>https://…trycloudflare.com</code> 地址)——HTTPS 域名上 Safari 正常。</p>
|
|
507
|
+
<p style="margin-top:14px"><a href="/?${HANDSHAKE_RETRY_PARAM}=1" style="display:inline-block;padding:8px 14px;background:#4f6ef7;color:#fff;border-radius:8px;text-decoration:none;font-size:13px">重试一次 | Retry</a></p>
|
|
508
|
+
<p style="color:#9ca3af;font-size:12px">Browser did not keep the session cookie, so the login handshake could not complete (issue #91). Safari over plain <code>http://</code> + IP is the known case — try Chrome, or use the public HTTPS entry.</p>
|
|
509
|
+
</div></body></html>`;
|
|
510
|
+
}
|
|
511
|
+
|
|
418
512
|
// ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
|
|
419
513
|
// DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
|
|
420
514
|
// 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
|
|
@@ -486,19 +580,15 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
|
|
|
486
580
|
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
|
|
487
581
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
488
582
|
*/
|
|
489
|
-
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '' } = {}) {
|
|
583
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '', handshakeLimit } = {}) {
|
|
490
584
|
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
491
|
-
//
|
|
492
|
-
const
|
|
493
|
-
|
|
585
|
+
// 会话握手重试计数(issue #91):Safari 在 http://IP 源上丢 3xx 的 cookie → 死循环
|
|
586
|
+
const handshake = createHandshakeTracker(
|
|
587
|
+
typeof handshakeLimit === 'number' ? { max: handshakeLimit } : {},
|
|
588
|
+
);
|
|
494
589
|
const server = createServer((req, res) => {
|
|
495
590
|
const host = String(req.headers.host ?? '');
|
|
496
591
|
const isPublic = classifyHost(host) === 'public';
|
|
497
|
-
// 访问类型标记(issue #83):sessionGuard 只在公网启用——局域网 IP 是用户自己电脑的地址,
|
|
498
|
-
// 不存在「快速隧道子域被 Cloudflare 回收复用」的风险,不应弹防钓鱼警告。
|
|
499
|
-
const accessMeta = isPublic
|
|
500
|
-
? '<meta name="dsh-pocket-access" content="public">'
|
|
501
|
-
: '<meta name="dsh-pocket-access" content="lan">';
|
|
502
592
|
// 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
|
|
503
593
|
// 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
|
|
504
594
|
// 公网流量随后照常走访问密码认证。
|
|
@@ -531,7 +621,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
531
621
|
'cache-control': 'no-store',
|
|
532
622
|
'retry-after': String(rl.retryAfter),
|
|
533
623
|
});
|
|
534
|
-
res.end(loginPageHtml('locked', isPublic, rl.retryAfter
|
|
624
|
+
res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
|
|
535
625
|
return;
|
|
536
626
|
}
|
|
537
627
|
let body = '';
|
|
@@ -552,7 +642,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
552
642
|
limiter?.record(ip);
|
|
553
643
|
log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
|
|
554
644
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
555
|
-
res.end(loginPageHtml(true, isPublic, 0
|
|
645
|
+
res.end(loginPageHtml(true, isPublic, 0));
|
|
556
646
|
}
|
|
557
647
|
});
|
|
558
648
|
return;
|
|
@@ -563,7 +653,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
563
653
|
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
|
|
564
654
|
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
565
655
|
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
566
|
-
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter
|
|
656
|
+
res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
|
|
567
657
|
} else {
|
|
568
658
|
res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
569
659
|
res.end('{"error":"unauthorized"}');
|
|
@@ -578,17 +668,64 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
578
668
|
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
|
|
579
669
|
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
|
|
580
670
|
// 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
671
|
+
// 握手重试上限(issue #91):Safari 不保存 http://IP 源上 3xx 下发的 cookie →
|
|
672
|
+
// 补 token → 上游 303 → 浏览器仍无 cookie → 无限循环。达到上限就别再补了,
|
|
673
|
+
// 让请求落到提示页,而不是继续转圈。
|
|
674
|
+
const handshakeIp = clientIp(req);
|
|
675
|
+
// `/?dsh-pocket-retry=1`:提示页上的「重试」入口——清掉这一轮的失败计数,让握手
|
|
676
|
+
// 重新走一遍(否则用户得干等窗口过期)。这参数是我们自己加的,不往上游透传。
|
|
677
|
+
let cleanPath = stripDesktopMarkers(req.url);
|
|
678
|
+
if (cleanPath.includes(HANDSHAKE_RETRY_PARAM)) {
|
|
679
|
+
handshake.clear(handshakeIp);
|
|
680
|
+
cleanPath = stripQueryParam(cleanPath, HANDSHAKE_RETRY_PARAM);
|
|
681
|
+
}
|
|
682
|
+
const handshakeOver = launchTok !== '' && handshake.exhausted(handshakeIp);
|
|
683
|
+
const upstreamPath = handshakeOver
|
|
684
|
+
? cleanPath
|
|
685
|
+
: upstreamPathWithLaunchToken(cleanPath, req.method, req.headers.cookie, launchTok);
|
|
686
|
+
const didInjectToken = upstreamPath !== cleanPath;
|
|
687
|
+
if (didInjectToken) {
|
|
688
|
+
handshake.record(handshakeIp);
|
|
689
|
+
handshake.prune();
|
|
690
|
+
}
|
|
691
|
+
// 请求带上了会话 cookie → 这一轮的握手计数可以清掉了(说明 cookie 通路是好的)
|
|
692
|
+
if (!didInjectToken && String(req.headers.cookie ?? '').includes(DSH_AUTH_COOKIE)) {
|
|
693
|
+
handshake.clear(handshakeIp);
|
|
694
|
+
}
|
|
695
|
+
if (handshakeOver && isHtmlRequest(req)) {
|
|
696
|
+
// 已判定握不上手 → 停在这里给人话,别再转圈。API/WS 不走这里(上游会 401)。
|
|
697
|
+
res.writeHead(503, {
|
|
698
|
+
'content-type': 'text/html; charset=utf-8',
|
|
699
|
+
'cache-control': 'no-store',
|
|
700
|
+
'x-dsh-pocket-handshake': 'blocked',
|
|
701
|
+
});
|
|
702
|
+
res.end(handshakeBlockedPageHtml());
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
587
705
|
const proxyReq = httpRequest(
|
|
588
706
|
{ host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
|
|
589
707
|
(proxyRes) => {
|
|
590
708
|
log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
|
|
591
709
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
710
|
+
// issue #91:我们刚注入了 launch token,上游回 303(换 cookie 后回干净根路径)。
|
|
711
|
+
// Safari 不保存 http://IP 源上 3xx 响应下发的 cookie,于是浏览器下次仍无 cookie
|
|
712
|
+
// → 代理再注入 → 再 303 → 死循环。这里把这次 303 改成 200 过渡页:Set-Cookie
|
|
713
|
+
// 照发(200 上的 cookie 不会被那条重定向策略丢掉),页面用 meta refresh 跳回 `/`。
|
|
714
|
+
if (didInjectToken && proxyRes.statusCode === 303 && isHtmlRequest(req)) {
|
|
715
|
+
const out = { ...proxyRes.headers };
|
|
716
|
+
delete out['content-length'];
|
|
717
|
+
delete out['transfer-encoding'];
|
|
718
|
+
delete out.location; // 自己跳,不留给浏览器去重做一次 303
|
|
719
|
+
const page = Buffer.from(handshakePageHtml(), 'utf8');
|
|
720
|
+
out['content-type'] = 'text/html; charset=utf-8';
|
|
721
|
+
out['content-length'] = String(page.length);
|
|
722
|
+
out['cache-control'] = 'no-store';
|
|
723
|
+
out['x-dsh-pocket-handshake'] = 'transition';
|
|
724
|
+
proxyRes.resume(); // 消费掉上游响应体,释放连接
|
|
725
|
+
res.writeHead(200, out);
|
|
726
|
+
res.end(page);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
592
729
|
// issue #81:上游 desktop-browser-access 门禁(DSH Desktop 未开启「浏览器访问」时)
|
|
593
730
|
// 对普通浏览器(含经本代理转发的手机)返回 403 text/plain "forbidden",且本代理无法
|
|
594
731
|
// 携带 Electron renderer secret 绕过。对符合该特征的**浏览器导航**请求改写为可操作
|
|
@@ -633,11 +770,6 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
633
770
|
proxyRes.on('data', (c) => chunks.push(c));
|
|
634
771
|
proxyRes.on('end', () => {
|
|
635
772
|
let html = Buffer.concat(chunks).toString('utf8');
|
|
636
|
-
// 防密码被钓(issue #82):注入会话指纹 meta(与登录页同源)。
|
|
637
|
-
// 仅当页面尚无该标记时注入,避免重复;不存在 <head> 时跳过(dsh web 必有)。
|
|
638
|
-
if (!html.includes(SESSION_MARK)) {
|
|
639
|
-
html = html.replace(/<head[^>]*>/i, (m) => `${m}${sessionMeta}${accessMeta}`);
|
|
640
|
-
}
|
|
641
773
|
if (!html.includes(INJECT_MARK)) {
|
|
642
774
|
html = html.replace(/<head[^>]*>/i, (m) => `${m}${injectHtml}`);
|
|
643
775
|
}
|
package/lib/service.mjs
CHANGED
|
@@ -15,7 +15,6 @@ import { join, dirname } from 'node:path';
|
|
|
15
15
|
import { createPocketProxy } from './proxy.mjs';
|
|
16
16
|
import { startQuickTunnel, startNamedTunnel } from './tunnel.mjs';
|
|
17
17
|
import { isValidIpv4 } from './ip.mjs';
|
|
18
|
-
import { ensureSessionFingerprint } from './session.mjs';
|
|
19
18
|
|
|
20
19
|
const require = createRequire(import.meta.url);
|
|
21
20
|
|
|
@@ -400,8 +399,6 @@ export function createPocketService({
|
|
|
400
399
|
};
|
|
401
400
|
})(),
|
|
402
401
|
dshPort,
|
|
403
|
-
// 防密码被钓(issue #82):本次进程的会话指纹,设置页展示供用户与手机登录页交叉核对
|
|
404
|
-
sessionFingerprint: ensureSessionFingerprint(),
|
|
405
402
|
};
|
|
406
403
|
},
|
|
407
404
|
|
package/package.json
CHANGED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
// 公网会话指纹校验(issue #82,防密码被钓)。
|
|
2
|
-
//
|
|
3
|
-
// 本代理会给每个经它伺服的 HTML(应用页 + 登录页)注入
|
|
4
|
-
// <meta name="dsh-pocket-session" content="<本次进程随机指纹>">。该指纹只在本机
|
|
5
|
-
// 代理内生成、只出现在我们伺服的页面,陌生人复用被回收的快速隧道子域时(其站点
|
|
6
|
-
// 不经我们的代理)拿不到它、也猜不出。
|
|
7
|
-
//
|
|
8
|
-
// 校验逻辑(纯前端、不依赖任何外部请求,避免被钓鱼站点误导):
|
|
9
|
-
// - 页面带指纹 meta → 这是我们的页面,显示一个小徽标供用户与电脑设置页交叉核对
|
|
10
|
-
// (不一致即说明链接被复用);
|
|
11
|
-
// - 页面不带指纹 meta → 很可能不是我们的页面(链接已被他人复用,或克隆登录页钓鱼),
|
|
12
|
-
// 弹红色全屏警告并拦截交互,防止用户在被钓鱼页输入 8 位密码。
|
|
13
|
-
|
|
14
|
-
const SESSION_META = 'dsh-pocket-session'
|
|
15
|
-
const ACCESS_META = 'dsh-pocket-access'
|
|
16
|
-
|
|
17
|
-
function readFingerprint(): string | null {
|
|
18
|
-
const meta = document.querySelector<HTMLMetaElement>(`meta[name="${SESSION_META}"]`)
|
|
19
|
-
const fp = meta?.content?.trim()
|
|
20
|
-
return fp && fp.length > 0 ? fp : null
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** 读取代理注入的访问类型标记(public | lan)。 */
|
|
24
|
-
function readAccess(): string | null {
|
|
25
|
-
const meta = document.querySelector<HTMLMetaElement>(`meta[name="${ACCESS_META}"]`)
|
|
26
|
-
return meta?.content?.trim() ?? null
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function mountBadge(fp: string): () => void {
|
|
30
|
-
const badge = document.createElement('div')
|
|
31
|
-
badge.setAttribute('data-mobile-nav', 'session-fp')
|
|
32
|
-
badge.textContent = `🔒 会话指纹 ${fp}`
|
|
33
|
-
// 固定在底部居中、低存在感、仅供交叉核对
|
|
34
|
-
badge.style.cssText = [
|
|
35
|
-
'position:fixed', 'left:50%', 'bottom:8px', 'transform:translateX(-50%)',
|
|
36
|
-
'z-index:2147483646', 'padding:4px 10px', 'border-radius:999px',
|
|
37
|
-
'background:rgba(17,24,39,.82)', 'color:#fff',
|
|
38
|
-
'font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
39
|
-
'letter-spacing:.5px', 'pointer-events:none', 'user-select:none', 'white-space:nowrap',
|
|
40
|
-
].join(';')
|
|
41
|
-
badge.title = '本页会话指纹,应与电脑「手机访问」设置页显示的一致;不一致说明链接已被他人复用'
|
|
42
|
-
document.body.appendChild(badge)
|
|
43
|
-
return () => badge.remove()
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function mountWarning(): () => void {
|
|
47
|
-
const overlay = document.createElement('div')
|
|
48
|
-
overlay.setAttribute('data-mobile-nav', 'session-warn')
|
|
49
|
-
overlay.style.cssText = [
|
|
50
|
-
'position:fixed', 'inset:0', 'z-index:2147483647',
|
|
51
|
-
'display:flex', 'align-items:center', 'justify-content:center', 'padding:24px',
|
|
52
|
-
'background:rgba(0,0,0,.72)', 'color:#fff',
|
|
53
|
-
'font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', 'text-align:center',
|
|
54
|
-
].join(';')
|
|
55
|
-
overlay.style.whiteSpace = 'pre-line'
|
|
56
|
-
overlay.textContent = '⚠️ 此页面不是你的 DSH:链接可能已被他人复用。\n请勿在此输入任何密码。\n请重新在电脑「手机访问」设置页扫当前二维码。'
|
|
57
|
-
document.documentElement.appendChild(overlay)
|
|
58
|
-
return () => overlay.remove()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function startSessionGuard(): () => void {
|
|
62
|
-
// 局域网访问不存在「快速隧道子域被 Cloudflare 回收复用」的风险(issue #83),
|
|
63
|
-
// 防钓鱼校验只在公网启用;局域网直接不挂,避免误报阻断用户使用。
|
|
64
|
-
if (readAccess() !== 'public') return () => {}
|
|
65
|
-
|
|
66
|
-
let cleanupBadge: (() => void) | null = null
|
|
67
|
-
let cleanupWarn: (() => void) | null = null
|
|
68
|
-
const evaluate = (): void => {
|
|
69
|
-
const fp = readFingerprint()
|
|
70
|
-
if (fp) {
|
|
71
|
-
cleanupWarn?.()
|
|
72
|
-
cleanupWarn = null
|
|
73
|
-
if (!cleanupBadge) cleanupBadge = mountBadge(fp)
|
|
74
|
-
} else {
|
|
75
|
-
cleanupBadge?.()
|
|
76
|
-
cleanupBadge = null
|
|
77
|
-
if (!cleanupWarn) cleanupWarn = mountWarning()
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
evaluate()
|
|
81
|
-
// 容错:极少数情况下 meta 在 effect 运行时尚未解析,稍后复查一次
|
|
82
|
-
const timer = window.setTimeout(evaluate, 300)
|
|
83
|
-
const observer = new MutationObserver(evaluate)
|
|
84
|
-
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
85
|
-
return () => {
|
|
86
|
-
window.clearTimeout(timer)
|
|
87
|
-
observer.disconnect()
|
|
88
|
-
cleanupBadge?.()
|
|
89
|
-
cleanupWarn?.()
|
|
90
|
-
}
|
|
91
|
-
}
|
package/lib/session.mjs
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
// 公网会话指纹(防密码被钓,issue #82)。
|
|
2
|
-
//
|
|
3
|
-
// 每次 dsh web 进程启动生成一次,随机、不可预测。只经本代理注入到我们伺服的
|
|
4
|
-
// HTML(应用页 + 登录页),并显示在电脑「手机访问」设置页——永不落盘、不进日志、
|
|
5
|
-
// 不出现在任何静态产物里。
|
|
6
|
-
//
|
|
7
|
-
// 快速隧道子域被 Cloudflare 回收、分给陌生人后,陌生人的站点不经我们的代理,
|
|
8
|
-
// 拿不到这个指纹、也猜不出。于是其克隆的登录页要么没有指纹、要么指纹对不上:
|
|
9
|
-
// 用户在手机登录页看到的指纹若与电脑设置页不一致,即说明链接已被他人复用,
|
|
10
|
-
// 不应输入密码。这是"密码不防回收"之外,真正能拦住密码钓鱼的一层。
|
|
11
|
-
|
|
12
|
-
import { randomBytes } from 'node:crypto'
|
|
13
|
-
|
|
14
|
-
let fingerprint = null
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* 惰性生成并返回本次进程的会话指纹(多次调用返回同一值)。
|
|
18
|
-
* @returns {string} 6 位大写字母/数字指纹
|
|
19
|
-
*/
|
|
20
|
-
export function ensureSessionFingerprint() {
|
|
21
|
-
if (!fingerprint) {
|
|
22
|
-
let s = ''
|
|
23
|
-
while (s.length < 6) {
|
|
24
|
-
// base64 含 + / 等非字母数字字符,只保留 A-Z0-9 并转大写
|
|
25
|
-
s += randomBytes(6).toString('base64').replace(/[^A-Z0-9]/gi, '').toUpperCase()
|
|
26
|
-
}
|
|
27
|
-
fingerprint = s.slice(0, 6)
|
|
28
|
-
}
|
|
29
|
-
return fingerprint
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** 读取已生成的指纹(代理尚未启动时返回 null)。 */
|
|
33
|
-
export function getSessionFingerprint() {
|
|
34
|
-
return fingerprint
|
|
35
|
-
}
|