dsh-pocket 1.11.0 → 1.11.2

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/README.en.md CHANGED
@@ -94,6 +94,7 @@ On the same page click "**Enable anywhere**" → wait for the tunnel (first run
94
94
  - **DSH can execute code on your computer.** **LAN** QR/URL plus its own **8-digit PIN** is the key (PIN **on by default**, switchable off — then LAN scans connect directly, same-network devices only) — **never share the LAN QR, URL or PIN**.
95
95
  - **Public** access is protected by an **8-digit PIN**: the link is random, the PIN rotates on every tunnel start by default, and old links die instantly — even a leaked link can't get in. **A custom PIN is never auto-rotated** (your value stays stable).
96
96
  - Phone login state is tied to the computer's dsh web process: **no re-entry while dsh web stays up; one re-entry after a restart/update**.
97
+ - **Login rate limiting** (anti brute-force): **5** consecutive wrong PINs from the same IP lock it for **60s**; a global failure threshold briefly locks everyone (blocks distributed IP-rotation scans); a successful login resets the counter.
97
98
  - The public URL is randomly assigned by cloudflared and **changes on every restart** (old links die automatically — a natural key rotation).
98
99
  - LAN mode exposes nothing publicly; only devices on the same network can reach it.
99
100
  - Built for personal use; the public PIN lives in `$DSH_HOME/dsh-pocket/token` (re-rolled per tunnel start unless customized), the LAN PIN in `$DSH_HOME/dsh-pocket/token-lan` (refreshed manually in Settings), and switches/custom flags in `$DSH_HOME/dsh-pocket/settings.json`.
package/README.md CHANGED
@@ -94,6 +94,7 @@ npx @deepseek-ai/dsh web
94
94
  - **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位数字密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
95
95
  - **公网**有 **8 位数字密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码)
96
96
  - 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
97
+ - **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
97
98
  - 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换)
98
99
  - 局域网模式不暴露公网,只有同一网络内的设备能访问
99
100
  - 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
package/client/client.js CHANGED
@@ -209,16 +209,22 @@ function MobileNavOverlay({ toggleSidebar, t }) {
209
209
  document.addEventListener("click", onDrawerClick, true);
210
210
  return () => document.removeEventListener("click", onDrawerClick, true);
211
211
  }, [mobile, open, toggleSidebar]);
212
+ (0, import_react.useEffect)(() => {
213
+ if (!mobile || !open) return;
214
+ const onOutsideClick = (event) => {
215
+ if (document.querySelector('[aria-modal="true"]') !== null) return;
216
+ const target = event.target;
217
+ if (target === null) return;
218
+ if (target.closest('[data-mobile-nav="toggle"]') !== null) return;
219
+ const drawer = document.querySelector('[data-mobile-nav="frame"] > :first-child');
220
+ if (drawer !== null && drawer.contains(target)) return;
221
+ toggleSidebar();
222
+ };
223
+ document.addEventListener("click", onOutsideClick, true);
224
+ return () => document.removeEventListener("click", onOutsideClick, true);
225
+ }, [mobile, open, toggleSidebar]);
212
226
  if (!mobile) return null;
213
- return /* @__PURE__ */ React.createElement(React.Fragment, null, open && /* @__PURE__ */ React.createElement(
214
- "div",
215
- {
216
- "data-mobile-nav": "backdrop",
217
- role: "button",
218
- "aria-label": t("backdrop"),
219
- onClick: () => toggleSidebar()
220
- }
221
- ), fabVisible && !open && /* @__PURE__ */ React.createElement(
227
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, open && /* @__PURE__ */ React.createElement("div", { "data-mobile-nav": "backdrop" }), fabVisible && !open && /* @__PURE__ */ React.createElement(
222
228
  "button",
223
229
  {
224
230
  type: "button",
@@ -361,13 +367,16 @@ var MOBILE_CSS = `
361
367
  outline-offset: 2px;
362
368
  }
363
369
 
364
- /* Dimmed backdrop under the open drawer; above every column, below the drawer. */
370
+ /* Dimmed backdrop under the open drawer; above every column, below the drawer.
371
+ pointer-events: none \u2014\u2014 \u70B9\u51FB\u7A7F\u900F\uFF08issue #38\uFF09\uFF1Abackdrop \u53EA\u8D1F\u8D23\u89C6\u89C9\u538B\u6697\uFF0C
372
+ \u4E0D\u62A2\u70B9\u51FB\u3002\u5173\u95ED\u62BD\u5C49\u6539\u7531 MobileNavOverlay \u7684 document \u7EA7\u300C\u62BD\u5C49\u5916\u70B9\u51FB\u300D\u76D1\u542C\u5904\u7406
373
+ \uFF08\u7B49\u4EF7\u4E8E\u539F\u6765\u7684\u70B9\u51FB\u906E\u7F69\u5173\u95ED\uFF0C\u4E14\u62BD\u5C49\u5185\u70B9\u51FB\u4E0D\u518D\u88AB backdrop \u5403\u6389\uFF09\u3002 */
365
374
  [data-mobile-nav="backdrop"] {
366
375
  position: absolute;
367
376
  inset: 0;
368
377
  z-index: 30;
369
378
  background: rgba(0, 0, 0, .45);
370
- cursor: pointer;
379
+ pointer-events: none;
371
380
  animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
372
381
  -webkit-tap-highlight-color: transparent;
373
382
  }
@@ -128,16 +128,33 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
128
128
  return () => document.removeEventListener('click', onDrawerClick, true)
129
129
  }, [mobile, open, toggleSidebar])
130
130
 
131
+ // Tap-outside closes the drawer (issue #38). The backdrop is now
132
+ // pointer-events: none (pure dimming layer that never steals clicks), so
133
+ // "tap the dimmed area to close" moves here: any click outside the drawer
134
+ // (and outside the header toggle) closes it, keeping the standard
135
+ // interaction while letting drawer contents receive clicks normally.
136
+ // Capture phase: the close happens before the content processes the tap
137
+ // (same first-tap-closes behaviour as before).
138
+ useEffect(() => {
139
+ if (!mobile || !open) return
140
+ const onOutsideClick = (event: MouseEvent) => {
141
+ if (document.querySelector('[aria-modal="true"]') !== null) return
142
+ const target = event.target as HTMLElement | null
143
+ if (target === null) return
144
+ if (target.closest('[data-mobile-nav="toggle"]') !== null) return
145
+ const drawer = document.querySelector<HTMLElement>('[data-mobile-nav="frame"] > :first-child')
146
+ if (drawer !== null && drawer.contains(target)) return
147
+ toggleSidebar()
148
+ }
149
+ document.addEventListener('click', onOutsideClick, true)
150
+ return () => document.removeEventListener('click', onOutsideClick, true)
151
+ }, [mobile, open, toggleSidebar])
152
+
131
153
  if (!mobile) return null
132
154
  return (
133
155
  <>
134
156
  {open && (
135
- <div
136
- data-mobile-nav="backdrop"
137
- role="button"
138
- aria-label={t('backdrop')}
139
- onClick={() => toggleSidebar()}
140
- />
157
+ <div data-mobile-nav="backdrop" />
141
158
  )}
142
159
  {fabVisible && !open && (
143
160
  <button
@@ -106,13 +106,16 @@ export const MOBILE_CSS = `
106
106
  outline-offset: 2px;
107
107
  }
108
108
 
109
- /* Dimmed backdrop under the open drawer; above every column, below the drawer. */
109
+ /* Dimmed backdrop under the open drawer; above every column, below the drawer.
110
+ pointer-events: none —— 点击穿透(issue #38):backdrop 只负责视觉压暗,
111
+ 不抢点击。关闭抽屉改由 MobileNavOverlay 的 document 级「抽屉外点击」监听处理
112
+ (等价于原来的点击遮罩关闭,且抽屉内点击不再被 backdrop 吃掉)。 */
110
113
  [data-mobile-nav="backdrop"] {
111
114
  position: absolute;
112
115
  inset: 0;
113
116
  z-index: 30;
114
117
  background: rgba(0, 0, 0, .45);
115
- cursor: pointer;
118
+ pointer-events: none;
116
119
  animation: dsh-mobile-nav-fade .2s var(--ds-ease-in-out, ease-in-out);
117
120
  -webkit-tap-highlight-color: transparent;
118
121
  }
package/lib/proxy.mjs CHANGED
@@ -79,6 +79,67 @@ function cookieFor(token, sessionKey) {
79
79
  return createHash('sha256').update(`${token}:${sessionKey}`).digest('hex');
80
80
  }
81
81
 
82
+ // ---------- 登录速率限制(issue #40,改进版方案 A) ----------
83
+ // 8 位数字密码(10^8 组合)本身可接受,真正风险是「无限制重试」让穷举可行。
84
+ // 这里做三层防护(内存态,随进程生命周期,与 sessionKey 一致):
85
+ // 1) 单 IP 滑动窗口:60 秒内失败 ≥5 次 → 锁 60 秒(429)
86
+ // 2) 全局滑动窗口:1 分钟全局失败 > 50 次 → 全局锁 30 秒(防分布式扫描换 IP 绕过)
87
+ // 3) 成功登录清空该 IP 计数
88
+ // IP 识别:优先 cf-connecting-ip(Cloudflare 在隧道入口设置的**真实**客户端 IP,
89
+ // 可信);无则回退 socket remoteAddress。**不信任客户端 x-forwarded-for**(可伪造)。
90
+ export const DEFAULT_RATE_LIMIT = {
91
+ windowMs: 60_000, // 失败计数滑动窗口
92
+ maxFailures: 5, // 窗口内失败阈值 → 触发单 IP 锁
93
+ lockMs: 60_000, // 单 IP 锁定时长
94
+ globalMaxFailures: 50, // 全局失败阈值(同窗口)→ 触发全局锁
95
+ globalLockMs: 30_000, // 全局锁定时长
96
+ };
97
+ function createRateLimiter(cfg = {}) {
98
+ const c = { ...DEFAULT_RATE_LIMIT, ...cfg };
99
+ const failCounts = new Map(); // ip -> { count, windowStart }
100
+ const ipLocks = new Map(); // ip -> lockedUntil
101
+ const global = { count: 0, windowStart: 0, lockedUntil: 0 };
102
+ return {
103
+ /** 该 IP 当前是否被锁;返回 { locked, retryAfter }。 */
104
+ status(ip) {
105
+ const now = Date.now();
106
+ if (global.lockedUntil > now) return { locked: true, retryAfter: Math.ceil((global.lockedUntil - now) / 1000) };
107
+ const until = ipLocks.get(ip) ?? 0;
108
+ if (until > now) return { locked: true, retryAfter: Math.ceil((until - now) / 1000) };
109
+ return { locked: false, retryAfter: 0 };
110
+ },
111
+ /** 记一次失败:维护滑动窗口计数,达阈值触发单 IP / 全局锁。 */
112
+ record(ip) {
113
+ const now = Date.now();
114
+ let rec = failCounts.get(ip);
115
+ if (!rec || now - rec.windowStart > c.windowMs) rec = { count: 0, windowStart: now };
116
+ rec.count++;
117
+ failCounts.set(ip, rec);
118
+ if (now - global.windowStart > c.windowMs) { global.count = 0; global.windowStart = now; }
119
+ global.count++;
120
+ if (rec.count >= c.maxFailures) ipLocks.set(ip, now + c.lockMs);
121
+ if (global.count >= c.globalMaxFailures) global.lockedUntil = now + c.globalLockMs;
122
+ // 防内存膨胀:超过 2000 条记录时清掉已过窗口期的条目
123
+ if (failCounts.size > 2000) {
124
+ for (const [k, v] of failCounts) {
125
+ if (now - v.windowStart > c.windowMs) failCounts.delete(k);
126
+ }
127
+ }
128
+ },
129
+ /** 成功登录:清空该 IP 计数与锁。 */
130
+ clear(ip) {
131
+ failCounts.delete(ip);
132
+ ipLocks.delete(ip);
133
+ },
134
+ };
135
+ }
136
+ /** 客户端真实 IP:cf-connecting-ip(隧道,可信)优先,否则 socket 地址;不信 XFF。 */
137
+ function clientIp(req) {
138
+ const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
139
+ if (cf) return cf;
140
+ return String(req.socket?.remoteAddress ?? 'unknown');
141
+ }
142
+
82
143
  function parseCookies(header) {
83
144
  const out = {};
84
145
  for (const part of String(header ?? '').split(';')) {
@@ -88,10 +149,13 @@ function parseCookies(header) {
88
149
  return out;
89
150
  }
90
151
 
91
- /** 登录页:按访问来源显示提示(局域网 / 公网)。 */
92
- function loginPageHtml(error, isPublic) {
152
+ /** 登录页:按访问来源显示提示(局域网 / 公网);error: false|true|'locked'(locked 带剩余秒数)。 */
153
+ function loginPageHtml(error, isPublic, retryAfter = 0) {
93
154
  const where = isPublic ? '此公网地址' : '此局域网地址';
94
155
  const whereEn = isPublic ? 'This public address' : 'This LAN address';
156
+ const errMsg = error === 'locked'
157
+ ? `尝试次数过多,请 ${retryAfter} 秒后再试 | Too many attempts — try again in ${retryAfter}s`
158
+ : error ? '密码错误,请重试 | Wrong PIN, try again' : '';
95
159
  return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
96
160
  <meta name="viewport" content="width=device-width,initial-scale=1">
97
161
  <title>DSH Pocket · 访问验证</title>
@@ -107,7 +171,7 @@ button{width:100%;padding:10px;font-size:15px;background:#4f6ef7;color:#fff;bord
107
171
  </style></head><body><div class="card">
108
172
  <h1>🔐 DSH Pocket</h1>
109
173
  <p>${where}受访问密码保护,请输入 8 位数字密码 | ${whereEn} is password-protected — enter the 8-digit PIN</p>
110
- <div class="err">${error ? '密码错误,请重试' : ''}</div>
174
+ <div class="err">${errMsg}</div>
111
175
  <form method="post" action="/pocket-login">
112
176
  <input name="token" type="password" inputmode="numeric" maxlength="8" autocomplete="one-time-code" autofocus required>
113
177
  <button type="submit">进入 | Enter</button>
@@ -152,12 +216,14 @@ function loopbackAuthority(headers, upstream) {
152
216
  * @param {string} [opts.host] 监听地址(默认 0.0.0.0:LAN 与隧道都能到)
153
217
  * @param {{host:string,port:number}} [opts.upstream] 上游 dsh web(默认 127.0.0.1:3080)
154
218
  * @param {string} [opts.injectHtml] 注入 HTML 的内容(默认 polyfill + 移动端适配;传 '' 关闭)
155
- * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected }
219
+ * @param {object} [opts.auth] 可选访问令牌认证(issue #13):{ getToken, isProtected, sessionKey }
220
+ * @param {object} [opts.rateLimit] 登录速率限制参数覆盖(issue #40;测试用短窗口)
156
221
  * @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
157
222
  */
158
- export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null } = {}) {
223
+ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null } = {}) {
224
+ const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
159
225
  const server = createServer((req, res) => {
160
- // 访问令牌认证(issue #13 + #18 + #33):局域网与公网按开关/来源要求密码
226
+ // 访问令牌认证(issue #13 + #18 + #33 + #40):局域网与公网按开关/来源要求密码
161
227
  if (auth) {
162
228
  const host = String(req.headers.host ?? '');
163
229
  const isPublic = /trycloudflare\.com$/i.test(host);
@@ -165,13 +231,25 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
165
231
  const token = protectedHost ? (auth.getToken?.(host) ?? null) : null;
166
232
  const sessionKey = auth.sessionKey ?? null;
167
233
  if (protectedHost && token) {
168
- // 登录提交:校验密码 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
234
+ const ip = clientIp(req);
235
+ // 登录提交:速率限制(issue #40)→ 校验密码 → 种持久 HttpOnly cookie(30 天,绑定进程会话密钥)→ 回首页
169
236
  if (req.method === 'POST' && req.url?.startsWith('/pocket-login')) {
237
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
238
+ if (rl.locked) {
239
+ res.writeHead(429, {
240
+ 'content-type': 'text/html; charset=utf-8',
241
+ 'cache-control': 'no-store',
242
+ 'retry-after': String(rl.retryAfter),
243
+ });
244
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
245
+ return;
246
+ }
170
247
  let body = '';
171
248
  req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
172
249
  req.on('end', () => {
173
250
  const submitted = String(new URLSearchParams(body).get('token') ?? '');
174
251
  if (submitted === token) {
252
+ limiter?.clear(ip);
175
253
  res.writeHead(302, {
176
254
  location: '/',
177
255
  'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
@@ -179,6 +257,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
179
257
  });
180
258
  res.end();
181
259
  } else {
260
+ limiter?.record(ip);
261
+ log?.(`dsh-pocket: login failed from ${ip} | 登录失败 IP: ${ip}`);
182
262
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
183
263
  res.end(loginPageHtml(true, isPublic));
184
264
  }
@@ -187,8 +267,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
187
267
  }
188
268
  if (!authCheck(req, token, sessionKey)) {
189
269
  if (isHtmlRequest(req)) {
270
+ // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
271
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
190
272
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
191
- res.end(loginPageHtml(false, isPublic));
273
+ res.end(loginPageHtml(rl.locked ? 'locked' : false, isPublic, rl.retryAfter));
192
274
  } else {
193
275
  res.writeHead(401, { 'content-type': 'application/json', 'cache-control': 'no-store' });
194
276
  res.end('{"error":"unauthorized"}');
package/package.json CHANGED
@@ -76,5 +76,5 @@
76
76
  "access": "public",
77
77
  "registry": "https://registry.npmjs.org/"
78
78
  },
79
- "version": "1.11.0"
79
+ "version": "1.11.2"
80
80
  }