@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 +37 -0
- package/README.md +3 -2
- package/README.ru.md +3 -2
- package/README.zh.md +3 -2
- package/lib/concurrency.js +2 -2
- package/lib/index.js +3 -3
- package/lib/ops-keys.js +7 -4
- package/lib/ops-status.js +2 -4
- package/lib/ops-test.js +1 -0
- package/lib/ops-webhook.js +12 -1
- package/lib/plugin-updater.js +1 -1
- package/lib/pool.js +8 -5
- package/lib/safe-fetch.js +221 -0
- package/lib/settings-write.js +2 -2
- package/lib/usage-report.js +1 -1
- package/lib/webhook.js +3 -3
- package/package.json +1 -1
- package/lib/bounded-map.js +0 -65
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 (`
|
|
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/concurrency.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// concurrency.js — per-key in-flight counter + least-connections picking (issue #193).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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/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()
|
package/lib/ops-webhook.js
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/lib/plugin-updater.js
CHANGED
|
@@ -135,7 +135,7 @@ function comparePrerelease(left, right) {
|
|
|
135
135
|
return 0
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
|
|
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
|
-
/**
|
|
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/lib/settings-write.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/lib/usage-report.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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": [
|
package/lib/bounded-map.js
DELETED
|
@@ -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
|
-
}
|