dsh-pocket 1.11.0 → 1.11.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/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/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.1"
80
80
  }