dsh-offpeak-guard 0.3.2 → 0.3.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 — 2026-11-14
4
+
5
+ - 设置接口 `/plugins/dsh-offpeak-guard/action` 补上准入:路由只在 `webServer` 与 `connection` 两个服务都在位时注册,每个请求先过 `connection.requestRejection(req)`(Host/Origin 校验 + 浏览器令牌鉴权),返回 401/403 直接拒绝。此前本机任意网页都能改会话豁免、峰谷规则和暂停方式。
6
+ - 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不退回到只检查 Host/Origin/回环地址。
7
+ - 请求体加 64 KiB 上限,超出返回 413;其余错误仍返回 400。
8
+ - 新增 `test/admission.test.mjs`:覆盖缺服务、读取抛错、`requestRejection` 抛错、放行与 401/403 透传五条路径。
9
+ - 适配 DSH 0.2.2-rc.2 的 `@deepseek-ai/dsh-client-connection` 服务约定(`requestRejection` 接收 `{ headers }`)。
10
+
3
11
  ## 0.3.2 — 2026-11-06
4
12
 
5
13
  - 修复设置面板切换峰时策略时报 `Unexpected end of JSON input`:Host 启动顺序可能让 `webServer` 在插件 apply 时尚未就绪,导致设置路由根本没注册;改用 `ctx.inject(['webServer'], ...)` 后再挂载路由。
package/README.en.md CHANGED
@@ -110,6 +110,15 @@ Click the sidebar clock to open it:
110
110
  - **Session exemptions**: searchable list of live root sessions; hover a session row in the left sidebar for a shield button, or use the **Exempt this session** button in the composer strip to toggle the current conversation directly;
111
111
  - **Holidays**: show the dynamic sync state.
112
112
 
113
+ ### Route admission
114
+
115
+ The settings route `/plugins/dsh-offpeak-guard/action` goes through host authentication:
116
+
117
+ - the route is only registered once both `webServer` and `connection` are available;
118
+ - every request passes `connection.requestRejection(req)` first — its Host/Origin fence stops DNS rebinding and cross-site calls, and its browser authentication stops pages that never logged in; a 401/403 is answered before any business logic runs;
119
+ - a missing `connection`, or one that throws, rejects the request (fail closed); there is no fallback to checking only Host, Origin or the loopback address;
120
+ - request bodies are capped at 64 KiB and answer 413 above that.
121
+
113
122
  ## Subagents and waiting
114
123
 
115
124
  - Running subagents are allowed to finish; the parent is paused after the last child ends and resumed off-peak;
package/README.md CHANGED
@@ -112,6 +112,15 @@
112
112
 
113
113
  面板里的修改立即生效并持久化。
114
114
 
115
+ ### 接口准入
116
+
117
+ 设置接口 `/plugins/dsh-offpeak-guard/action` 走宿主认证,不对外开放:
118
+
119
+ - 路由在 `webServer` 与 `connection` 两个服务都在位时才注册;
120
+ - 每个请求先过 `connection.requestRejection(req)`——Host/Origin 校验挡住 DNS rebinding 与跨站调用,浏览器令牌鉴权挡住没登录的本机网页;返回 401/403 时直接拒绝,不进入业务逻辑;
121
+ - 拿不到 `connection` 或它抛错时一律按拒绝处理(失败关闭),不会退回只检查 Host、Origin 或回环地址;
122
+ - 请求体上限 64 KiB,超出返回 413。
123
+
115
124
  ## 子代理与等待
116
125
 
117
126
  - 峰时开始时若会话有**正在运行的子代理**,先放行父会话与子代理;
package/index.js CHANGED
@@ -149,11 +149,25 @@ export function envConfig(env = process.env) {
149
149
  return out;
150
150
  }
151
151
 
152
+ /** 设置接口只收几个字段的小 JSON,超过这个体积按可疑请求拒绝。 */
153
+ const MAX_BODY_BYTES = 64 * 1024;
154
+
152
155
  async function readJsonBody(req) {
153
- let raw = '';
154
- for await (const chunk of req) raw += chunk;
155
- if (raw === '') return {};
156
- return JSON.parse(raw);
156
+ const chunks = [];
157
+ let size = 0;
158
+ for await (const chunk of req) {
159
+ size += chunk?.byteLength ?? Buffer.byteLength(String(chunk));
160
+ if (size > MAX_BODY_BYTES) {
161
+ // 先排空再拒绝,让客户端读到响应而不是被一刀切断连接。
162
+ req.resume();
163
+ const error = new Error('request body too large');
164
+ error.statusCode = 413;
165
+ throw error;
166
+ }
167
+ chunks.push(chunk);
168
+ }
169
+ if (size === 0) return {};
170
+ return JSON.parse(Buffer.concat(chunks, size).toString('utf8'));
157
171
  }
158
172
 
159
173
  function sendJson(res, status, payload) {
@@ -164,6 +178,33 @@ function sendJson(res, status, payload) {
164
178
  res.end(body);
165
179
  }
166
180
 
181
+ /**
182
+ * 设置路由的准入栅栏。
183
+ *
184
+ * `connection.requestRejection` 是宿主唯一的本机接口准入点:它同时做 Host/Origin
185
+ * 校验(挡 DNS rebinding 和跨站请求)与浏览器令牌鉴权(挡没登录的本机网页)。
186
+ * 没有它,本机任意网页都能改会话豁免、峰谷规则和暂停方式。
187
+ *
188
+ * 拿不到 `connection`、或它自己抛错时一律拒绝(失败关闭),不退回到只检查
189
+ * Host/Origin/回环地址——那条路挡不住别的网页。
190
+ */
191
+ export function admissionRejection(ctx, req) {
192
+ let connection;
193
+ try {
194
+ connection = Reflect.get(ctx, 'connection');
195
+ } catch {
196
+ return 403;
197
+ }
198
+ if (connection === undefined || connection === null || typeof connection.requestRejection !== 'function') {
199
+ return 403;
200
+ }
201
+ try {
202
+ return connection.requestRejection({ headers: req.headers }) ?? undefined;
203
+ } catch {
204
+ return 403;
205
+ }
206
+ }
207
+
167
208
  export function apply(ctx, rawConfig = {}) {
168
209
  const config = { ...(rawConfig ?? {}), ...envConfig() };
169
210
  const guard = createGuard(ctx, config);
@@ -197,7 +238,7 @@ export function apply(ctx, rawConfig = {}) {
197
238
  };
198
239
  }, 'offpeak-guard: tools');
199
240
 
200
- ctx.inject(['webServer'], (webCtx) => {
241
+ ctx.inject(['webServer', 'connection'], (webCtx) => {
201
242
  webCtx.effect(
202
243
  () =>
203
244
  webCtx.webServer.register({
@@ -205,6 +246,11 @@ export function apply(ctx, rawConfig = {}) {
205
246
  path: '/plugins/dsh-offpeak-guard',
206
247
  async handler(req, res) {
207
248
  try {
249
+ const rejection = admissionRejection(webCtx, req);
250
+ if (rejection !== undefined) {
251
+ sendJson(res, rejection, { ok: false, error: 'unauthorized' });
252
+ return;
253
+ }
208
254
  const url = new URL(req.url || '/', 'http://x');
209
255
  const rest = url.pathname.replace(/^\/plugins\/dsh-offpeak-guard/, '') || '/';
210
256
  if (req.method !== 'POST' || rest !== '/action') {
@@ -234,7 +280,8 @@ export function apply(ctx, rawConfig = {}) {
234
280
  }
235
281
  sendJson(res, 400, { ok: false, error: 'unknown action' });
236
282
  } catch (error) {
237
- sendJson(res, 400, { ok: false, error: String(error?.message ?? error) });
283
+ const status = Number.isInteger(error?.statusCode) ? error.statusCode : 400;
284
+ sendJson(res, status, { ok: false, error: String(error?.message ?? error) });
238
285
  }
239
286
  },
240
287
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-offpeak-guard",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "DeepSeek peak/off-peak pricing guard for DSH: detects the price window and current model, pauses running tasks at peak, resumes them automatically when off-peak begins, and schedules work for the next off-peak window.",
5
5
  "private": false,
6
6
  "type": "module",