@goodandready/dsh-key-rotation 0.8.71 → 0.8.73

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.73 (2026-10-30)
4
+
5
+ ### Security & Hardening (Block 1 Security Release)
6
+ - **Origin & Bridge Guard**: Reject unauthenticated mutating requests without Origin/Referer (#516).
7
+ - **Key Endpoint Protection**: Forbid plaintext credential reveal via `GET /dsh-key-rotation/key` (403 Forbidden) (#543).
8
+ - **Safe Export Webhook Masking**: Mask real `notifyWebhook` URLs and embedded credentials during masked config exports (#532).
9
+ - **Secure Export Passphrase Transport**: Passphrase for encrypted exports is now transmitted via `POST` JSON body instead of URL query string (#552).
10
+ - **Selection Trace Sanitization**: Redact upstream API tokens and error details before recording steps to selection trace (#537).
11
+ - **Fail-Closed Balance & Probe Inspection**: Prevent sending ciphertext credentials (`enc:v1:...`) upstream if decryption fails (#539).
12
+ - **PBKDF2 Iteration Bounds & Async KDF**: Enforce strict iteration limits (`10000..200000`) and async derivation to eliminate event-loop blocking DoS (#542).
13
+
14
+ ## [0.8.72] — 2026-10-23
15
+
16
+ ### Security
17
+ - **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.
18
+ - **`/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`.
19
+
20
+ ### Added
21
+ - `isDesktopShellRequest(req)` predicate in `lib/pool.js` for positive Desktop shell identification.
22
+ - `test/issue-516-write-origin-guard.test.mjs` — dedicated regression test suite (3 tests).
23
+
24
+ ### Changed
25
+ - `isTrustedBridgeRequest` in `lib/pool.js`: absent Origin on write requests now checks Desktop shell credentials or same-origin Referer before passing.
26
+ - Client card `runAutoImport` sends `{ all: true }` instead of `{}`.
27
+ - Updated 7 existing test files to match new fail-closed semantics.
28
+ - Updated `README.md`, `README.zh.md`, `docs/design/DESIGN.md`.
29
+
3
30
  ## 0.8.71 - 2026-10-22
4
31
 
5
32
  ### 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 & Same-Origin Fencing**: 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 `POST`/`PUT`/`PATCH`/`DELETE` but optional on `GET`/`HEAD`/`OPTIONS`, because browsers omit it on same-origin reads — requiring it there would reject the Settings card's own requests.
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 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 校验:套接字对端与 `Host` 头必须均为环回地址(`127.0.0.1`、`::1`、`localhost`),并始终拒绝 `sec-fetch-site: cross-site`。若请求携带 `Origin`,则必须是 http(s) 环回源且与 `Host` 完全一致。`POST`/`PUT`/`PATCH`/`DELETE` 必须携带 `Origin`,而 `GET`/`HEAD`/`OPTIONS` 允许省略——因为浏览器在同源读取请求中不会发送该头,强制要求会连带拒绝设置面板自身的请求。
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 永久认证失败将即时在所有关联共享池中统一标记吊销。
@@ -169,7 +169,15 @@ export async function inspectBalanceForRef(ctx, ref, provider, { testValue, fetc
169
169
  let rawValue = testValue || hit?.value;
170
170
  if (!rawValue && process.env[ref]) rawValue = process.env[ref];
171
171
  if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
172
- rawValue = decryptSecret(rawValue);
172
+ try {
173
+ const dec = decryptSecret(rawValue);
174
+ if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
175
+ return { ok: false, error: 'failed to decrypt credential: missing or invalid DSH_KEY_SECRET' };
176
+ }
177
+ rawValue = dec;
178
+ } catch (err) {
179
+ return { ok: false, error: `failed to decrypt credential: ${err.message}` };
180
+ }
173
181
  }
174
182
  if (!rawValue) return { ok: false, error: 'no credential' };
175
183
  return checkProviderBalance(provider, rawValue, { fetchImpl, timeoutMs });
package/lib/client.js CHANGED
@@ -1276,13 +1276,24 @@ window.__ModuleLoader__.load({
1276
1276
  const runExport = async () => {
1277
1277
  setExportError('');
1278
1278
  try {
1279
- const q = new URLSearchParams();
1280
- q.set('masked', exportMasked ? 'true' : 'false');
1281
- if (exportPassphrase.trim()) q.set('passphrase', exportPassphrase.trim());
1282
- const res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
1283
- headers: { accept: 'application/json' },
1284
- signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1285
- });
1279
+ const passphrase = exportPassphrase.trim();
1280
+ let res;
1281
+ if (passphrase) {
1282
+ // Secure POST transmission for encrypted export (#552)
1283
+ res = await fetch('/dsh-key-rotation/export', {
1284
+ method: 'POST',
1285
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
1286
+ body: JSON.stringify({ masked: exportMasked, passphrase }),
1287
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1288
+ });
1289
+ } else {
1290
+ const q = new URLSearchParams();
1291
+ q.set('masked', exportMasked ? 'true' : 'false');
1292
+ res = await fetch(`/dsh-key-rotation/export?${q.toString()}`, {
1293
+ headers: { accept: 'application/json' },
1294
+ signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
1295
+ });
1296
+ }
1286
1297
  const data = await res.json();
1287
1298
  if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Export failed');
1288
1299
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -1410,7 +1421,7 @@ window.__ModuleLoader__.load({
1410
1421
  const res = await fetch('/dsh-key-rotation/auto-import', {
1411
1422
  method: 'POST',
1412
1423
  headers: { 'Content-Type': 'application/json' },
1413
- body: JSON.stringify({}),
1424
+ body: JSON.stringify({ all: true }),
1414
1425
  signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(8000) : undefined,
1415
1426
  });
1416
1427
  const data = await res.json();
@@ -47,3 +47,28 @@ export function decryptSecret(ciphertext, secretKey = process.env.DSH_KEY_SECRET
47
47
  return ciphertext;
48
48
  }
49
49
  }
50
+
51
+ /**
52
+ * Decrypt ciphertext using AES-256-GCM.
53
+ * Fails closed by throwing or returning null if decryption fails or secretKey is missing.
54
+ */
55
+ export function decryptSecretFailClosed(ciphertext, secretKey = process.env.DSH_KEY_SECRET) {
56
+ if (!ciphertext || typeof ciphertext !== 'string') return ciphertext;
57
+ if (!ciphertext.startsWith('enc:v1:')) return ciphertext; // plaintext is allowed as-is
58
+ if (!secretKey) {
59
+ throw new Error('DSH_KEY_SECRET is required to decrypt credential');
60
+ }
61
+ const parts = ciphertext.slice('enc:v1:'.length).split(':');
62
+ if (parts.length !== 3) {
63
+ throw new Error('Malformed encrypted secret payload');
64
+ }
65
+ const [ivHex, tagHex, dataHex] = parts;
66
+ const key = crypto.createHash('sha256').update(String(secretKey)).digest();
67
+ const iv = Buffer.from(ivHex, 'hex');
68
+ const authTag = Buffer.from(tagHex, 'hex');
69
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
70
+ decipher.setAuthTag(authTag);
71
+ let decrypted = decipher.update(dataHex, 'hex', 'utf8');
72
+ decrypted += decipher.final('utf8');
73
+ return decrypted;
74
+ }
@@ -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';
@@ -12,9 +12,43 @@ import { CONFIG_EXPORT_PATH, CONFIG_IMPORT_PATH, IMPORT_PATH } from './ops-paths
12
12
  import { keyTail } from './pool.js';
13
13
  import { plainConfig } from './index.js';
14
14
 
15
+ export const MAX_PBKDF2_ITERATIONS = 200000;
16
+ export const MIN_PBKDF2_ITERATIONS = 10000;
17
+ export const DEFAULT_PBKDF2_ITERATIONS = 100000;
18
+
19
+ export function deriveKeyAsync(passphrase, salt, iterations) {
20
+ return new Promise((resolve, reject) => {
21
+ crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (err, derivedKey) => {
22
+ if (err) reject(err);
23
+ else resolve(derivedKey);
24
+ });
25
+ });
26
+ }
27
+
28
+ function validateAndExtractCryptoParams(payload) {
29
+ if (!payload || typeof payload !== 'object') {
30
+ throw new Error('Invalid encrypted config payload structure');
31
+ }
32
+ if (!payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
33
+ throw new Error('Missing salt, iv, tag or ciphertext in payload');
34
+ }
35
+ const iters = Number(payload.iterations) || DEFAULT_PBKDF2_ITERATIONS;
36
+ if (!Number.isFinite(iters) || iters < MIN_PBKDF2_ITERATIONS || iters > MAX_PBKDF2_ITERATIONS) {
37
+ throw new Error(`PBKDF2 iterations must be between ${MIN_PBKDF2_ITERATIONS} and ${MAX_PBKDF2_ITERATIONS}`);
38
+ }
39
+ const salt = Buffer.from(payload.salt, 'hex');
40
+ const iv = Buffer.from(payload.iv, 'hex');
41
+ const tag = Buffer.from(payload.tag, 'hex');
42
+ const ciphertext = Buffer.from(payload.ciphertext, 'hex');
43
+ if (salt.length !== 16 || iv.length !== 12 || tag.length !== 16) {
44
+ throw new Error('Malformed salt, iv or auth tag length in encrypted payload');
45
+ }
46
+ return { iters, salt, iv, tag, ciphertext };
47
+ }
48
+
15
49
  export function encryptConfig(plainText, passphrase) {
16
50
  const salt = crypto.randomBytes(16);
17
- const key = crypto.pbkdf2Sync(passphrase, salt, 100000, 32, 'sha256');
51
+ const key = crypto.pbkdf2Sync(passphrase, salt, DEFAULT_PBKDF2_ITERATIONS, 32, 'sha256');
18
52
  const iv = crypto.randomBytes(12);
19
53
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
20
54
  const encrypted = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()]);
@@ -23,7 +57,7 @@ export function encryptConfig(plainText, passphrase) {
23
57
  encrypted: true,
24
58
  algorithm: 'aes-256-gcm',
25
59
  kdf: 'pbkdf2',
26
- iterations: 100000,
60
+ iterations: DEFAULT_PBKDF2_ITERATIONS,
27
61
  salt: salt.toString('hex'),
28
62
  iv: iv.toString('hex'),
29
63
  tag: tag.toString('hex'),
@@ -32,14 +66,17 @@ export function encryptConfig(plainText, passphrase) {
32
66
  }
33
67
 
34
68
  export function decryptConfig(payload, passphrase) {
35
- if (!payload || !payload.salt || !payload.iv || !payload.tag || !payload.ciphertext) {
36
- throw new Error('Invalid encrypted config payload structure');
37
- }
38
- const salt = Buffer.from(payload.salt, 'hex');
39
- const iv = Buffer.from(payload.iv, 'hex');
40
- const tag = Buffer.from(payload.tag, 'hex');
41
- const ciphertext = Buffer.from(payload.ciphertext, 'hex');
42
- const key = crypto.pbkdf2Sync(passphrase, salt, payload.iterations || 100000, 32, 'sha256');
69
+ const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
70
+ const key = crypto.pbkdf2Sync(passphrase, salt, iters, 32, 'sha256');
71
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
72
+ decipher.setAuthTag(tag);
73
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
74
+ return decrypted.toString('utf8');
75
+ }
76
+
77
+ export async function decryptConfigAsync(payload, passphrase) {
78
+ const { iters, salt, iv, tag, ciphertext } = validateAndExtractCryptoParams(payload);
79
+ const key = await deriveKeyAsync(passphrase, salt, iters);
43
80
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
44
81
  decipher.setAuthTag(tag);
45
82
  const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
@@ -65,19 +102,30 @@ export function maskConfig(config) {
65
102
  }
66
103
  }
67
104
  }
68
- if (clone.webhookUrl && typeof clone.webhookUrl === 'string') {
105
+ function maskWebhookString(val) {
106
+ if (!val || typeof val !== 'string') return val;
69
107
  try {
70
- const u = new URL(clone.webhookUrl);
108
+ const u = new URL(val);
109
+ if (u.username) u.username = '***';
110
+ if (u.password) u.password = '***';
111
+ if (u.search) u.search = '?token=***';
71
112
  const parts = u.pathname.split('/');
72
113
  if (parts.length > 2) {
73
114
  parts[parts.length - 1] = '***';
74
115
  u.pathname = parts.join('/');
75
116
  }
76
- clone.webhookUrl = u.toString();
117
+ return u.toString();
77
118
  } catch (_) {
78
- clone.webhookUrl = '***';
119
+ return '***';
79
120
  }
80
121
  }
122
+
123
+ if (clone.webhookUrl) {
124
+ clone.webhookUrl = maskWebhookString(clone.webhookUrl);
125
+ }
126
+ if (clone.notifyWebhook) {
127
+ clone.notifyWebhook = maskWebhookString(clone.notifyWebhook);
128
+ }
81
129
  if (clone.webhookActionToken) clone.webhookActionToken = '***';
82
130
  return clone;
83
131
  }
@@ -138,13 +186,13 @@ export function diffConfigs(currentConfig, incomingConfig, mode = 'merge') {
138
186
  }
139
187
 
140
188
  export function registerConfigIoRoutes(ctx, deps) {
141
- // ── GET /dsh-key-rotation/export ──
189
+ // ── GET & POST /dsh-key-rotation/export (#552: support POST for safe passphrase transmission) ──
142
190
  ctx.effect(() => ctx.webServer.register({
143
191
  kind: 'exact',
144
192
  path: CONFIG_EXPORT_PATH,
145
193
  handler: async (req, res) => {
146
- if (req.method !== 'GET') {
147
- json(res, 405, { error: { code: 'method', message: 'GET only' } });
194
+ if (req.method !== 'GET' && req.method !== 'POST') {
195
+ json(res, 405, { error: { code: 'method', message: 'GET or POST only' } });
148
196
  return;
149
197
  }
150
198
  if (!isTrustedBridgeRequest(req)) {
@@ -152,10 +200,29 @@ export function registerConfigIoRoutes(ctx, deps) {
152
200
  return;
153
201
  }
154
202
 
155
- const urlObj = new URL(req.url, 'http://127.0.0.1');
156
- const maskedParam = urlObj.searchParams.get('masked');
157
- const masked = maskedParam === null ? true : maskedParam !== 'false';
158
- const passphrase = urlObj.searchParams.get('passphrase') || null;
203
+ let masked = true;
204
+ let passphrase = null;
205
+
206
+ if (req.method === 'POST') {
207
+ let body = {};
208
+ try {
209
+ body = await readJson(req);
210
+ } catch (_) {
211
+ body = {};
212
+ }
213
+ masked = body.masked !== false;
214
+ passphrase = typeof body.passphrase === 'string' && body.passphrase.trim() ? body.passphrase.trim() : null;
215
+ } else {
216
+ const urlObj = new URL(req.url, 'http://127.0.0.1');
217
+ const maskedParam = urlObj.searchParams.get('masked');
218
+ masked = maskedParam === null ? true : maskedParam !== 'false';
219
+ if (urlObj.searchParams.has('passphrase')) {
220
+ passphrase = urlObj.searchParams.get('passphrase') || null;
221
+ try {
222
+ res.setHeader?.('X-Deprecated-Security-Warning', 'Passphrase in query string is deprecated and will be removed; use POST /dsh-key-rotation/export with body');
223
+ } catch (_) {}
224
+ }
225
+ }
159
226
 
160
227
  const settings = ctx.get('settings');
161
228
  let currentConfig = {};
@@ -176,16 +243,21 @@ export function registerConfigIoRoutes(ctx, deps) {
176
243
  const exportData = masked ? maskConfig(rawConfig) : rawConfig;
177
244
 
178
245
  if (passphrase) {
179
- const encryptedPayload = encryptConfig(JSON.stringify(exportData), passphrase);
180
- json(res, 200, {
181
- ok: true,
182
- version: PLUGIN_VERSION,
183
- exportedAt: new Date().toISOString(),
184
- masked,
185
- encrypted: true,
186
- ...encryptedPayload,
187
- });
188
- return;
246
+ try {
247
+ const encryptedPayload = await encryptConfig(JSON.stringify(exportData), passphrase);
248
+ json(res, 200, {
249
+ ok: true,
250
+ version: PLUGIN_VERSION,
251
+ exportedAt: new Date().toISOString(),
252
+ masked,
253
+ encrypted: true,
254
+ ...encryptedPayload,
255
+ });
256
+ return;
257
+ } catch (err) {
258
+ json(res, 500, { error: { code: 'encryption-failed', message: err.message } });
259
+ return;
260
+ }
189
261
  }
190
262
 
191
263
  json(res, 200, {
@@ -227,7 +299,7 @@ export function registerConfigIoRoutes(ctx, deps) {
227
299
  return;
228
300
  }
229
301
  try {
230
- const decryptedStr = decryptConfig(body, passphrase);
302
+ const decryptedStr = await decryptConfigAsync(body, passphrase);
231
303
  incomingConfig = JSON.parse(decryptedStr);
232
304
  } catch (err) {
233
305
  json(res, 400, { error: { code: 'decryption-failed', message: `Decryption failed: ${err.message}` } });
package/lib/ops-keys.js CHANGED
@@ -232,27 +232,8 @@ ctx.effect(() => ctx.webServer.register({
232
232
  }
233
233
  const credentialsService = ctx.get('credentials');
234
234
  if (req.method === 'GET') {
235
- const url = new URL(req.url, 'http://127.0.0.1');
236
- const ref = url.searchParams.get('ref')?.trim();
237
- if (!isValidRef(ref)) {
238
- json(res, 400, { error: { code: 'bad-ref', message: 'dsh-key-rotation: ref must be an environment variable name' } });
239
- return;
240
- }
241
- try {
242
- let hit = await (credentialsService?.__dshKeyRotationOriginalResolve ?? credentialsService?.resolve)?.call(credentialsService, ref);
243
- let raw = hit?.value;
244
- if (!raw) {
245
- raw = envValue(ref);
246
- }
247
- if (!raw) {
248
- json(res, 404, { error: { code: 'not-found', message: `dsh-key-rotation: secret for "${ref}" not found` } });
249
- return;
250
- }
251
- const plain = decryptSecret(raw);
252
- json(res, 200, { ref, plaintext: plain, tail: keyTail(plain) });
253
- } catch (err) {
254
- json(res, 500, { error: { code: 'reveal-failed', message: String(err?.message ?? err) } });
255
- }
235
+ // Disallow plaintext key reveal without authorized session (#543)
236
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: plaintext key extraction via GET is forbidden' } });
256
237
  return;
257
238
  }
258
239
  if (!credentialsService || typeof credentialsService.set !== 'function') {
@@ -464,19 +445,24 @@ ctx.effect(() => ctx.webServer.register({
464
445
  json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: auto-import is local-only' } });
465
446
  return;
466
447
  }
467
- let body = {};
468
- try { body = await readJson(req); } catch (_) { body = {}; }
448
+ let body;
449
+ try {
450
+ body = await readJson(req);
451
+ } catch (e) {
452
+ json(res, 400, { error: { code: 'bad-request', message: e.message || 'invalid request body' } });
453
+ return;
454
+ }
455
+ const hasRefs = Array.isArray(body?.refs) && body.refs.length > 0;
456
+ if (!hasRefs && body?.all !== true) {
457
+ json(res, 400, { error: { code: 'bad-request', message: 'auto-import requires explicit "refs" array or "all: true"' } });
458
+ return;
459
+ }
469
460
 
470
461
  try {
471
462
  const runtime = deps.buildRuntime?.() ?? {};
472
463
  const discovered = await discoverUnconfiguredKeys(ctx, runtime.pools || []);
473
- const targetRefs = Array.isArray(body?.refs) && body.refs.length > 0
474
- ? new Set(body.refs)
475
- : null;
476
-
477
- const toImport = targetRefs
478
- ? discovered.filter((d) => targetRefs.has(d.ref))
479
- : discovered;
464
+ const targetRefs = hasRefs ? new Set(body.refs) : null;
465
+ const toImport = targetRefs ? discovered.filter((d) => targetRefs.has(d.ref)) : discovered;
480
466
 
481
467
  if (toImport.length === 0) {
482
468
  json(res, 200, { ok: true, importedCount: 0, message: 'No unassigned keys to import' });
package/lib/ops-test.js CHANGED
@@ -171,7 +171,15 @@ ctx.effect(() => ctx.webServer.register({
171
171
  const ev = envValue(ref);
172
172
  if (ev !== undefined) rawValue = ev;
173
173
  }
174
- let effectiveValue = rawValue ? decryptSecret(rawValue) : rawValue;
174
+ if (typeof rawValue === 'string' && rawValue.startsWith('enc:v1:')) {
175
+ const dec = decryptSecret(rawValue);
176
+ if (!dec || dec === rawValue || dec.startsWith('enc:v1:')) {
177
+ results.push({ provider, ref, keyTail: '', ok: false, code: 'decryption-failed', message: 'failed to decrypt credential', latencyMs: 0 });
178
+ continue;
179
+ }
180
+ rawValue = dec;
181
+ }
182
+ let effectiveValue = rawValue;
175
183
  const tail = effectiveValue ? keyTail(effectiveValue) : '';
176
184
  if (!effectiveValue) {
177
185
  results.push({ provider, ref, keyTail: tail, ok: false, code: 'no-credential', latencyMs: 0 });
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,52 @@ 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 === undefined) {
59
- // Official Desktop build forwards requests via forwardWebRequest which strips Origin (#465 / GitHub #22).
60
- // Real browsers unconditionally send Origin on cross-origin mutations, so an absent Origin cannot originate
61
- // from a malicious website. Peer address and Host header are already verified loopback above.
66
+ const referer = req?.headers?.referer;
67
+
68
+ if (origin !== undefined) {
69
+ if (!origin) return false; // Explicit empty string or null-like falsy
70
+ try {
71
+ const originUrl = new URL(origin);
72
+ // Official Desktop build shell origin (#22, #465, #516)
73
+ if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
74
+ return true;
75
+ }
76
+ if (originUrl.host !== hostHeader) return false;
77
+ const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
78
+ return ['http:', 'https:'].includes(originUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
79
+ } catch (_) {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ // Absent Origin:
85
+ const method = String(req?.method || 'GET').toUpperCase();
86
+ const mutating = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
87
+ if (!mutating) {
88
+ // Browsers omit Origin on same-origin read navigations (GET/HEAD/OPTIONS).
62
89
  return true;
63
90
  }
64
- if (!origin) return false; // Explicit empty string or null-like falsy
65
- try {
66
- const originUrl = new URL(origin);
67
- // Official Desktop build shell origin
68
- if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app')) {
69
- return true;
91
+
92
+ // Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465) or valid Referer.
93
+ if (isDesktopShellRequest(req)) {
94
+ return true;
95
+ }
96
+
97
+ if (referer) {
98
+ try {
99
+ const refUrl = new URL(referer);
100
+ if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
101
+ return true;
102
+ }
103
+ if (refUrl.host !== hostHeader) return false;
104
+ const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
105
+ return ['http:', 'https:'].includes(refUrl.protocol) && (isLoopbackAddress(hostname) || hostname === 'localhost');
106
+ } catch (_) {
107
+ return false;
70
108
  }
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
109
  }
110
+
111
+ return false;
77
112
  }
78
113
 
79
114
  /** Regex matching error phrases that warrant switching to the next key. */
@@ -7,7 +7,7 @@ const TRACE_BUFFER_LIMIT = 100;
7
7
  const traces = [];
8
8
 
9
9
  /**
10
- * Redacts any sensitive tokens from string / object representation.
10
+ * Redacts any sensitive tokens from string / object representation (#537).
11
11
  */
12
12
  export function redactSecret(val) {
13
13
  if (typeof val !== 'string') return val;
@@ -19,6 +19,17 @@ export function redactSecret(val) {
19
19
  return val;
20
20
  }
21
21
 
22
+ /**
23
+ * Aggressively sanitizes arbitrary error/details strings to prevent token/secret leaks in traces (#537).
24
+ */
25
+ export function sanitizeTraceDetails(str) {
26
+ if (typeof str !== 'string') return '';
27
+ return str
28
+ .replace(/(?:sk-|key-|Bearer\s+|ghp_|token[=:\s]+|api[_-]?key[=:\s]+)[a-zA-Z0-9_\-\.]{8,}/gi, '[REDACTED_SECRET]')
29
+ .replace(/https?:\/\/[^\s/$.?#].[^\s]*[?&](?:token|key|api_key|secret|password)=[^\s&]+/gi, '[REDACTED_URL]')
30
+ .slice(0, 300);
31
+ }
32
+
22
33
  export function createSelectionTrace(requestId, options = {}) {
23
34
  const reqId = requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
24
35
  const startTime = Date.now();
@@ -43,7 +54,7 @@ export function createSelectionTrace(requestId, options = {}) {
43
54
  ref: redactSecret(ref),
44
55
  decision,
45
56
  reasonCode,
46
- details: String(details).slice(0, 300),
57
+ details: sanitizeTraceDetails(details),
47
58
  });
48
59
  },
49
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.71",
3
+ "version": "0.8.73",
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": [