@goodandready/dsh-key-rotation 0.8.38 → 0.8.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.39 - 2026-09-30
4
+
5
+ ### Fixed
6
+ - **SSRF guard: IPv4-mapped IPv6 loopbacks & connect-time rebinding (#411)**:
7
+ - Blocked hex (`::ffff:7f00:1`, `::ffff:7f00:0001`), dotted (`::ffff:127.0.0.1`), IPv4-compatible (`::/96`), and NAT64 (`64:ff9b::/96`) loopback addresses via full 8-word IPv6 unpacking in `lib/safe-fetch.js`.
8
+ - Added connect-time DNS resolution enforcement using `undici.Agent` lookup hook to prevent TOCTOU DNS rebinding before establishing socket connections.
9
+ - **Fail-closed credential resolver on pool exhaustion (#406)**:
10
+ - Return fail-closed error `LOCAL_POOL_EXHAUSTED` instead of leaking unmanaged `original(ref)` when all credentials in a managed pool are exhausted or blocked by cooldown, expiry, RPM, or TPM.
11
+ - **Credential status inheritance & permanent auth revocation (#408)**:
12
+ - Model sub-pools inherit `paused`, `revoked`, and `expiresAt` credentials from their parent base provider pool.
13
+ - Runtime 401 permanent auth failure marks credential as revoked (`revokedRefs.add` and `failedUntil = Infinity`) across all associated pools sharing the ref.
14
+ - **Persistence of revocation state and spend counters (#409)**:
15
+ - `StatePersistence` serializes and restores `revokedRefs` (as a Set), `Infinity` failedUntil timestamps, and daily usage/cost aggregation maps (`costDays`, `costPerKey`, `usageDays`, `usageCounts`).
16
+ - **UI credential alignment and model weights on reordering (#412)**:
17
+ - Preserved `paused` and `revoked` flag alignment with credential references during reordering, deletion, and undo actions in the settings card.
18
+ - Model pool key reordering preserves model-specific weights and expiry mappings.
19
+
3
20
  ## 0.8.38 - 2026-09-30
4
21
 
5
22
  ### Added
package/README.md CHANGED
@@ -207,7 +207,9 @@ Access full visual management under **Settings → Key Rotation** or via the Hea
207
207
  * **Secure Vault Storage**: Actual secret values reside securely in `$DSH_HOME/.credentials.yaml` managed by the DSH `Credentials` service.
208
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
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
+ * **Fail-Closed Resolver on Pool Exhaustion**: When all credentials in a managed pool are exhausted, paused, expired, or blocked by RPM/TPM limits, the resolver fails closed with `LOCAL_POOL_EXHAUSTED` error rather than falling back to leaking unmanaged credentials.
211
+ * **SSRF Protection & Rebinding Guard**: Remote provider pool import strictly enforces HTTPS-only URLs, validates all resolved IP addresses including IPv4-mapped and IPv4-compatible IPv6 addresses (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), and enforces connect-time DNS validation via undici agent dispatchers to prevent TOCTOU DNS rebinding.
212
+ * **Cross-Pool Revocation & Inheritance**: Model pools automatically inherit pause, revoke, and expiry states from their base provider; runtime 401 permanent authentication failures revoke the credential across all shared pools immediately.
211
213
 
212
214
  ---
213
215
 
package/README.ru.md CHANGED
@@ -194,7 +194,9 @@ graph LR
194
194
  * **Защищённое хранилище хоста**: Реальные значения ключей сохраняются в `$DSH_HOME/.credentials.yaml` сервисом `Credentials`.
195
195
  * **Маскировка в браузере (5 символов)**: Браузер получает только последние 5 символов ключа для визуального отличия. Для коротких ключей (длиной <= 5 символов) возвращается безопасная маскировочная заглушка `***`, исключающая раскрытие ключа целиком.
196
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
+ * **Fail-Closed резолвер при исчерпании пула**: Если все ключи в пуле исчерпаны, приостановлены, истекли или заблокированы по RPM/TPM, резолвер завершается с ошибкой `LOCAL_POOL_EXHAUSTED`, исключая скрытую утечку исходного неконтролируемого ключа.
198
+ * **SSRF-защита и блокировка DNS Rebinding**: Маршрут импорта пулов провайдеров разрешает исключительно HTTPS, валидирует все IP-адреса, включая IPv4-mapped и IPv4-compatible IPv6 представления (`::ffff:127.0.0.1`, `::ffff:7f00:1`, `64:ff9b::/96`), и выполняет проверку адреса в момент соединения (connect-time DNS validation) через диспетчер undici для защиты от TOCTOU DNS rebinding.
199
+ * **Сквозной отзыв и наследование статусов**: Пул конкретной модели автоматически наследует флаги паузы, отзыва и срок действия базового провайдера; перманентный сбой аутентификации 401 немедленно отзывает ключ во всех связанных пулах.
198
200
 
199
201
  ---
200
202
 
package/README.zh.md CHANGED
@@ -186,7 +186,9 @@ graph LR
186
186
  * **宿主安全存储**:真实密钥持久化保存在 `$DSH_HOME/.credentials.yaml`。
187
187
  * **前台 5 字符脱敏**:前端仅展示密钥后 5 位字符进行视觉区分。对于长度 <= 5 的短密钥,返回统一脱敏占位符 (`***`),防止凭证完整泄露。
188
188
  * **Fail-Closed 环回同源安全隔离**:管理接口严格通过 `isTrustedBridgeRequest` 验证本地同源请求,强制要求有效 `Origin` 和 `Host` 匹配,无条件拒绝跨站 (`cross-site`) 与非环回调用。
189
- * **导入接口 SSRF 防护**:远程提供商配置导入严格仅支持 HTTPS,解析并拦截所有私有/保留 IP 地址(RFC 1918、环回、链路本地、多播、CGNAT),对重定向目标进行全程验证,并将响应体上限严格控制在 1 MB 以内。
189
+ * **凭证池耗尽 Fail-Closed 熔断**:当受管凭证池中所有密钥均被耗尽、暂停、过期或触发 RPM/TPM 限制时,解析器立即返回 `LOCAL_POOL_EXHAUSTED` 错误熔断,杜绝静默回退泄漏未受控原始密钥。
190
+ * **SSRF 与 DNS 重绑定防护**:远程配置导入严格仅支持 HTTPS,全面校验解析 IP 并拦截 IPv4-mapped/IPv4-compatible 等各类 IPv6 环回变体(如 `::ffff:127.0.0.1`、`::ffff:7f00:1`、`64:ff9b::/96`),并通过 undici 调度器执行连接时(connect-time)DNS 校验,从根本上防御 TOCTOU DNS 重绑定。
191
+ * **跨池吊销与状态继承**:模型子池自动继承基础提供商的暂停、吊销与过期时间;运行时 401 永久认证失败将即时在所有关联共享池中统一标记吊销。
190
192
 
191
193
  ---
192
194
 
package/lib/client.js CHANGED
@@ -938,7 +938,7 @@ window.__ModuleLoader__.load({
938
938
 
939
939
  function reorderKeys(entry, order) {
940
940
  const next = { ...entry, keys: order.map(index => entry.keys[index]) };
941
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
941
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
942
942
  if (Array.isArray(entry[field]) && entry[field].length) next[field] = order.map(index => entry[field][index] ?? fallback);
943
943
  }
944
944
  return next;
@@ -1213,7 +1213,7 @@ window.__ModuleLoader__.load({
1213
1213
  const entry = { ...providers[index] };
1214
1214
  const at = Math.min(u.kIndex, entry.keys.length);
1215
1215
  entry.keys = [...entry.keys]; entry.keys.splice(at, 0, u.key);
1216
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
1216
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
1217
1217
  if (u[field] !== undefined || entry[field]?.length) {
1218
1218
  const values = Array.from({ length: entry.keys.length - 1 }, (_, i) => entry[field]?.[i] ?? fallback);
1219
1219
  values.splice(at, 0, u[field] ?? fallback); entry[field] = values;
@@ -1563,7 +1563,7 @@ window.__ModuleLoader__.load({
1563
1563
  const allRefs = providers.flatMap((prov) => prov?.keys ?? []);
1564
1564
  entry.keys = [...(entry.keys ?? []), nextKeyRef(entry.provider, entry.keys, allRefs)];
1565
1565
  // keep weights aligned with keys (#215): new key gets default weight 1
1566
- for (const [field, fallback] of [['weights', 1], ['expiresAt', 0]]) {
1566
+ for (const [field, fallback] of [['weights', 1], ['expiresAt', 0], ['paused', false], ['revoked', false]]) {
1567
1567
  if (Array.isArray(entry[field]) && entry[field].length) entry[field] = Array.from({ length: entry.keys.length }, (_, i) => entry[field][i] ?? fallback);
1568
1568
  }
1569
1569
  providers[pIndex] = entry;
@@ -1574,7 +1574,8 @@ window.__ModuleLoader__.load({
1574
1574
  const entry = next[pIndex];
1575
1575
  if (!entry || !entry.keys[kIndex]) return cur;
1576
1576
  stashUndo({ type: 'key', provider: entry.provider, kIndex, key: entry.keys[kIndex],
1577
- weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex] });
1577
+ weights: entry.weights?.[kIndex], expiresAt: entry.expiresAt?.[kIndex],
1578
+ paused: entry.paused?.[kIndex], revoked: entry.revoked?.[kIndex] });
1578
1579
  next[pIndex] = reorderKeys(entry, entry.keys.map((_, index) => index).filter(index => index !== kIndex));
1579
1580
  return { ...cur, providers: next };
1580
1581
  });
@@ -1691,9 +1692,9 @@ window.__ModuleLoader__.load({
1691
1692
  const keys = Array.isArray(mp.keys) ? [...mp.keys] : [];
1692
1693
  const target = kIndex + delta;
1693
1694
  if (target < 0 || target >= keys.length) return;
1694
- [keys[kIndex], keys[target]] = [keys[target], keys[kIndex]];
1695
- mp.keys = keys;
1696
- models[model] = mp;
1695
+ const order = keys.map((_, i) => i);
1696
+ [order[kIndex], order[target]] = [order[target], order[kIndex]];
1697
+ models[model] = reorderKeys(mp, order);
1697
1698
  });
1698
1699
 
1699
1700
  const save = async () => {
@@ -33,8 +33,17 @@ export class StatePersistence {
33
33
  if (st && st.failedUntil && typeof st.failedUntil.forEach === 'function') {
34
34
  st.failedUntil.forEach((until, ref) => {
35
35
  if (Number.isFinite(until)) failedUntil[ref] = until;
36
+ else if (until === Infinity) failedUntil[ref] = 'Infinity';
36
37
  });
37
38
  }
39
+ const revokedRefs = [];
40
+ if (st && st.revokedRefs) {
41
+ if (typeof st.revokedRefs.forEach === 'function') {
42
+ st.revokedRefs.forEach((r) => revokedRefs.push(r));
43
+ } else if (Array.isArray(st.revokedRefs)) {
44
+ revokedRefs.push(...st.revokedRefs);
45
+ }
46
+ }
38
47
  const tokenUsage = {};
39
48
  if (st && st.tokenUsage && typeof st.tokenUsage.forEach === 'function') {
40
49
  st.tokenUsage.forEach((entry, ref) => {
@@ -45,14 +54,38 @@ export class StatePersistence {
45
54
  tokenUsage[ref] = { used: Math.floor(used), resetAt: Math.floor(resetAt) };
46
55
  });
47
56
  }
57
+ const costDays = {};
58
+ if (st?.costDays) {
59
+ const src = st.costDays instanceof Map ? Object.fromEntries(st.costDays) : st.costDays;
60
+ if (typeof src === 'object' && src !== null) Object.assign(costDays, src);
61
+ }
62
+ const costPerKey = {};
63
+ if (st?.costPerKey) {
64
+ const src = st.costPerKey instanceof Map ? Object.fromEntries(st.costPerKey) : st.costPerKey;
65
+ if (typeof src === 'object' && src !== null) Object.assign(costPerKey, src);
66
+ }
67
+ const usageDays = {};
68
+ if (st?.usageDays) {
69
+ const src = st.usageDays instanceof Map ? Object.fromEntries(st.usageDays) : st.usageDays;
70
+ if (typeof src === 'object' && src !== null) Object.assign(usageDays, src);
71
+ }
72
+ const usageCounts = {};
73
+ if (st?.usageCounts instanceof Map) {
74
+ st.usageCounts.forEach((cnt, ref) => {
75
+ if (Number.isFinite(cnt)) usageCounts[ref] = cnt;
76
+ });
77
+ }
48
78
  const outPool = {
49
79
  failedUntil,
50
80
  pointer: Number.isFinite(st?.pointer) ? st.pointer : 0,
51
81
  lastUsed: typeof st?.lastUsed === 'string' ? st.lastUsed : null,
52
82
  };
53
- if (Object.keys(tokenUsage).length > 0) {
54
- outPool.tokenUsage = tokenUsage;
55
- }
83
+ if (revokedRefs.length > 0) outPool.revokedRefs = revokedRefs;
84
+ if (Object.keys(tokenUsage).length > 0) outPool.tokenUsage = tokenUsage;
85
+ if (Object.keys(costDays).length > 0) outPool.costDays = costDays;
86
+ if (Object.keys(costPerKey).length > 0) outPool.costPerKey = costPerKey;
87
+ if (Object.keys(usageDays).length > 0) outPool.usageDays = usageDays;
88
+ if (Object.keys(usageCounts).length > 0) outPool.usageCounts = usageCounts;
56
89
  pools[base] = outPool;
57
90
  });
58
91
  }
@@ -109,7 +142,7 @@ export class StatePersistence {
109
142
  async load() {
110
143
  const raw = await safeReadJson(this.filePath, null);
111
144
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
112
- if (raw.version !== 1) return null;
145
+ if (raw.version !== 1 && raw.version !== 2) return null;
113
146
  return raw;
114
147
  }
115
148
 
@@ -126,7 +159,17 @@ export class StatePersistence {
126
159
  if (!existing || !hasPoolActivity(state)) {
127
160
  if (saved.failedUntil && typeof saved.failedUntil === 'object' && !Array.isArray(saved.failedUntil)) {
128
161
  for (const [ref, until] of Object.entries(saved.failedUntil)) {
129
- if (Number.isFinite(until)) state.failedUntil.set(ref, until);
162
+ if (until === 'Infinity' || until === Infinity) {
163
+ state.failedUntil.set(ref, Infinity);
164
+ } else if (Number.isFinite(until)) {
165
+ state.failedUntil.set(ref, until);
166
+ }
167
+ }
168
+ }
169
+ if (Array.isArray(saved.revokedRefs)) {
170
+ if (!state.revokedRefs) state.revokedRefs = new Set();
171
+ for (const r of saved.revokedRefs) {
172
+ if (typeof r === 'string') state.revokedRefs.add(r);
130
173
  }
131
174
  }
132
175
  if (saved.tokenUsage && typeof saved.tokenUsage === 'object' && !Array.isArray(saved.tokenUsage)) {
@@ -139,6 +182,30 @@ export class StatePersistence {
139
182
  state.tokenUsage.set(ref, { used: Math.floor(used), resetAt: Math.floor(resetAt) });
140
183
  }
141
184
  }
185
+ if (saved.costDays && typeof saved.costDays === 'object') {
186
+ if (!state.costDays) state.costDays = new Map();
187
+ for (const [day, cost] of Object.entries(saved.costDays)) {
188
+ if (Number.isFinite(cost)) state.costDays.set(day, cost);
189
+ }
190
+ }
191
+ if (saved.costPerKey && typeof saved.costPerKey === 'object') {
192
+ if (!state.costPerKey) state.costPerKey = new Map();
193
+ for (const [k, c] of Object.entries(saved.costPerKey)) {
194
+ if (Number.isFinite(c)) state.costPerKey.set(k, c);
195
+ }
196
+ }
197
+ if (saved.usageDays && typeof saved.usageDays === 'object') {
198
+ if (!state.usageDays) state.usageDays = new Map();
199
+ for (const [d, u] of Object.entries(saved.usageDays)) {
200
+ if (Number.isFinite(u)) state.usageDays.set(d, u);
201
+ }
202
+ }
203
+ if (saved.usageCounts && typeof saved.usageCounts === 'object') {
204
+ if (!state.usageCounts) state.usageCounts = new Map();
205
+ for (const [k, cnt] of Object.entries(saved.usageCounts)) {
206
+ if (Number.isFinite(cnt)) state.usageCounts.set(k, cnt);
207
+ }
208
+ }
142
209
  state.pointer = Number.isSafeInteger(saved.pointer) && saved.pointer >= 0 ? saved.pointer : 0;
143
210
  state.lastUsed = typeof saved.lastUsed === 'string' ? saved.lastUsed : null;
144
211
  }
@@ -53,17 +53,30 @@ export function buildPoolItem({
53
53
  const exp = parseExpiry(expiresAt[indexed[i].index]);
54
54
  if (exp !== undefined) parsedExpiry[refs[i]] = exp;
55
55
  }
56
+ } else if (expiresAt && typeof expiresAt === 'object') {
57
+ for (let i = 0; i < refs.length; i++) {
58
+ const exp = parseExpiry(expiresAt[refs[i]]);
59
+ if (exp !== undefined) parsedExpiry[refs[i]] = exp;
60
+ }
56
61
  }
57
62
  const parsedPaused = new Set();
58
63
  if (Array.isArray(paused)) {
59
64
  for (let i = 0; i < refs.length; i++) {
60
- if (paused[indexed[i].index] === true) parsedPaused.add(refs[i]);
65
+ if (paused[indexed[i].index] === true || paused.includes(refs[i])) parsedPaused.add(refs[i]);
66
+ }
67
+ } else if (paused instanceof Set) {
68
+ for (let i = 0; i < refs.length; i++) {
69
+ if (paused.has(refs[i])) parsedPaused.add(refs[i]);
61
70
  }
62
71
  }
63
72
  const parsedRevoked = new Set();
64
73
  if (Array.isArray(revoked)) {
65
74
  for (let i = 0; i < refs.length; i++) {
66
- if (revoked[indexed[i].index] === true) parsedRevoked.add(refs[i]);
75
+ if (revoked[indexed[i].index] === true || revoked.includes(refs[i])) parsedRevoked.add(refs[i]);
76
+ }
77
+ } else if (revoked instanceof Set) {
78
+ for (let i = 0; i < refs.length; i++) {
79
+ if (revoked.has(refs[i])) parsedRevoked.add(refs[i]);
67
80
  }
68
81
  }
69
82
  const weightsMap = {};
@@ -140,12 +153,22 @@ export function buildPools({ cfg = {}, poolState = new Map(), defaultState = ini
140
153
  for (const [model, mp] of Object.entries(p.models ?? {})) {
141
154
  const modelRpm = typeof mp.rpmLimit === 'number' ? mp.rpmLimit : provRpm;
142
155
  const modelTpm = typeof mp.tpmLimit === 'number' ? mp.tpmLimit : provTpm;
143
- const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mp.paused, mp.revoked, poolCooldown, poolMax, undefined, p.routingStrategy, p.proactiveRateLimitGuard, {
156
+ const mergedPaused = new Set([
157
+ ...(pool?.pausedRefs ?? []),
158
+ ...(Array.isArray(mp.paused) ? mp.paused : (mp.paused instanceof Set ? [...mp.paused] : [])),
159
+ ]);
160
+ const mergedRevoked = new Set([
161
+ ...(pool?.revokedRefs ?? []),
162
+ ...(Array.isArray(mp.revoked) ? mp.revoked : (mp.revoked instanceof Set ? [...mp.revoked] : [])),
163
+ ]);
164
+ const modelExpiresAt = { ...(pool?.expiresAt ?? {}), ...(typeof mp.expiresAt === 'object' ? mp.expiresAt : {}) };
165
+ const mpool = buildPool(`${p.provider}::${model}`, mp.keys, mp.weights, mergedPaused, mergedRevoked, poolCooldown, poolMax, modelExpiresAt, p.routingStrategy, p.proactiveRateLimitGuard, {
144
166
  provider: p.provider,
145
167
  model,
146
168
  quotas: mp.quotas,
147
169
  }, modelRpm, modelTpm);
148
170
  if (!mpool) continue;
171
+ mpool.basePool = pool;
149
172
  byModel.set(model, mpool);
150
173
  for (const ref of mpool.refs) {
151
174
  if (!poolByRef.has(ref)) poolByRef.set(ref, mpool);
package/lib/pool.js CHANGED
@@ -316,6 +316,7 @@ export function isKeyPaused(pool, ref) {
316
316
  if (Array.isArray(pool.pausedRefs) && pool.pausedRefs.includes(ref)) return true;
317
317
  if (pool.state && pool.state.pausedRefs instanceof Set && pool.state.pausedRefs.has(ref)) return true;
318
318
  if (pool.state && Array.isArray(pool.state.pausedRefs) && pool.state.pausedRefs.includes(ref)) return true;
319
+ if (pool.basePool) return isKeyPaused(pool.basePool, ref);
319
320
  return false;
320
321
  }
321
322
 
@@ -560,5 +561,6 @@ export function isKeyRevoked(pool, ref) {
560
561
  if (Array.isArray(pool.revokedRefs) && pool.revokedRefs.includes(ref)) return true;
561
562
  if (pool.state && pool.state.revokedRefs instanceof Set && pool.state.revokedRefs.has(ref)) return true;
562
563
  if (pool.state && Array.isArray(pool.state.revokedRefs) && pool.state.revokedRefs.includes(ref)) return true;
564
+ if (pool.basePool) return isKeyRevoked(pool.basePool, ref);
563
565
  return false;
564
566
  }
package/lib/resolver.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
 
23
23
  /** Internal failure code for "local budgets forbid this dispatch". */
24
24
  export const LOCAL_QUOTA_CODE = 'LOCAL_MODEL_QUOTA_EXHAUSTED';
25
+ export const LOCAL_POOL_EXHAUSTED_CODE = 'LOCAL_POOL_EXHAUSTED';
25
26
 
26
27
  /**
27
28
  * @param {object} deps
@@ -78,7 +79,8 @@ export function createResolver({
78
79
  if (isKeyPaused(pool, candidate)) continue;
79
80
  const until = pool.state.failedUntil.get(candidate);
80
81
  if (until !== undefined && until > at) continue;
81
- if (pool.expiresAt?.[candidate] !== undefined && at >= pool.expiresAt[candidate]) continue;
82
+ const candidateExp = pool.expiresAt?.[candidate] ?? pool.basePool?.expiresAt?.[candidate];
83
+ if (candidateExp !== undefined && at >= candidateExp) continue;
82
84
  // Local model token budget: a spent credential is simply not a candidate.
83
85
  // This is deliberately NOT recorded as a failure — the key is healthy and
84
86
  // merely out of budget — so it must never touch failedUntil / failCounts /
@@ -144,7 +146,10 @@ export function createResolver({
144
146
  { code: LOCAL_QUOTA_CODE, localQuota: true },
145
147
  );
146
148
  }
147
- return original(ref);
149
+ throw Object.assign(
150
+ new Error(`dsh-key-rotation: all credentials in pool "${pool.base ?? pool.provider}" are currently blocked, paused, expired, or exhausted`),
151
+ { code: LOCAL_POOL_EXHAUSTED_CODE, localExhausted: true },
152
+ );
148
153
  };
149
154
  }
150
155
 
package/lib/rotate.js CHANGED
@@ -210,9 +210,10 @@ export function createRotate(deps) {
210
210
  return llm.stream({ ...options, [MARKER]: true });
211
211
  });
212
212
  } catch (e) {
213
- if (e?.localQuota || e?.code === LOCAL_QUOTA_CODE) {
214
- lastFailure = finishError(LOCAL_QUOTA_CODE, String(e?.message ?? ''));
215
- logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local model token budget blocks dispatch — no upstream request sent`);
213
+ if (e?.localQuota || e?.localExhausted || e?.code === LOCAL_QUOTA_CODE || e?.code === LOCAL_POOL_EXHAUSTED_CODE) {
214
+ const code = e?.code ?? (e?.localQuota ? LOCAL_QUOTA_CODE : LOCAL_POOL_EXHAUSTED_CODE);
215
+ lastFailure = finishError(code, String(e?.message ?? ''));
216
+ logWarn(`[dsh-key-rotation] ${options.provider}/${options.model ?? '-'}: local rejection (${code}) — no upstream request sent`);
216
217
  break;
217
218
  }
218
219
  const curRef = reqStore.pickedRef ?? pool.state.lastUsed;
package/lib/safe-fetch.js CHANGED
@@ -1,20 +1,57 @@
1
- // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354).
1
+ // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354, #411).
2
2
  import net from 'node:net';
3
3
  import dns from 'node:dns/promises';
4
+ import nodeDns from 'node:dns';
5
+ import { Agent } from 'undici';
4
6
 
5
7
  export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
6
8
  export const MAX_REDIRECTS = 5;
7
9
 
10
+ /**
11
+ * Parses an IPv6 string into an array of 8 16-bit integers (words).
12
+ * Supports standard hex, compressed `::`, and embedded dotted IPv4 (e.g. `::ffff:127.0.0.1`).
13
+ * @param {string} ip
14
+ * @returns {number[]|null}
15
+ */
16
+ export function parseIpv6Words(ip) {
17
+ if (typeof ip !== 'string') return null;
18
+ let s = ip.toLowerCase().trim().replace(/^\[|\]$/g, '');
19
+ const lastColon = s.lastIndexOf(':');
20
+ if (lastColon !== -1) {
21
+ const tail = s.slice(lastColon + 1);
22
+ if (net.isIP(tail) === 4) {
23
+ const parts = tail.split('.').map(Number);
24
+ if (parts.length === 4 && !parts.some(n => isNaN(n) || n < 0 || n > 255)) {
25
+ const w6 = ((parts[0] << 8) | parts[1]) & 0xffff;
26
+ const w7 = ((parts[2] << 8) | parts[3]) & 0xffff;
27
+ s = s.slice(0, lastColon) + ':' + w6.toString(16) + ':' + w7.toString(16);
28
+ }
29
+ }
30
+ }
31
+ const halves = s.split('::');
32
+ if (halves.length > 2) return null;
33
+ const left = halves[0] ? halves[0].split(':').map(x => parseInt(x, 16)) : [];
34
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':').map(x => parseInt(x, 16)) : [];
35
+ if (left.some(isNaN) || right.some(isNaN)) return null;
36
+ const missing = 8 - (left.length + right.length);
37
+ if (halves.length === 1 && missing !== 0) return null;
38
+ if (missing < 0) return null;
39
+ const middle = new Array(missing).fill(0);
40
+ return [...left, ...middle, ...right];
41
+ }
42
+
8
43
  /**
9
44
  * Checks if an IPv4 or IPv6 address is private, loopback, link-local, multicast, or reserved.
45
+ * Canonicalizes IPv6 addresses including hex and dotted-quad IPv4-mapped forms (#411).
10
46
  * @param {string} ip
11
47
  * @returns {boolean} true if private/reserved/loopback
12
48
  */
13
49
  export function isPrivateOrReservedIp(ip) {
14
50
  if (!ip || typeof ip !== 'string') return true;
15
- const kind = net.isIP(ip);
51
+ const cleaned = ip.trim().replace(/^\[|\]$/g, '');
52
+ const kind = net.isIP(cleaned);
16
53
  if (kind === 4) {
17
- const parts = ip.split('.').map(Number);
54
+ const parts = cleaned.split('.').map(Number);
18
55
  if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return true;
19
56
  const [a, b, c, d] = parts;
20
57
  if (a === 0) return true; // 0.0.0.0/8 current network
@@ -33,22 +70,70 @@ export function isPrivateOrReservedIp(ip) {
33
70
  return false;
34
71
  }
35
72
  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);
73
+ const words = parseIpv6Words(cleaned);
74
+ if (!words || words.length !== 8) return true;
75
+ const [w0, w1, w2, w3, w4, w5, w6, w7] = words;
76
+ // Unspecified :: or loopback ::1
77
+ if (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0 && w6 === 0) {
78
+ if (w7 === 0 || w7 === 1) return true;
79
+ }
80
+ // IPv4-mapped (::ffff:0:0/96), IPv4-compatible (::/96), or NAT64 (64:ff9b::/96)
81
+ const isMapped = (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0xffff);
82
+ const isCompat = (w0 === 0 && w1 === 0 && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0);
83
+ const isNat64 = (w0 === 0x0064 && w1 === 0xff9b && w2 === 0 && w3 === 0 && w4 === 0 && w5 === 0);
84
+ if (isMapped || isCompat || isNat64) {
85
+ const a = (w6 >> 8) & 0xff;
86
+ const b = w6 & 0xff;
87
+ const c = (w7 >> 8) & 0xff;
88
+ const d = w7 & 0xff;
89
+ return isPrivateOrReservedIp(`${a}.${b}.${c}.${d}`);
41
90
  }
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
91
+ if ((w0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
92
+ if ((w0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
93
+ if ((w0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast
94
+ if (w0 === 0x2001 && w1 === 0x0db8) return true; // 2001:db8::/32 documentation
95
+ if (w0 === 0x0100 && w1 === 0) return true; // 100::/64 discard
47
96
  return false;
48
97
  }
49
98
  return true;
50
99
  }
51
100
 
101
+ /**
102
+ * Creates an undici Agent with connect-time lookup validation against DNS rebinding / TOCTOU (#411).
103
+ * @param {object} [opts]
104
+ * @param {Function} [opts.lookupImpl]
105
+ * @returns {Agent}
106
+ */
107
+ export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
108
+ return new Agent({
109
+ connect: {
110
+ lookup: (hostname, options, callback) => {
111
+ const cb = typeof options === 'function' ? options : callback;
112
+ const opts = typeof options === 'function' ? {} : options;
113
+ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
114
+ if (err) return cb(err);
115
+ const list = Array.isArray(addresses) ? addresses : [addresses];
116
+ for (const item of list) {
117
+ const addr = typeof item === 'string' ? item : item.address;
118
+ if (isPrivateOrReservedIp(addr)) {
119
+ const ssrfErr = new Error(`dsh-key-rotation: connect blocked: host ${hostname} resolves to private or reserved IP ${addr}`);
120
+ ssrfErr.code = 'SSRF_BLOCKED';
121
+ return cb(ssrfErr);
122
+ }
123
+ }
124
+ if (typeof options === 'function') {
125
+ const first = list[0];
126
+ const addr = typeof first === 'string' ? first : first.address;
127
+ const fam = typeof first === 'object' && first.family ? first.family : (net.isIP(addr) || 4);
128
+ return cb(null, addr, fam);
129
+ }
130
+ return cb(null, addresses);
131
+ });
132
+ },
133
+ },
134
+ });
135
+ }
136
+
52
137
  /**
53
138
  * Validates a target URL and its hostname/IP against SSRF.
54
139
  * @param {string} urlString
@@ -173,7 +258,7 @@ export async function readBoundedJson(resp, maxBytes = MAX_IMPORT_BYTES) {
173
258
  }
174
259
 
175
260
  /**
176
- * Fetches JSON from a URL with SSRF protection, redirect verification, and size bounding.
261
+ * Fetches JSON from a URL with SSRF protection, connect-time rebinding guard, redirect verification, and size bounding.
177
262
  * @param {string} url
178
263
  * @param {object} options
179
264
  */
@@ -183,14 +268,18 @@ export async function safeFetchJson(url, {
183
268
  lookupImpl = dns.lookup,
184
269
  maxBytes = MAX_IMPORT_BYTES,
185
270
  redirectsLeft = MAX_REDIRECTS,
271
+ dispatcher,
186
272
  } = {}) {
273
+ const safeDispatcher = dispatcher ?? (fetchImpl === globalThis.fetch ? createSafeDispatcher() : undefined);
187
274
  let currentUrl = url;
188
275
  while (true) {
189
276
  await assertSafeUrl(currentUrl, { lookupImpl });
190
- const resp = await fetchImpl(currentUrl, {
277
+ const fetchOpts = {
191
278
  signal,
192
279
  redirect: 'manual',
193
- });
280
+ };
281
+ if (safeDispatcher) fetchOpts.dispatcher = safeDispatcher;
282
+ const resp = await fetchImpl(currentUrl, fetchOpts);
194
283
 
195
284
  if ([301, 302, 303, 307, 308].includes(resp.status)) {
196
285
  if (redirectsLeft <= 0) {
@@ -218,4 +307,4 @@ export async function safeFetchJson(url, {
218
307
 
219
308
  return await readBoundedJson(resp, maxBytes);
220
309
  }
221
- }
310
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.38",
3
+ "version": "0.8.39",
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": [
@@ -57,8 +57,8 @@
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.1",
60
- "@deepseek-ai/schemastery": "^3.18.4",
61
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1"
60
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
61
+ "@deepseek-ai/schemastery": "^3.18.4"
62
62
  },
63
63
  "scripts": {
64
64
  "test": "node tools/test.mjs",
@@ -70,5 +70,8 @@
70
70
  "devDependencies": {
71
71
  "react": "18.3.1",
72
72
  "react-test-renderer": "18.3.1"
73
+ },
74
+ "dependencies": {
75
+ "undici": "^6.29.0"
73
76
  }
74
77
  }