dsh-pocket 2.9.1 → 2.10.0

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 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):链接 ephemeral 提示 + 本次会话指纹交叉核对
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):链接 ephemeral 提示 + 本次会话指纹交叉核对
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',
@@ -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
- * fp: 本次公网会话指纹(issue #82 防钓鱼)——与电脑「手机访问」设置页一致才是你的 DSH。 */
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>${fpLine}
194
+ </form>
203
195
  </div></body></html>`;
204
196
  }
205
197
 
@@ -488,17 +480,9 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
488
480
  */
489
481
  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 = () => '' } = {}) {
490
482
  const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
491
- // 防密码被钓(issue #82):本次进程会话指纹,注入到所有经代理伺服的 HTML
492
- const sessionFingerprint = ensureSessionFingerprint();
493
- const sessionMeta = `<meta name="dsh-pocket-session" content="${sessionFingerprint}">`;
494
483
  const server = createServer((req, res) => {
495
484
  const host = String(req.headers.host ?? '');
496
485
  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
486
  // 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
503
487
  // 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
504
488
  // 公网流量随后照常走访问密码认证。
@@ -531,7 +515,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
531
515
  'cache-control': 'no-store',
532
516
  'retry-after': String(rl.retryAfter),
533
517
  });
534
- res.end(loginPageHtml('locked', isPublic, rl.retryAfter, isPublic ? sessionFingerprint : null));
518
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
535
519
  return;
536
520
  }
537
521
  let body = '';
@@ -552,7 +536,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
552
536
  limiter?.record(ip);
553
537
  log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
554
538
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
555
- res.end(loginPageHtml(true, isPublic, 0, isPublic ? sessionFingerprint : null));
539
+ res.end(loginPageHtml(true, isPublic, 0));
556
540
  }
557
541
  });
558
542
  return;
@@ -563,7 +547,7 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
563
547
  // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
564
548
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
565
549
  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, isPublic ? sessionFingerprint : null));
550
+ res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
567
551
  } else {
568
552
  res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
569
553
  res.end('{"error":"unauthorized"}');
@@ -633,11 +617,6 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
633
617
  proxyRes.on('data', (c) => chunks.push(c));
634
618
  proxyRes.on('end', () => {
635
619
  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
620
  if (!html.includes(INJECT_MARK)) {
642
621
  html = html.replace(/<head[^>]*>/i, (m) => `${m}${injectHtml}`);
643
622
  }
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
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.9.1"
83
+ "version": "2.10.0"
84
84
  }
@@ -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
- }