@goodandready/dsh-key-rotation 0.8.23 → 0.8.25

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,43 @@
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.25 - 2026-09-27
7
+
8
+ ### Fixed & Hardened
9
+ - **Sandbox cache HTTP method validation (#357)**:
10
+ - `lib/ops-test.js`: enforced strict `GET` method check on `/dsh-key-rotation/sandbox-cache`. Non-GET requests (`POST`, `PUT`, `DELETE`) now consistently return HTTP 405 Method Not Allowed (`{ error: { code: 'method', message: 'GET only' } }`).
11
+ - **Webhook action security and timeout hardening (#358)**:
12
+ - `lib/ops-webhook.js`: converted authorization bearer token validation to constant-time comparison via `crypto.timingSafeEqual`, preventing timing side-channel attacks on `webhookActionToken`.
13
+ - Added strict Telegram bot token format validation (`TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/`).
14
+ - Added 10-second timeout guard (`signal: AbortSignal.timeout(10000)`) on outgoing Telegram API `setWebhook` requests to eliminate socket hang risks.
15
+ - **Dead code removal & unexported private symbols (#359)**:
16
+ - Removed unimported orphaned module `lib/bounded-map.js` and removed its standalone test cases.
17
+ - Eliminated dead exports in internal modules: unexported `CONCURRENCY_DEFAULT_LIMIT`, `CONCURRENCY_STALE_LOCK_MS` in `lib/concurrency.js`, `MAX_USAGE_RETENTION_DAYS` in `lib/usage-report.js`, `WEBHOOK_*` constants in `lib/webhook.js`, `assertJson` and `SECRET_FIELDS` in `lib/settings-write.js`, and `isNewerVersion` in `lib/plugin-updater.js`.
18
+ - **Documentation contract alignment (#360)**:
19
+ - `docs/design/DESIGN.md`: added mandatory sections «Политика маршрутов» (complete 12-route method, origin, and authorization matrix) and «Что публикуется» (npm files, exclusions, and verification timestamp) per `dsh-documentation-standard`.
20
+ - Corrected webhook endpoint URI in DESIGN.md to `/dsh-key-rotation/webhook-action`.
21
+ - **Canonical scoped package name in module export (#361)**:
22
+ - `lib/index.js`: unified `export const name = '@goodandready/dsh-key-rotation';` across all four package identity checkpoints (`package.json`, `cordis.patch.yml`, `lib/client.js`, and `lib/index.js`), resolving preflight check failure.
23
+
24
+ ## 0.8.24 - 2026-09-25
25
+
26
+ ### Security Hardening
27
+ - **Bridge trust guard fail-closed enforcement (#353)**:
28
+ - `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`.
29
+ - `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`.
30
+ - Added comprehensive test suite `test/route-guard-fail-closed.test.mjs` verifying missing-header and origin-mismatch rejections across all 12 guarded route surfaces.
31
+ - **SSRF protection on provider pool import route (#354)**:
32
+ - `lib/safe-fetch.js` & `lib/ops-keys.js`: added multi-layered SSRF guard for `/dsh-key-rotation/import`.
33
+ - Protocol constraint: strictly `https:`.
34
+ - 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.
35
+ - Redirect protection: uses `redirect: 'manual'` and recursively validates redirect target URLs and resolved IPs up to 5 hops (`MAX_REDIRECTS`).
36
+ - Response size bounding: caps response body stream to 1 MB (`MAX_IMPORT_BYTES = 1048576`) before parsing JSON, preventing memory exhaustion attacks.
37
+ - Added comprehensive test suite `test/ssrf-protection.test.mjs`.
38
+ - **Short credential masking in keyTail (#355)**:
39
+ - `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.
40
+ - Exported `KEY_TAIL_PLACEHOLDER` and `KEY_TAIL_CHARS`.
41
+ - Added boundary tests covering lengths 0 through 8 in `test/key-tail.test.js`.
42
+
6
43
  ## 0.8.23 - 2026-09-23
7
44
 
8
45
  ### Fixed
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
 
@@ -1,7 +1,7 @@
1
1
  // concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
2
2
 
3
- export const CONCURRENCY_DEFAULT_LIMIT = 0;
4
- export const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
3
+ const CONCURRENCY_DEFAULT_LIMIT = 0;
4
+ const CONCURRENCY_STALE_LOCK_MS = 5 * 60 * 1000;
5
5
 
6
6
  export class ConcurrencyTracker {
7
7
  constructor(opts) {
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,
@@ -37,9 +37,9 @@ import { checkBudgetAndHealthAlerts } from './budget-monitor.js';
37
37
  import { buildPoolItem, cleanupRemovedProviders } from './pool-builder.js';
38
38
  import { setupIdleHealEffect, setupAutoUnbreakEffect, setupPersistence } from './lifecycle.js';
39
39
 
40
- export const name = 'dsh-key-rotation';
40
+ export const name = '@goodandready/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/ops-test.js CHANGED
@@ -90,6 +90,7 @@ ctx.effect(() => ctx.webServer.register({
90
90
  kind: 'exact',
91
91
  path: SANDBOX_CACHE_PATH,
92
92
  handler: (req, res) => {
93
+ if (req.method !== 'GET') { json(res, 405, { error: { code: 'method', message: 'GET only' } }); return; }
93
94
  if (!isTrustedBridgeRequest(req)) { json(res, 403, { error: { code: 'forbidden', message: 'dsh-key-rotation: cache is local-only' } }); return; }
94
95
  const cacheSnap = typeof lastTestCache?.snapshot === 'function'
95
96
  ? lastTestCache.snapshot()
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  // lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js).
2
3
  import {
3
4
  json,
@@ -26,7 +27,11 @@ ctx.effect(() => ctx.webServer.register({
26
27
  const expected = runtime.webhookActionToken;
27
28
  if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
28
29
  const auth = String(req.headers.authorization ?? '');
29
- if (auth !== `Bearer ${expected}`) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
+ const expectedAuth = `Bearer ${expected}`;
31
+ const authBuf = Buffer.from(auth);
32
+ const expBuf = Buffer.from(expectedAuth);
33
+ const tokenMatch = authBuf.length === expBuf.length && crypto.timingSafeEqual(authBuf, expBuf);
34
+ if (!tokenMatch) { json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } }); return; }
30
35
  let body;
31
36
  try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
32
37
  // Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
@@ -45,12 +50,18 @@ ctx.effect(() => ctx.webServer.register({
45
50
  if (typeof body?.setWebhook === 'object' && body.setWebhook) {
46
51
  const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
47
52
  if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
53
+ const TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/;
54
+ if (!TELEGRAM_BOT_TOKEN_RE.test(botToken)) {
55
+ json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
56
+ return;
57
+ }
48
58
  // derive the public URL from request headers; explicit URL wins
49
59
  const url = typeof body.setWebhook.url === 'string' && body.setWebhook.url ? body.setWebhook.url : `https://${String(req.headers.host ?? '')}/dsh-key-rotation/webhook-action`;
50
60
  try {
51
61
  const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
52
62
  method: 'POST', headers: { 'content-type': 'application/json' },
53
63
  body: JSON.stringify({ url, allowed_updates: ['callback_query'] }),
64
+ signal: AbortSignal.timeout(10000),
54
65
  });
55
66
  const hookData = await hookRes.json().catch(() => ({}));
56
67
  json(res, 200, { ok: hookRes.ok, url, telegram: hookData });
@@ -135,7 +135,7 @@ function comparePrerelease(left, right) {
135
135
  return 0
136
136
  }
137
137
 
138
- export function isNewerVersion(currentValue, candidateValue) {
138
+ function isNewerVersion(currentValue, candidateValue) {
139
139
  const current = parseSemver(currentValue)
140
140
  const candidate = parseSemver(candidateValue)
141
141
  if (current === undefined || candidate === undefined) return false
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
+ }
@@ -1,6 +1,6 @@
1
1
  // Settings writes from redacted browser views must never replace an entire
2
2
  // namespace. Host-side mutate is atomic and rejects conflicts on both core eras.
3
- export const SECRET_FIELDS = new Set(['webhookActionToken']);
3
+ const SECRET_FIELDS = new Set(['webhookActionToken']);
4
4
  const UNSAFE_FIELDS = new Set(['__proto__', 'prototype', 'constructor']);
5
5
 
6
6
  export function plainObject(value) {
@@ -9,7 +9,7 @@ export function plainObject(value) {
9
9
  return proto === Object.prototype || proto === null;
10
10
  }
11
11
 
12
- export function assertJson(value, depth = 0) {
12
+ function assertJson(value, depth = 0) {
13
13
  if (depth > 40) throw new TypeError('Configuration is nested too deeply');
14
14
  if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
15
15
  if (typeof value === 'number' && Number.isFinite(value)) return;
@@ -1,6 +1,6 @@
1
1
  // lib/usage-report.js - #209 usage report rows from pool state + 30-day compaction (#11). Pure helpers.
2
2
  const DAY_MS = 86400000;
3
- export const MAX_USAGE_RETENTION_DAYS = 30;
3
+ const MAX_USAGE_RETENTION_DAYS = 30;
4
4
 
5
5
  /**
6
6
  * Build per-key usage rows for the last `days` ISO days (default 7).
package/lib/webhook.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { bestEffort } from './best-effort.js';
2
2
  // lib/webhook.js — webhook sender with throttle, interactive actions + alert digest debouncer (#199, #10).
3
- export const WEBHOOK_TIMEOUT_MS = 5000;
4
- export const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
- export const WEBHOOK_RETRY_DELAY_MS = 2000;
3
+ const WEBHOOK_TIMEOUT_MS = 5000;
4
+ const WEBHOOK_MIN_INTERVAL_MS = 1000;
5
+ const WEBHOOK_RETRY_DELAY_MS = 2000;
6
6
 
7
7
  /**
8
8
  * Detect platform from webhook URL (#199).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.23",
3
+ "version": "0.8.25",
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": [
@@ -1,65 +0,0 @@
1
- // lib/bounded-map.js — Map with max size + optional TTL (#262).
2
-
3
- export class BoundedMap {
4
- /** @param {{ max?: number, ttlMs?: number|null }} opts */
5
- constructor({ max = 1000, ttlMs = null } = {}) {
6
- if (!Number.isFinite(max) || max < 1) throw new Error('BoundedMap: max must be >= 1');
7
- this.max = max;
8
- this.ttlMs = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : null;
9
- this._m = new Map(); // insertion order = LRU order when we re-insert on get/set
10
- }
11
-
12
- get size() { return this._m.size; }
13
-
14
- _expired(entry, now) {
15
- if (!this.ttlMs || !entry) return false;
16
- return now - entry.at >= this.ttlMs;
17
- }
18
-
19
- get(key, now = Date.now()) {
20
- const e = this._m.get(key);
21
- if (!e) return undefined;
22
- if (this._expired(e, now)) {
23
- this._m.delete(key);
24
- return undefined;
25
- }
26
- // refresh LRU
27
- this._m.delete(key);
28
- this._m.set(key, e);
29
- return e.v;
30
- }
31
-
32
- set(key, value, now = Date.now()) {
33
- if (this._m.has(key)) this._m.delete(key);
34
- this._m.set(key, { v: value, at: now });
35
- while (this._m.size > this.max) {
36
- const oldest = this._m.keys().next().value;
37
- this._m.delete(oldest);
38
- }
39
- return this;
40
- }
41
-
42
- has(key, now = Date.now()) {
43
- return this.get(key, now) !== undefined;
44
- }
45
-
46
- delete(key) { return this._m.delete(key); }
47
- clear() { this._m.clear(); }
48
-
49
- *entries(now = Date.now()) {
50
- for (const [k, e] of [...this._m.entries()]) {
51
- if (this._expired(e, now)) { this._m.delete(k); continue; }
52
- yield [k, e.v];
53
- }
54
- }
55
-
56
- *keys(now = Date.now()) {
57
- for (const [k] of this.entries(now)) yield k;
58
- }
59
-
60
- toObject(now = Date.now()) {
61
- const out = {};
62
- for (const [k, v] of this.entries(now)) out[k] = v;
63
- return out;
64
- }
65
- }