@goodandready/dsh-key-rotation 0.8.22 → 0.8.24

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
@@ -3,6 +3,33 @@
3
3
  All notable changes to `@goodandready/dsh-key-rotation` are documented here.
4
4
  User-facing feature notes also appear in README (en is source of truth).
5
5
 
6
+ ## 0.8.24 - 2026-09-25
7
+
8
+ ### Security Hardening
9
+ - **Bridge trust guard fail-closed enforcement (#353)**:
10
+ - `lib/pool.js`: `isTrustedBridgeRequest` is now strictly fail-closed. Rejects any request missing the `Origin` header (`origin` is required). Validates that `Origin` host strictly matches the `Host` header and that hostname is loopback (`127.0.0.1`, `::1`, `localhost`). Rejects requests with `Sec-Fetch-Site: cross-site`.
11
+ - `lib/ops-status.js`: removed loopback fallback logic in `/health` (lines 170-175) that previously bypassed the centralized predicate when `Sec-Fetch-Site` was not `cross-site`. All guarded routes (`status`, `health`, `key`, `reset`, `import`, `test`, `sandbox-cache`, `usage`, `snapshot`, `config`) now consistently route through `isTrustedBridgeRequest`.
12
+ - Added comprehensive test suite `test/route-guard-fail-closed.test.mjs` verifying missing-header and origin-mismatch rejections across all 12 guarded route surfaces.
13
+ - **SSRF protection on provider pool import route (#354)**:
14
+ - `lib/safe-fetch.js` & `lib/ops-keys.js`: added multi-layered SSRF guard for `/dsh-key-rotation/import`.
15
+ - Protocol constraint: strictly `https:`.
16
+ - DNS / IP classification: resolves and rejects private (RFC 1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), unique-local (`fc00::/7`), multicast (`224.0.0.0/4`, `ff00::/8`), carrier-grade NAT (`100.64.0.0/10`), IPv4-mapped IPv6, and reserved IP literals and DNS resolutions. Guards against DNS rebinding attacks.
17
+ - Redirect protection: uses `redirect: 'manual'` and recursively validates redirect target URLs and resolved IPs up to 5 hops (`MAX_REDIRECTS`).
18
+ - Response size bounding: caps response body stream to 1 MB (`MAX_IMPORT_BYTES = 1048576`) before parsing JSON, preventing memory exhaustion attacks.
19
+ - Added comprehensive test suite `test/ssrf-protection.test.mjs`.
20
+ - **Short credential masking in keyTail (#355)**:
21
+ - `lib/pool.js`: `keyTail` now returns a fixed placeholder `***` (`KEY_TAIL_PLACEHOLDER`) for credentials with length <= 5 (`KEY_TAIL_CHARS`), preventing complete credential disclosure through operational telemetry (`status`, `test`). Returns empty string for empty or non-string inputs.
22
+ - Exported `KEY_TAIL_PLACEHOLDER` and `KEY_TAIL_CHARS`.
23
+ - Added boundary tests covering lengths 0 through 8 in `test/key-tail.test.js`.
24
+
25
+ ## 0.8.23 - 2026-09-23
26
+
27
+ ### Fixed
28
+ - **Fix settings cache invalidation on Cordis traceable Proxies (#350)**:
29
+ - Removed `settings !== owner` reference equality check in `createProviderProfilesReader` (`lib/config-compat.js`). In Cordis, `ctx.get('settings')` creates and returns a fresh traceable `Proxy` wrapper on every single invocation, which was constantly triggering `dirty = true` and invalidating the cache per-request.
30
+ - Subscribed to `internal/service` event (for service name `'settings'`) alongside `settings/document-updated` to safely invalidate cache upon service replacement without comparing proxy identities.
31
+ - Added regression test suite verifying that when `ctx.get('settings')` returns a new `Proxy` instance on every call, 100 subsequent invocations of `buildRuntime()` or `readProfiles()` execute strictly 1 call to `settings.get()`.
32
+
6
33
  ## 0.8.22 - 2026-09-23
7
34
 
8
35
  ### Performance
package/README.md CHANGED
@@ -205,8 +205,9 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
205
205
 
206
206
  * **Zero Plaintext Secrets in Plugin Config**: Configuration files store only environment variable reference names (e.g. `MY_PROVIDER_API_KEY`).
207
207
  * **Secure Vault Storage**: Actual secret values reside securely in `$DSH_HOME/.credentials.yaml` managed by the DSH `Credentials` service.
208
- * **5-Character Masking (`keyTail`)**: Full secret values are never sent to the client browser; only the trailing 5 characters are exposed for visual identification.
209
- * **Loopback & Same-Origin Fencing**: Administrative endpoints (`GET /status`, `PUT /key`, `POST /reset`, `POST /test-matrix`) strictly enforce loopback origin checks (`isTrustedBridgeRequest`).
208
+ * **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.
209
+ * **Fail-Closed Loopback & Same-Origin Fencing**: Administrative endpoints strictly enforce loopback origin checks (`isTrustedBridgeRequest`), requiring valid `Origin` headers, matching `Host` headers, and rejecting `cross-site` or non-loopback requests without fallbacks.
210
+ * **SSRF Guard on Import Route**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses against private and reserved ranges (RFC 1918, loopback, link-local, multicast, CGNAT), verifies redirect targets, and caps response payload size to 1 MB.
210
211
 
211
212
  ---
212
213
 
package/README.ru.md CHANGED
@@ -192,8 +192,9 @@ graph LR
192
192
 
193
193
  * **Никаких открытых секретов в конфигурации**: В настройках плагина хранятся только имена переменных окружения (например, `PROVIDER_API_KEY`).
194
194
  * **Защищённое хранилище хоста**: Реальные значения ключей сохраняются в `$DSH_HOME/.credentials.yaml` сервисом `Credentials`.
195
- * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия.
196
- * **Ограничение Loopback**: Все управляющие маршруты (`GET /status`, `PUT /key`, `POST /reset`, `POST /test-matrix`) строго проверяют loopback-происхождение запроса (`isTrustedBridgeRequest`).
195
+ * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия. Для коротких ключей (длиной <= 5 символов) возвращается безопасная маскировочная заглушка `***`, исключающая раскрытие ключа целиком.
196
+ * **Fail-Closed изоляция Loopback и Same-Origin**: Все управляющие эндпоинты строго проверяют происхождение через `isTrustedBridgeRequest`, требуя обязательный заголовок `Origin`, соответствие `Host` и отклоняя запросы `cross-site` и не-loopback без каких-либо исключений.
197
+ * **SSRF-защита маршрута импорта**: Маршрут импорта пулов провайдеров разрешает исключительно HTTPS, валидирует все резолвящиеся IP-адреса с блокировкой приватных и зарезервированных диапазонов (RFC 1918, loopback, link-local, multicast, CGNAT), проверяет адреса редиректов и ограничивает размер ответа 1 МБ.
197
198
 
198
199
  ---
199
200
 
package/README.zh.md CHANGED
@@ -184,8 +184,9 @@ graph LR
184
184
 
185
185
  * **配置零明文**:插件配置仅保存环境变量引用名(如 `MY_PROVIDER_API_KEY`)。
186
186
  * **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
187
- * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。
188
- * **环回安全隔离**:管理接口严格限制来自本地同源请求 (`isTrustedBridgeRequest`)。
187
+ * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。对于长度 <= 5 的短密钥,返回统一脱敏占位符 (`***`),防止凭证完整泄露。
188
+ * **Fail-Closed 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 验证本地同源请求,强制要求有效 `Origin` 和 `Host` 匹配,无条件拒绝跨站 (`cross-site`) 与非环回调用。
189
+ * **导入接口 SSRF 防护**:远程提供商配置导入严格仅支持 HTTPS,解析并拦截所有私有/保留 IP 地址(RFC 1918、环回、链路本地、多播、CGNAT),对重定向目标进行全程验证,并将响应体上限严格控制在 1 MB 以内。
189
190
 
190
191
  ---
191
192
 
@@ -45,36 +45,45 @@ export function createConfigReader(config, ctx) {
45
45
  // Modern settings owns descriptors rather than the removed get(namespace) API.
46
46
  // Cache by document invalidation; clone-producing describe() and tree-materializing
47
47
  // settings.get() on 0.1.7+ must not execute on every request.
48
+ // Invalidation occurs strictly on settings/document-updated or internal/service ('settings').
49
+ // Never compare ctx.get('settings') by reference: Cordis wraps services in fresh
50
+ // traceable Proxies on every get() call, which would invalidate the cache on every request.
48
51
  export function createProviderProfilesReader(ctx, namespace) {
49
- let dirty = true, held = null, owner;
52
+ let dirty = true, held = null;
50
53
  const markDirty = () => { dirty = true; };
51
54
  if (typeof ctx?.effect === 'function') {
52
55
  ctx.effect(() => ctx.on('settings/document-updated', markDirty));
56
+ ctx.effect(() => ctx.on('internal/service', (name) => {
57
+ if (!name || name === 'settings') markDirty();
58
+ }));
53
59
  } else if (typeof ctx?.on === 'function') {
54
60
  ctx.on('settings/document-updated', markDirty);
61
+ ctx.on('internal/service', (name) => {
62
+ if (!name || name === 'settings') markDirty();
63
+ });
55
64
  }
56
65
 
57
66
  return () => {
67
+ if (!dirty) {
68
+ return held;
69
+ }
58
70
  const settings = ctx?.get?.('settings');
59
- if (settings !== owner) { owner = settings; dirty = true; }
60
71
  if (!settings) return null;
61
- if (dirty) {
62
- if (typeof settings.get === 'function') {
63
- try {
64
- held = settings.get(namespace)?.providers ?? null;
65
- dirty = false;
66
- } catch (_) {
67
- held = null;
68
- dirty = false;
69
- }
70
- } else {
71
- try {
72
- held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
73
- dirty = false;
74
- } catch (_) {
75
- held = null;
76
- dirty = false;
77
- }
72
+ if (typeof settings.get === 'function') {
73
+ try {
74
+ held = settings.get(namespace)?.providers ?? null;
75
+ dirty = false;
76
+ } catch (_) {
77
+ held = null;
78
+ dirty = false;
79
+ }
80
+ } else {
81
+ try {
82
+ held = settings.describe({ redactSecrets: true }).find(row => row.ns === namespace)?.value?.providers ?? null;
83
+ dirty = false;
84
+ } catch (_) {
85
+ held = null;
86
+ dirty = false;
78
87
  }
79
88
  }
80
89
  return held;
package/lib/index.js CHANGED
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
8
8
  import Schema from '@deepseek-ai/schemastery';
9
9
  import { createConfigReader, createProviderProfilesReader } from './config-compat.js';
10
10
  import {
11
- keyTail, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN,
11
+ keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, SWITCHABLE_MESSAGE_PATTERN,
12
12
  DEFAULT_SWITCH_CODES, isValidRef, pickNext, applyCooldown, recordFailure,
13
13
  recordSuccess, computeBackoff, envValue, sweepExpired, parseRetryAfter,
14
14
  computeHealthScore, extractRateLimit, isRateLimited, selectPool, isSwitchableError,
@@ -39,7 +39,7 @@ import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from '.
39
39
 
40
40
  export const name = 'dsh-key-rotation';
41
41
  export const inject = ['llm', 'webServer', 'settings', 'credentials'];
42
- export { keyTail, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
42
+ export { keyTail, KEY_TAIL_CHARS, KEY_TAIL_PLACEHOLDER, isLoopbackAddress, isTrustedBridgeRequest, DEFAULT_SWITCH_CODES, isSwitchableError, formatExhaustionMessage, getRuntime };
43
43
  export { notifySwitch, notifyExhaustion };
44
44
 
45
45
  const NS = 'dsh-key-rotation';
package/lib/ops-keys.js CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  readJson,
5
5
  NS,
6
6
  } from './http-bridge.js';
7
+ import { safeFetchJson } from './safe-fetch.js';
7
8
  import {
8
9
  isTrustedBridgeRequest,
9
10
  isValidRef,
@@ -168,9 +169,11 @@ ctx.effect(() => ctx.webServer.register({
168
169
  const url = typeof body?.url === 'string' ? body.url.trim() : '';
169
170
  if (!url || !url.startsWith('https://')) { json(res, 400, { error: { code: 'bad-url', message: 'dsh-key-rotation: only HTTPS URLs are allowed' } }); return; }
170
171
  try {
171
- const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
172
- if (!resp.ok) { json(res, 400, { error: { code: 'fetch-failed', message: 'dsh-key-rotation: fetch returned ' + resp.status } }); return; }
173
- const data = await resp.json();
172
+ const data = await safeFetchJson(url, {
173
+ signal: AbortSignal.timeout(10000),
174
+ fetchImpl: deps.fetchImpl ?? globalThis.fetch,
175
+ lookupImpl: deps.lookupImpl ?? undefined,
176
+ });
174
177
  validateProviders(data);
175
178
  const settings = ctx.get('settings');
176
179
  if (!settings) { json(res, 503, { error: { code: 'settings-rejected', message: 'dsh-key-rotation: no settings provider' } }); return; }
@@ -184,7 +187,7 @@ ctx.effect(() => ctx.webServer.register({
184
187
  await mutateSettings(settings, NS, [{ op: 'set', path: ['providers'], value: mergedArr }], body.expectedRevision ?? desc.revision);
185
188
  json(res, 200, { ok: true, providersImported: data.length, total: mergedArr.length });
186
189
  } catch (e) {
187
- const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError';
190
+ const isTimeout = e?.name === 'TimeoutError' || e?.name === 'AbortError' || e?.code === 'timeout';
188
191
  json(res, isTimeout ? 504 : e?.code === 'SETTINGS_CONFLICT' ? 409 : 400, { error: { code: isTimeout ? 'timeout' : 'import-failed', message: String(e?.message ?? e) } });
189
192
  }
190
193
  },
package/lib/ops-status.js CHANGED
@@ -167,11 +167,9 @@ ctx.effect(() => ctx.webServer.register({
167
167
  kind: 'exact',
168
168
  path: HEALTH_PATH,
169
169
  handler: async (req, res) => {
170
- if (!isTrustedBridgeRequest(req) && req.socket?.remoteAddress !== '127.0.0.1' && req.socket?.remoteAddress !== '::1') { } // allow same-origin already checked
171
170
  if (!isTrustedBridgeRequest(req)) {
172
- // also allow plain loopback without Origin
173
- if (!isLoopbackAddress(req.socket?.remoteAddress)) { res.writeHead(403); res.end(); return; }
174
- if (req.headers['sec-fetch-site'] === 'cross-site') { res.writeHead(403); res.end(); return; }
171
+ json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: health is local-only' } });
172
+ return;
175
173
  }
176
174
  if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
177
175
  const now = Date.now();
package/lib/pool.js CHANGED
@@ -5,10 +5,13 @@ import { bestEffort } from './best-effort.js';
5
5
  /** Number of characters shown in masked key tails. */
6
6
  export const KEY_TAIL_CHARS = 5;
7
7
 
8
- /** Mask key to show only the last KEY_TAIL_CHARS. */
8
+ /** Fixed placeholder for short credentials (len <= KEY_TAIL_CHARS) to prevent disclosure (#355). */
9
+ export const KEY_TAIL_PLACEHOLDER = '***';
10
+
11
+ /** Mask key to show only the last KEY_TAIL_CHARS; returns placeholder for short keys (#355). */
9
12
  export function keyTail(key) {
10
- if (typeof key !== 'string') return '';
11
- if (key.length <= KEY_TAIL_CHARS) return key;
13
+ if (typeof key !== 'string' || key.length === 0) return '';
14
+ if (key.length <= KEY_TAIL_CHARS) return KEY_TAIL_PLACEHOLDER;
12
15
  return key.slice(-KEY_TAIL_CHARS);
13
16
  }
14
17
 
@@ -20,12 +23,12 @@ export function isLoopbackAddress(ip) {
20
23
  return false;
21
24
  }
22
25
 
23
- /** Check if an incoming HTTP request is from a trusted local bridge origin. */
26
+ /** Check if an incoming HTTP request is from a trusted local bridge origin (#353 fail-closed). */
24
27
  export function isTrustedBridgeRequest(req) {
25
28
  const remoteAddress = req?.socket?.remoteAddress;
26
29
  if (!isLoopbackAddress(remoteAddress)) return false;
27
30
  const origin = req?.headers?.origin;
28
- if (!origin) return true;
31
+ if (!origin) return false;
29
32
  if (req?.headers?.['sec-fetch-site'] === 'cross-site') return false;
30
33
  const hostHeader = req?.headers?.host;
31
34
  if (!hostHeader) return false;
@@ -0,0 +1,221 @@
1
+ // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354).
2
+ import net from 'node:net';
3
+ import dns from 'node:dns/promises';
4
+
5
+ export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
6
+ export const MAX_REDIRECTS = 5;
7
+
8
+ /**
9
+ * Checks if an IPv4 or IPv6 address is private, loopback, link-local, multicast, or reserved.
10
+ * @param {string} ip
11
+ * @returns {boolean} true if private/reserved/loopback
12
+ */
13
+ export function isPrivateOrReservedIp(ip) {
14
+ if (!ip || typeof ip !== 'string') return true;
15
+ const kind = net.isIP(ip);
16
+ if (kind === 4) {
17
+ const parts = ip.split('.').map(Number);
18
+ if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return true;
19
+ const [a, b, c, d] = parts;
20
+ if (a === 0) return true; // 0.0.0.0/8 current network
21
+ if (a === 10) return true; // 10.0.0.0/8 private
22
+ if (a === 127) return true; // 127.0.0.0/8 loopback
23
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
24
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
25
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
26
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 carrier-grade NAT
27
+ if (a === 192 && b === 0 && c === 0) return true; // 192.0.0.0/24 IETF protocol
28
+ if (a === 192 && b === 0 && c === 2) return true; // 192.0.2.0/24 TEST-NET-1
29
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
30
+ if (a === 198 && b === 51 && c === 100) return true; // 198.51.100.0/24 TEST-NET-2
31
+ if (a === 203 && b === 0 && c === 113) return true; // 203.0.113.0/24 TEST-NET-3
32
+ if (a >= 224) return true; // 224.0.0.0/4 multicast & 240.0.0.0/4 reserved & broadcast
33
+ return false;
34
+ }
35
+ if (kind === 6) {
36
+ const norm = ip.toLowerCase();
37
+ if (norm === '::' || norm === '::1' || norm === '0:0:0:0:0:0:0:0' || norm === '0:0:0:0:0:0:0:1') return true;
38
+ if (norm.startsWith('::ffff:') || norm.startsWith('0:0:0:0:0:ffff:')) {
39
+ const last = norm.split(':').pop();
40
+ if (net.isIP(last) === 4) return isPrivateOrReservedIp(last);
41
+ }
42
+ if (/^fe[89ab][0-9a-f]:/i.test(norm)) return true; // fe80::/10 link-local
43
+ if (/^f[cd][0-9a-f]{2}:/i.test(norm)) return true; // fc00::/7 unique local
44
+ if (/^ff[0-9a-f]{2}:/i.test(norm)) return true; // ff00::/8 multicast
45
+ if (/^(?:00)?2001:(?:0)?db8:/i.test(norm)) return true; // 2001:db8::/32 documentation
46
+ if (/^(?:00)?100:/i.test(norm)) return true; // 100::/64 discard
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /**
53
+ * Validates a target URL and its hostname/IP against SSRF.
54
+ * @param {string} urlString
55
+ * @param {object} options
56
+ */
57
+ export async function assertSafeUrl(urlString, { lookupImpl = dns.lookup } = {}) {
58
+ let urlObj;
59
+ try {
60
+ urlObj = new URL(urlString);
61
+ } catch {
62
+ const err = new Error('dsh-key-rotation: invalid URL');
63
+ err.code = 'INVALID_URL';
64
+ throw err;
65
+ }
66
+
67
+ if (urlObj.protocol !== 'https:') {
68
+ const err = new Error('dsh-key-rotation: only HTTPS URLs are allowed');
69
+ err.code = 'DISALLOWED_PROTOCOL';
70
+ throw err;
71
+ }
72
+
73
+ const hostname = urlObj.hostname.replace(/^\[|\]$/g, '');
74
+ if (!hostname) {
75
+ const err = new Error('dsh-key-rotation: missing hostname');
76
+ err.code = 'INVALID_HOST';
77
+ throw err;
78
+ }
79
+
80
+ if (net.isIP(hostname)) {
81
+ if (isPrivateOrReservedIp(hostname)) {
82
+ const err = new Error(`dsh-key-rotation: target IP ${hostname} is private or reserved`);
83
+ err.code = 'SSRF_BLOCKED';
84
+ throw err;
85
+ }
86
+ return urlObj;
87
+ }
88
+
89
+ let records;
90
+ try {
91
+ records = await lookupImpl(hostname, { all: true });
92
+ } catch (dnsErr) {
93
+ const err = new Error(`dsh-key-rotation: DNS resolution failed for ${hostname}: ${dnsErr.message}`);
94
+ err.code = 'DNS_FAILED';
95
+ throw err;
96
+ }
97
+
98
+ if (!records || records.length === 0) {
99
+ const err = new Error(`dsh-key-rotation: no DNS records found for ${hostname}`);
100
+ err.code = 'DNS_FAILED';
101
+ throw err;
102
+ }
103
+
104
+ for (const rec of records) {
105
+ const addr = typeof rec === 'string' ? rec : rec.address;
106
+ if (isPrivateOrReservedIp(addr)) {
107
+ const err = new Error(`dsh-key-rotation: host ${hostname} resolves to private or reserved IP ${addr}`);
108
+ err.code = 'SSRF_BLOCKED';
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ return urlObj;
114
+ }
115
+
116
+ /**
117
+ * Reads a response safely, capping byte size to maxBytes before parsing JSON.
118
+ * @param {Response} resp
119
+ * @param {number} maxBytes
120
+ * @returns {Promise<any>}
121
+ */
122
+ export async function readBoundedJson(resp, maxBytes = MAX_IMPORT_BYTES) {
123
+ const clHeader = resp.headers?.get?.('content-length');
124
+ if (clHeader) {
125
+ const len = Number(clHeader);
126
+ if (!isNaN(len) && len > maxBytes) {
127
+ const err = new Error(`dsh-key-rotation: response exceeds maximum size of ${maxBytes} bytes`);
128
+ err.code = 'PAYLOAD_TOO_LARGE';
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ if (resp.body && typeof resp.body.getReader === 'function') {
134
+ const reader = resp.body.getReader();
135
+ let total = 0;
136
+ const chunks = [];
137
+ try {
138
+ while (true) {
139
+ const { done, value } = await reader.read();
140
+ if (done) break;
141
+ total += value.length;
142
+ if (total > maxBytes) {
143
+ reader.cancel();
144
+ const err = new Error(`dsh-key-rotation: response exceeds maximum size of ${maxBytes} bytes`);
145
+ err.code = 'PAYLOAD_TOO_LARGE';
146
+ throw err;
147
+ }
148
+ chunks.push(value);
149
+ }
150
+ } catch (e) {
151
+ if (e.code === 'PAYLOAD_TOO_LARGE') throw e;
152
+ throw new Error(`dsh-key-rotation: error reading response body: ${e.message}`);
153
+ }
154
+ const fullBuffer = Buffer.concat(chunks);
155
+ return JSON.parse(fullBuffer.toString('utf8'));
156
+ }
157
+
158
+ if (typeof resp.text === 'function') {
159
+ const text = await resp.text();
160
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
161
+ const err = new Error(`dsh-key-rotation: response exceeds maximum size of ${maxBytes} bytes`);
162
+ err.code = 'PAYLOAD_TOO_LARGE';
163
+ throw err;
164
+ }
165
+ return JSON.parse(text);
166
+ }
167
+
168
+ if (typeof resp.json === 'function') {
169
+ return await resp.json();
170
+ }
171
+
172
+ throw new Error('dsh-key-rotation: unable to read response as JSON');
173
+ }
174
+
175
+ /**
176
+ * Fetches JSON from a URL with SSRF protection, redirect verification, and size bounding.
177
+ * @param {string} url
178
+ * @param {object} options
179
+ */
180
+ export async function safeFetchJson(url, {
181
+ signal,
182
+ fetchImpl = globalThis.fetch,
183
+ lookupImpl = dns.lookup,
184
+ maxBytes = MAX_IMPORT_BYTES,
185
+ redirectsLeft = MAX_REDIRECTS,
186
+ } = {}) {
187
+ let currentUrl = url;
188
+ while (true) {
189
+ await assertSafeUrl(currentUrl, { lookupImpl });
190
+ const resp = await fetchImpl(currentUrl, {
191
+ signal,
192
+ redirect: 'manual',
193
+ });
194
+
195
+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
196
+ if (redirectsLeft <= 0) {
197
+ const err = new Error('dsh-key-rotation: too many redirects');
198
+ err.code = 'TOO_MANY_REDIRECTS';
199
+ throw err;
200
+ }
201
+ redirectsLeft--;
202
+ const loc = resp.headers?.get?.('location');
203
+ if (!loc) {
204
+ const err = new Error('dsh-key-rotation: redirect missing Location header');
205
+ err.code = 'BAD_REDIRECT';
206
+ throw err;
207
+ }
208
+ currentUrl = new URL(loc, currentUrl).href;
209
+ continue;
210
+ }
211
+
212
+ if (!resp.ok) {
213
+ const err = new Error(`dsh-key-rotation: fetch returned ${resp.status}`);
214
+ err.code = 'FETCH_FAILED';
215
+ err.status = resp.status;
216
+ throw err;
217
+ }
218
+
219
+ return await readBoundedJson(resp, maxBytes);
220
+ }
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.22",
3
+ "version": "0.8.24",
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": [