@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 +27 -0
- package/README.md +3 -2
- package/README.ru.md +3 -2
- package/README.zh.md +3 -2
- package/lib/config-compat.js +28 -19
- package/lib/index.js +2 -2
- package/lib/ops-keys.js +7 -4
- package/lib/ops-status.js +2 -4
- package/lib/pool.js +8 -5
- package/lib/safe-fetch.js +221 -0
- package/package.json +1 -1
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 (`
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
package/lib/config-compat.js
CHANGED
|
@@ -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
|
|
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 (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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.
|
|
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": [
|