@goodandready/dsh-key-rotation 0.8.70 → 0.8.72
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 +24 -0
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +14 -1
- package/lib/http-bridge.js +2 -2
- package/lib/index.js +2 -2
- package/lib/ops-keys.js +14 -9
- package/lib/pool.js +50 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.72] — 2026-10-23
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **Fail-closed write-origin guard (#516):** Mutating HTTP methods (`POST`, `PUT`, `PATCH`, `DELETE`) without `Origin` header are now rejected with `403 Forbidden` unless positively identified as the Desktop shell (UA contains `@deepseek-ai/dsh-desktop/` AND Cookie contains `dsh-auth-`). Previously, any LAN/Tailscale client whose requests were forwarded via `dsh-lanmode` (loopback socket) could bypass source validation on all write routes.
|
|
7
|
+
- **`/auto-import` fail-closed body validation (#516):** Invalid JSON body now returns `400 Bad Request` instead of silently importing all discovered keys. Auto-import requires explicit `refs` array or `all: true`.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `isDesktopShellRequest(req)` predicate in `lib/pool.js` for positive Desktop shell identification.
|
|
11
|
+
- `test/issue-516-write-origin-guard.test.mjs` — dedicated regression test suite (3 tests).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `isTrustedBridgeRequest` in `lib/pool.js`: absent Origin on write requests now checks Desktop shell credentials or same-origin Referer before passing.
|
|
15
|
+
- Client card `runAutoImport` sends `{ all: true }` instead of `{}`.
|
|
16
|
+
- Updated 7 existing test files to match new fail-closed semantics.
|
|
17
|
+
- Updated `README.md`, `README.zh.md`, `docs/design/DESIGN.md`.
|
|
18
|
+
|
|
19
|
+
## 0.8.71 - 2026-10-22
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
- **Complete Chinese (zh) Localization for Interactive Routing Simulator (#514, PR #515)**:
|
|
23
|
+
- Added 13 missing Chinese translations to `zh` dictionary in `lib/client-src/10-locales.js` for the Interactive Routing Simulator dialog (`simulatorTitle`, `simulatorDesc`, `simulatorRun`, `simulatorRunning`, `simulatorSelectProvider`, `simulatorSelectModel`, `simulatorMaxAttempts`, `simulatorEvent`, `simulatorEventSuccess`, `simulatorEventRateLimit`, `simulatorEventServer500`, `simulatorResults`, `simulatorNoResults`).
|
|
24
|
+
- Recompiled `lib/client.js` bundle (194,681 bytes) meeting strict size and line constraints.
|
|
25
|
+
- Added automated test `test/issue-514-i18n-zh-simulator.test.mjs` verifying 100% key parity between English and Chinese dictionaries (248 keys each).
|
|
26
|
+
|
|
3
27
|
## 0.8.70 - 2026-10-20
|
|
4
28
|
|
|
5
29
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -303,7 +303,7 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
|
|
|
303
303
|
* **Zero Plaintext Secrets in Plugin Config**: Configuration files store only environment variable reference names (e.g. `MY_PROVIDER_API_KEY`).
|
|
304
304
|
* **Secure Vault Storage**: Actual secret values reside securely in `$DSH_HOME/.credentials.yaml` managed by the DSH `Credentials` service.
|
|
305
305
|
* **5-Character Masking (`keyTail`)**: Full secret values are never sent to the client browser; only the trailing 5 characters are exposed for visual identification. Short keys (<= 5 characters) return a fixed masked placeholder (`***`) to prevent credential disclosure.
|
|
306
|
-
* **Fail-Closed Loopback &
|
|
306
|
+
* **Fail-Closed Loopback, Same-Origin & Write-Origin Guard**: Administrative endpoints strictly enforce loopback checks (`isTrustedBridgeRequest`): the socket peer and the `Host` header must both be loopback (`127.0.0.1`, `::1`, `localhost`), and `sec-fetch-site: cross-site` is always refused. An attached `Origin` must be an http(s) loopback origin matching `Host` exactly. `Origin` is required on mutating methods (`POST`/`PUT`/`PATCH`/`DELETE`) — requests without `Origin` pass only if they carry positive Desktop shell identification (`@deepseek-ai/dsh-desktop/` in `User-Agent` AND `dsh-auth-` session cookie) or a same-origin `Referer`. `GET`/`HEAD`/`OPTIONS` remain unrestricted as browsers omit `Origin` on same-origin reads.
|
|
307
307
|
* **Fail-Closed Resolver on Pool Exhaustion**: When all credentials in a managed pool are exhausted, paused, expired, or blocked by RPM/TPM limits, the resolver fails closed with `LOCAL_POOL_EXHAUSTED` error rather than falling back to leaking unmanaged credentials.
|
|
308
308
|
* **SSRF Protection & Rebinding Guard**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses including IPv4-mapped and IPv4-compatible IPv6 addresses (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), and enforces connect-time DNS validation via undici agent dispatchers to prevent TOCTOU DNS rebinding.
|
|
309
309
|
* **Cross-Pool Revocation & Inheritance**: Model pools automatically inherit pause, revoke, and expiry states from their base provider; runtime 401 permanent authentication failures revoke the credential across all shared pools immediately.
|
package/README.zh.md
CHANGED
|
@@ -265,7 +265,7 @@ claude-sonnet 请求
|
|
|
265
265
|
* **配置零明文**:插件配置仅保存环境变量引用名(如 `MY_PROVIDER_API_KEY`)。
|
|
266
266
|
* **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
|
|
267
267
|
* **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。对于长度 <= 5 的短密钥,返回统一脱敏占位符 (`***`),防止凭证完整泄露。
|
|
268
|
-
* **Fail-Closed
|
|
268
|
+
* **Fail-Closed 环回同源安全隔离及写操作 Origin 守卫**:管理接口严格通过 `isTrustedBridgeRequest` 校验:套接字对端与 `Host` 头必须均为环回地址(`127.0.0.1`、`::1`、`localhost`),并始终拒绝 `sec-fetch-site: cross-site`。若请求携带 `Origin`,则必须是 http(s) 环回源且与 `Host` 完全一致。变更方法(`POST`/`PUT`/`PATCH`/`DELETE`)无 `Origin` 时,仅允许通过明确 Desktop 外壳身份识别(`User-Agent` 含 `@deepseek-ai/dsh-desktop/` 且 Cookie 含 `dsh-auth-`)或同源 `Referer` 的请求。`GET`/`HEAD`/`OPTIONS` 保持不限制,因为浏览器同源读取时不发送 `Origin`。
|
|
269
269
|
* **凭证池耗尽 Fail-Closed 熔断**:当受管凭证池中所有密钥均被耗尽、暂停、过期或触发 RPM/TPM 限制时,解析器立即返回 `LOCAL_POOL_EXHAUSTED` 错误熔断,杜绝静默回退泄漏未受控原始密钥。
|
|
270
270
|
* **SSRF 与 DNS 重绑定防护**:远程配置导入严格仅支持 HTTPS,全面校验解析 IP 并拦截 IPv4-mapped/IPv4-compatible 等各类 IPv6 环回变体(如 `::ffff:127.0.0.1`、`::ffff:7f00:1`、`64:ff9b::/96`),并通过 undici 调度器执行连接时(connect-time)DNS 校验,从根本上防御 TOCTOU DNS 重绑定。
|
|
271
271
|
* **跨池吊销与状态继承**:模型子池自动继承基础提供商的暂停、吊销与过期时间;运行时 401 永久认证失败将即时在所有关联共享池中统一标记吊销。
|
package/lib/client.js
CHANGED
|
@@ -300,6 +300,19 @@ window.__ModuleLoader__.load({
|
|
|
300
300
|
'header.sub': '自动化多密钥轮换、429/5xx指数退避冷却、断路器保护及凭证安全 (~/.dsh/.credentials.yaml)。',
|
|
301
301
|
'header.pools_badge': '个活跃密钥池',
|
|
302
302
|
'header.keys_badge': '个已配置密钥',
|
|
303
|
+
simulatorTitle: '交互式路由模拟器',
|
|
304
|
+
simulatorDesc: '测试路由策略并在不向上游发送实际请求的情况下可视化密钥池的故障转移链路。',
|
|
305
|
+
simulatorRun: '运行模拟',
|
|
306
|
+
simulatorRunning: '模拟中…',
|
|
307
|
+
simulatorSelectProvider: '提供商',
|
|
308
|
+
simulatorSelectModel: '模型(可选)',
|
|
309
|
+
simulatorMaxAttempts: '最大尝试次数',
|
|
310
|
+
simulatorEvent: '模拟场景',
|
|
311
|
+
simulatorEventSuccess: '所有密钥均成功',
|
|
312
|
+
simulatorEventRateLimit: '密钥 1 触发速率限制 (429)',
|
|
313
|
+
simulatorEventServer500: '密钥 1 内部服务错误 (500)',
|
|
314
|
+
simulatorResults: '模拟追踪结果',
|
|
315
|
+
simulatorNoResults: '运行模拟以查看候选密钥执行链路。',
|
|
303
316
|
sectionStats: '密钥池健康与遥测',
|
|
304
317
|
sectionStatsDesc: '活跃密钥池、健康凭证和故障转移状态的实时遥测。',
|
|
305
318
|
sectionFailover: '故障转移触发器与错误代码',
|
|
@@ -1397,7 +1410,7 @@ window.__ModuleLoader__.load({
|
|
|
1397
1410
|
const res = await fetch('/dsh-key-rotation/auto-import', {
|
|
1398
1411
|
method: 'POST',
|
|
1399
1412
|
headers: { 'Content-Type': 'application/json' },
|
|
1400
|
-
body: JSON.stringify({}),
|
|
1413
|
+
body: JSON.stringify({ all: true }),
|
|
1401
1414
|
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined,
|
|
1402
1415
|
});
|
|
1403
1416
|
const data = await res.json();
|
package/lib/http-bridge.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// lib/http-bridge.js — shared HTTP helpers for dsh-key-rotation routes.
|
|
2
2
|
import { findSecrets } from './keycheck.js';
|
|
3
|
-
import { isTrustedBridgeRequest } from './pool.js';
|
|
4
|
-
export { isTrustedBridgeRequest };
|
|
3
|
+
import { isTrustedBridgeRequest, isDesktopShellRequest } from './pool.js';
|
|
4
|
+
export { isTrustedBridgeRequest, isDesktopShellRequest };
|
|
5
5
|
import { mutateSettings, plainObject, requireRevision, sectionOps, validateOps } from './settings-write.js';
|
|
6
6
|
|
|
7
7
|
export const NS = 'dsh-key-rotation';
|
package/lib/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
10
10
|
import Schema from '@deepseek-ai/schemastery';
|
|
11
11
|
import { createConfigReader, createProviderProfilesReader } from './config-compat.js';
|
|
12
12
|
import {
|
|
13
|
-
keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN,
|
|
13
|
+
keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, SWITCHABLE_MESSAGE_PATTERN,
|
|
14
14
|
DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
|
|
15
15
|
recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
|
|
16
16
|
computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
|
|
@@ -52,7 +52,7 @@ import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence, setupPro
|
|
|
52
52
|
|
|
53
53
|
export const name = '@goodandready/dsh-key-rotation';
|
|
54
54
|
export const inject = ['llm', 'webServer', 'settings', 'credentials'];
|
|
55
|
-
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
55
|
+
export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, isDesktopShellRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
|
|
56
56
|
export { notifySwitch, notifyExhaustion, notifyEarlyWarning };
|
|
57
57
|
|
|
58
58
|
const NS = 'dsh-key-rotation';
|
package/lib/ops-keys.js
CHANGED
|
@@ -464,19 +464,24 @@ ctx.effect(() => ctx.webServer.register({
|
|
|
464
464
|
json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: auto-import is local-only' } });
|
|
465
465
|
return;
|
|
466
466
|
}
|
|
467
|
-
let body
|
|
468
|
-
try {
|
|
467
|
+
let body;
|
|
468
|
+
try {
|
|
469
|
+
body = await readJson(req);
|
|
470
|
+
} catch (e) {
|
|
471
|
+
json(res, 400, { error: { code: 'bad-request', message: e.message || 'invalid request body' } });
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const hasRefs = Array.isArray(body?.refs) && body.refs.length > 0;
|
|
475
|
+
if (!hasRefs && body?.all !== true) {
|
|
476
|
+
json(res, 400, { error: { code: 'bad-request', message: 'auto-import requires explicit "refs" array or "all: true"' } });
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
469
479
|
|
|
470
480
|
try {
|
|
471
481
|
const runtime = deps.buildRuntime?.() ?? {};
|
|
472
482
|
const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
|
|
473
|
-
const targetRefs =
|
|
474
|
-
|
|
475
|
-
: null;
|
|
476
|
-
|
|
477
|
-
const toImport = targetRefs
|
|
478
|
-
? discovered.filter((d) => targetRefs.has(d.ref))
|
|
479
|
-
: discovered;
|
|
483
|
+
const targetRefs = hasRefs ? new Set(body.refs) : null;
|
|
484
|
+
const toImport = targetRefs ? discovered.filter((d) => targetRefs.has(d.ref)) : discovered;
|
|
480
485
|
|
|
481
486
|
if (toImport.length === 0) {
|
|
482
487
|
json(res, 200, { ok: true, importedCount: 0, message: 'No unassigned keys to import' });
|
package/lib/pool.js
CHANGED
|
@@ -39,12 +39,20 @@ export function isLoopbackAddress(ip) {
|
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/** Check if an incoming HTTP request is from the Desktop shell (#516). */
|
|
43
|
+
export function isDesktopShellRequest(req) {
|
|
44
|
+
const agent = req?.headers?.['user-agent'] || '';
|
|
45
|
+
const cookie = req?.headers?.cookie || '';
|
|
46
|
+
return typeof agent === 'string' && agent.includes('@deepseek-ai/dsh-desktop/') &&
|
|
47
|
+
typeof cookie === 'string' && cookie.includes('dsh-auth-');
|
|
48
|
+
}
|
|
49
|
+
|
|
42
50
|
/** Strip an optional :port and IPv6 brackets from a Host header value. */
|
|
43
51
|
function hostNameOf(hostHeader) {
|
|
44
52
|
return String(hostHeader).replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
/** Check if an incoming HTTP request is from a trusted local bridge origin (#353 fail-closed, #378 method-aware). */
|
|
55
|
+
/** Check if an incoming HTTP request is from a trusted local bridge origin (#353 fail-closed, #378 method-aware, #516 write-origin-guard). */
|
|
48
56
|
export function isTrustedBridgeRequest(req) {
|
|
49
57
|
const remoteAddress = req?.socket?.remoteAddress;
|
|
50
58
|
if (!isLoopbackAddress(remoteAddress)) return false;
|
|
@@ -55,25 +63,51 @@ export function isTrustedBridgeRequest(req) {
|
|
|
55
63
|
if (!(isLoopbackAddress(hostName) || hostName === 'localhost')) return false;
|
|
56
64
|
|
|
57
65
|
const origin = req?.headers?.origin;
|
|
58
|
-
if (origin
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
if (origin !== undefined) {
|
|
67
|
+
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
68
|
+
try {
|
|
69
|
+
const originUrl = new URL(origin);
|
|
70
|
+
// Official Desktop build shell origin
|
|
71
|
+
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (originUrl.host !== hostHeader) return false;
|
|
75
|
+
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
76
|
+
return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
|
|
77
|
+
} catch (_) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Absent Origin:
|
|
83
|
+
const method = String(req?.method || 'GET').toUpperCase();
|
|
84
|
+
const mutating = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
|
|
85
|
+
if (!mutating) {
|
|
86
|
+
// Browsers omit Origin on same-origin read navigations (GET/HEAD/OPTIONS).
|
|
62
87
|
return true;
|
|
63
88
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
|
|
90
|
+
// Mutating requests without Origin: allow only authenticated Desktop shell or valid Referer (#516).
|
|
91
|
+
if (isDesktopShellRequest(req)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const referer = req?.headers?.referer;
|
|
96
|
+
if (referer) {
|
|
97
|
+
try {
|
|
98
|
+
const refUrl = new URL(referer);
|
|
99
|
+
if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (refUrl.host !== hostHeader) return false;
|
|
103
|
+
const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
|
|
104
|
+
return ['http:', 'https:'].includes(refUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
|
|
105
|
+
} catch (_) {
|
|
106
|
+
return false;
|
|
70
107
|
}
|
|
71
|
-
if (originUrl.host !== hostHeader) return false;
|
|
72
|
-
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
73
|
-
return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
|
|
74
|
-
} catch (_) {
|
|
75
|
-
return false;
|
|
76
108
|
}
|
|
109
|
+
|
|
110
|
+
return false;
|
|
77
111
|
}
|
|
78
112
|
|
|
79
113
|
/** Regex matching error phrases that warrant switching to the next key. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.72",
|
|
4
4
|
"packageManager": "pnpm@10.33.2",
|
|
5
5
|
"description": "Per-provider API key rotation for DeepSeek Harness: a key pool per provider, auto-created clone routes, and switching to the next key on quota/rate-limit errors. Includes a Settings section (Key Rotation) to edit the key pools, cooldown and switch codes.",
|
|
6
6
|
"keywords": [
|