@goodandready/dsh-key-rotation 0.8.75 → 0.8.77

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/lib/pool.js CHANGED
@@ -1,16 +1,20 @@
1
1
  // lib/pool.js — pure pool arithmetic and selection logic for dsh-key-rotation.
2
2
  import {
3
3
  MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
4
+ unstickFailedKey, SessionRouter, defaultSessionRouter, MAX_SESSIONS, SESSION_TTL_MS,
4
5
  selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
5
6
  costForDay, costForWeek, budgetVerdict,
6
7
  } from './pool-selection.js';
7
8
 
8
9
  export {
9
10
  MAX_SESSION_STICKY_ENTRIES, setSessionSticky, getSessionSticky, clearSessionSticky,
11
+ unstickFailedKey, SessionRouter, defaultSessionRouter, MAX_SESSIONS, SESSION_TTL_MS,
10
12
  selectPool, sortAttemptList, expiringSoon, shouldNotifyDaily,
11
13
  costForDay, costForWeek, budgetVerdict,
12
14
  };
13
15
 
16
+ import { classifyResponseError, CONTEXT_WINDOW_EXCEEDED_CODE } from './errors.js';
17
+ export { classifyResponseError };
14
18
  import { resetModelQuotaIfNeeded } from './model-quota.js';
15
19
  import { decryptSecret } from './crypto-storage.js';
16
20
  import { bestEffort } from './best-effort.js';
@@ -39,8 +43,23 @@ export function isLoopbackAddress(ip) {
39
43
  return false;
40
44
  }
41
45
 
42
- /** Check if an incoming HTTP request is from the Desktop shell (#516). */
46
+ /** Check if request contains non-loopback forwarding headers (e.g. proxied from LAN/Tailscale) (#516). */
47
+ export function hasNonLoopbackForwarding(req) {
48
+ const xff = req?.headers?.['x-forwarded-for'];
49
+ if (xff && typeof xff === 'string') {
50
+ const ips = xff.split(',').map((s) => s.trim());
51
+ if (ips.some((ip) => ip && !isLoopbackAddress(ip))) return true;
52
+ }
53
+ const xRealIp = req?.headers?.['x-real-ip'];
54
+ if (xRealIp && typeof xRealIp === 'string' && !isLoopbackAddress(xRealIp.trim())) {
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ /** Check if an incoming HTTP request is from an authentic local Desktop shell (#516). */
43
61
  export function isDesktopShellRequest(req) {
62
+ if (hasNonLoopbackForwarding(req)) return false;
44
63
  const agent = req?.headers?.['user-agent'] || '';
45
64
  const cookie = req?.headers?.cookie || '';
46
65
  return typeof agent === 'string' && agent.includes('@deepseek-ai/dsh-desktop/') &&
@@ -69,9 +88,9 @@ export function isTrustedBridgeRequest(req) {
69
88
  if (!origin) return false; // Explicit empty string or null-like falsy
70
89
  try {
71
90
  const originUrl = new URL(origin);
72
- // Official Desktop build shell origin (#22, #465, #516)
91
+ // Official Desktop build shell origin (#22, #465, #516) - requires authentic local Desktop shell (rejects LAN forwarding)
73
92
  if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
74
- return true;
93
+ return !hasNonLoopbackForwarding(req);
75
94
  }
76
95
  if (originUrl.host !== hostHeader) return false;
77
96
  const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
@@ -89,7 +108,7 @@ export function isTrustedBridgeRequest(req) {
89
108
  return true;
90
109
  }
91
110
 
92
- // Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465) or valid Referer.
111
+ // Mutating requests without Origin: allow authentic local Desktop shell requests (#516, #465)
93
112
  if (isDesktopShellRequest(req)) {
94
113
  return true;
95
114
  }
@@ -98,7 +117,7 @@ export function isTrustedBridgeRequest(req) {
98
117
  try {
99
118
  const refUrl = new URL(referer);
100
119
  if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
101
- return true;
120
+ return !hasNonLoopbackForwarding(req);
102
121
  }
103
122
  if (refUrl.host !== hostHeader) return false;
104
123
  const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
@@ -175,6 +194,7 @@ export function pickNext(pool, now, refsCount = pool.refs.length) {
175
194
 
176
195
  /** Apply a failed-key cooldown to pool state. Pure: returns next state shape. */
177
196
  export function applyCooldown(pool, ref, cooldownMs, now = Date.now()) {
197
+ unstickFailedKey(pool, ref);
178
198
  return {
179
199
  ...pool,
180
200
  state: {
@@ -203,6 +223,7 @@ export function computeBackoff(baseMs, failCount, maxMs, isSoft = false) {
203
223
 
204
224
  /** Record a failure for `ref` in `pool.state`. */
205
225
  export function recordFailure(pool, ref, now, baseMs, maxMs, isSoft = false, jitter = false) {
226
+ unstickFailedKey(pool, ref);
206
227
  if (!pool.state.failCounts) pool.state.failCounts = new Map();
207
228
  let next = pool.state.failCounts.get(ref) ?? 0;
208
229
  if (!isSoft) {
@@ -479,6 +500,18 @@ export function isSwitchableError(failureOrPayload, switchCodes = new Set(DEFAUL
479
500
  status = Number(rawCode);
480
501
  }
481
502
 
503
+ // 0. Structured response error classification (#547)
504
+ if (status || failure.body || message.startsWith('{') || (code && code !== 'ERROR')) {
505
+ const typed = classifyResponseError(status, failure.body ?? message, failure.headers ?? failure.response?.headers);
506
+ if (typed.code === CONTEXT_WINDOW_EXCEEDED_CODE) return false;
507
+ if ((typed.code === 'QUOTA' || typed.code === 'ACCOUNT_QUOTA') && (switchCodes.has('QUOTA') || switchCodes.has('RATE_LIMIT') || switchCodes.has('429'))) return true;
508
+ if (typed.code === 'RATE_LIMIT' && (switchCodes.has('RATE_LIMIT') || switchCodes.has('429'))) return true;
509
+ if ((typed.code === 'AUTH' || typed.code === 'INVALID_CREDENTIAL') && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403'))) return true;
510
+ if (typed.code === 'SERVER' && (switchCodes.has('SERVER') || (status >= 500 && status <= 504 && switchCodes.has(String(status))))) return true;
511
+ if (typed.code === 'TIMEOUT' && (switchCodes.has('TIMEOUT') || switchCodes.has('408') || switchCodes.has('504'))) return true;
512
+ if (switchCodes.has(typed.code)) return true;
513
+ }
514
+
482
515
  // 1. Direct HTTP status codes (#267: also 408/425 transient, #545: numeric status/code mapped to semantic switch codes)
483
516
  if ((status === 429 || status === 408 || status === 425) && (switchCodes.has('RATE_LIMIT') || switchCodes.has('QUOTA') || switchCodes.has('429') || switchCodes.has('TIMEOUT') || switchCodes.has(String(status)))) return true;
484
517
  if ((status === 401 || status === 403) && (switchCodes.has('AUTH') || switchCodes.has('401') || switchCodes.has('403') || switchCodes.has(String(status)))) return true;
@@ -561,3 +594,4 @@ export function isKeyBroken(pool, ref, now = Date.now()) {
561
594
  if (pool.basePool) return isKeyBroken(pool.basePool, ref, now);
562
595
  return false;
563
596
  }
597
+
package/lib/rotate.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  getSessionSticky,
16
16
  setSessionSticky,
17
17
  clearSessionSticky,
18
+ unstickFailedKey,
18
19
  } from './pool.js';
19
20
  import { pickCascadeFallback, CASCADE_MAX_DEPTH } from './cascade.js';
20
21
  import { createRequestBudget, BUDGET_EXHAUSTED_TIMEOUT, BUDGET_EXHAUSTED_ATTEMPTS } from './request-budget.js';
@@ -210,6 +211,7 @@ export function createRotate(deps) {
210
211
  const penalizeRef = (targetRef, errCode, errMsg, rawHeaders) => {
211
212
  if (!targetRef) return;
212
213
  if (sessionId) clearSessionSticky(pool, sessionId);
214
+ unstickFailedKey(pool, targetRef);
213
215
  if (errCode === LOCAL_QUOTA_CODE) return;
214
216
  let headerResetMs = null;
215
217
  if (rawHeaders) {
@@ -411,6 +413,7 @@ export function createRotate(deps) {
411
413
  let yielded = false;
412
414
  let switching = false;
413
415
  let inner;
416
+ const preTokenBuffer = [];
414
417
  try {
415
418
  // #464 & #461: Record attempt & trace dispatch
416
419
  budget.recordAttempt();
@@ -450,10 +453,13 @@ export function createRotate(deps) {
450
453
  if (ok) reqStore.concurrencyRef = fallbackRef;
451
454
  }
452
455
  }
453
- // Only actual content deltas lock the stream (no more rotation).
454
- // Structural/metadata chunks (block-start/end, usage) do not.
455
- if (chunk && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta')) {
456
- yielded = true;
456
+ // Pre-First-Token Failover (#527): Lock the stream only upon first content delta.
457
+ if (chunk && isContentChunk(chunk)) {
458
+ if (!yielded) {
459
+ yielded = true;
460
+ for (const prev of preTokenBuffer) yield prev;
461
+ preTokenBuffer.length = 0;
462
+ }
457
463
  yield chunk;
458
464
  continue;
459
465
  }
@@ -465,15 +471,16 @@ export function createRotate(deps) {
465
471
  const message = failure?.message ?? '';
466
472
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
467
473
  const switchable = !yielded && kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes);
468
- const activeRef = reqStore.pickedRef ?? pool.state.lastUsed;
474
+ const activeRef = reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
469
475
  if (switchable) {
476
+ preTokenBuffer.length = 0; // Discard metadata from aborted attempt (#527)
470
477
  penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
471
478
  recordFailureCategory(pool, code ?? 'UNKNOWN', message, activeRef, now());
472
479
  pool.state.switches = (pool.state.switches ?? 0) + 1;
473
480
  pool.state.lastReason = String(code ?? 'UNKNOWN');
474
481
  pool.state.lastSwitchAt = now();
475
482
  lastFailure = chunk;
476
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed (${String(code)} ${String(message).slice(0, 100)}) - next key`);
483
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} failed before first token (${String(code)} ${String(message).slice(0, 100)}) - failover to next key`);
477
484
  // #216: per-switch webhook (opt-in switchNotify), deduped per provider
478
485
  if (switchNotify && activeRef) {
479
486
  notifySwitch(runtime0, pool, {
@@ -492,6 +499,10 @@ export function createRotate(deps) {
492
499
  if (kind === 'error' && isSwitchableError(failure, effectiveSwitchCodes)) {
493
500
  penalizeRef(activeRef, code ?? 'UNKNOWN', message, rawHdrs ?? failure?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
494
501
  }
502
+ if (!yielded) {
503
+ for (const prev of preTokenBuffer) yield prev;
504
+ preTokenBuffer.length = 0;
505
+ }
495
506
  yield chunk;
496
507
  return;
497
508
  }
@@ -539,24 +550,33 @@ export function createRotate(deps) {
539
550
  });
540
551
  }
541
552
  recordLatency(pool, reqStore);
542
- activePermit?.success();
553
+ if (typeof activePermit?.success === 'function') activePermit.success();
543
554
  if (isTraceOwner) trace.finish('SUCCESS', activeRef);
555
+ if (!yielded) {
556
+ for (const prev of preTokenBuffer) yield prev;
557
+ preTokenBuffer.length = 0;
558
+ }
544
559
  yield chunk;
545
560
  return;
546
561
  }
547
- yield chunk;
562
+ if (yielded) {
563
+ yield chunk;
564
+ } else {
565
+ preTokenBuffer.push(chunk);
566
+ }
548
567
  }
549
568
  } catch (e) {
550
569
  const effectiveSwitchCodes = pool.switchCodes ?? switchCodes;
551
- const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed;
570
+ const activeRef = _pickedRef ?? reqStore.pickedRef ?? pool.state.lastUsed ?? attemptList[attempt];
552
571
  if (!yielded && isSwitchableError(e, effectiveSwitchCodes)) {
572
+ preTokenBuffer.length = 0; // Discard metadata from failed attempt (#527)
553
573
  penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
554
574
  recordFailureCategory(pool, e?.code ?? 'TRANSPORT', String(e?.message ?? e), activeRef, now());
555
575
  pool.state.switches = (pool.state.switches ?? 0) + 1;
556
576
  pool.state.lastReason = String(e?.code ?? 'TRANSPORT');
557
577
  pool.state.lastSwitchAt = now();
558
578
  lastFailure = finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
559
- logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
579
+ logWarn(`[dsh-key-rotation] ${options.provider}: key ${String(activeRef ?? '?')} stream threw before first token ${String(e?.code ?? e?.message ?? e)} - failover to next key`);
560
580
  if (switchNotify && activeRef) {
561
581
  notifySwitch(runtime0, pool, {
562
582
  provider: options.provider,
@@ -570,16 +590,24 @@ export function createRotate(deps) {
570
590
  if (yielded && isSwitchableError(e, effectiveSwitchCodes)) {
571
591
  penalizeRef(activeRef, e?.code ?? 'TRANSPORT', String(e?.message ?? e), e?.headers ?? e?.response?.headers ?? pool.state.detectedLimits?.get?.(activeRef));
572
592
  }
593
+ if (!yielded) {
594
+ for (const prev of preTokenBuffer) yield prev;
595
+ preTokenBuffer.length = 0;
596
+ }
573
597
  yield finishError(e?.code ?? 'TRANSPORT', String(e?.message ?? e));
574
598
  return;
575
599
  }
576
600
 
577
601
  if (switching) continue; // try the next key
602
+ if (!yielded && preTokenBuffer.length > 0) {
603
+ for (const prev of preTokenBuffer) yield prev;
604
+ preTokenBuffer.length = 0;
605
+ }
578
606
  if (yielded) {
579
607
  const activeRef = reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed;
580
608
  if (activeRef) recordSuccess(pool, activeRef, now());
581
609
  recordLatency(pool, reqStore);
582
- activePermit?.success();
610
+ if (typeof activePermit?.success === 'function') activePermit.success();
583
611
  if (isTraceOwner) trace.finish('SUCCESS', activeRef);
584
612
  } else if (isTraceOwner) {
585
613
  trace.finish('COMPLETED', reqStore.pickedRef ?? _pickedRef ?? pool.state.lastUsed);
@@ -626,3 +654,25 @@ export function createRotate(deps) {
626
654
 
627
655
  return rotate;
628
656
  }
657
+
658
+ /**
659
+ * Whether a chunk carries a model-visible content delta (#527).
660
+ * @param {Record<string, unknown>} chunk
661
+ * @returns {boolean}
662
+ */
663
+ export function isContentChunk(chunk) {
664
+ if (!chunk || typeof chunk !== 'object') return false;
665
+ if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || chunk.type === 'tool-call-delta') {
666
+ return true;
667
+ }
668
+ if (chunk.delta !== undefined || chunk.text !== undefined || chunk.content !== undefined || chunk.choices !== undefined) {
669
+ return true;
670
+ }
671
+ if (chunk.type === 'start' || chunk.type === 'block-start' || chunk.type === 'block-stop' ||
672
+ chunk.type === 'metadata' || chunk.type === 'response-metadata' || chunk.type === 'usage' ||
673
+ chunk.type === 'finish') {
674
+ return false;
675
+ }
676
+ return false;
677
+ }
678
+
package/lib/safe-fetch.js CHANGED
@@ -1,7 +1,10 @@
1
- // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354, #411).
1
+ // lib/safe-fetch.js — SSRF-protected HTTP fetch with DNS and IP validation (#354, #411, #557).
2
2
  import net from 'node:net';
3
3
  import dns from 'node:dns/promises';
4
4
  import nodeDns from 'node:dns';
5
+ import https from 'node:https';
6
+ import http from 'node:http';
7
+ import { URL } from 'node:url';
5
8
 
6
9
  export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
7
10
  export const MAX_REDIRECTS = 5;
@@ -30,7 +33,7 @@ export function parseIpv6Words(ip) {
30
33
  const halves = s.split('::');
31
34
  if (halves.length > 2) return null;
32
35
  const left = halves[0] ? halves[0].split(':').map(x => parseInt(x, 16)) : [];
33
- const right = halves.length === 2 && halves[1] ? halves[1].split(':').map(x => parseInt(x, 16)) : [];
36
+ const right = halves[1] && halves[1] ? halves[1].split(':').map(x => parseInt(x, 16)) : [];
34
37
  if (left.some(isNaN) || right.some(isNaN)) return null;
35
38
  const missing = 8 - (left.length + right.length);
36
39
  if (halves.length === 1 && missing !== 0) return null;
@@ -98,13 +101,14 @@ export function isPrivateOrReservedIp(ip) {
98
101
  }
99
102
 
100
103
  /**
101
- * Creates an undici Agent with connect-time lookup validation against DNS rebinding / TOCTOU (#411).
104
+ * Creates a connect-time lookup function that rejects private/reserved IPs on socket establishment (#411, #557).
105
+ * Prevents DNS Rebinding (TOCTOU) by validating the IP address right when the socket connects.
102
106
  * @param {object} [opts]
103
107
  * @param {Function} [opts.lookupImpl]
104
- * @returns {Agent}
108
+ * @returns {Function}
105
109
  */
106
- export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
107
- const lookup = (hostname, options, callback) => {
110
+ export function createSafeLookup({ lookupImpl = nodeDns.lookup } = {}) {
111
+ return function safeLookup(hostname, options, callback) {
108
112
  const cb = typeof options === 'function' ? options : callback;
109
113
  const opts = typeof options === 'function' ? {} : (typeof options === 'number' ? { family: options } : (options || {}));
110
114
  lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
@@ -127,7 +131,16 @@ export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
127
131
  return cb(null, addresses);
128
132
  });
129
133
  };
134
+ }
130
135
 
136
+ /**
137
+ * Creates an agent/dispatcher with connect-time lookup validation against DNS rebinding / TOCTOU (#411, #557).
138
+ * @param {object} [opts]
139
+ * @param {Function} [opts.lookupImpl]
140
+ * @returns {object}
141
+ */
142
+ export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
143
+ const lookup = createSafeLookup({ lookupImpl });
131
144
  return {
132
145
  options: {
133
146
  connect: { lookup },
@@ -137,9 +150,12 @@ export function createSafeDispatcher({ lookupImpl = nodeDns.lookup } = {}) {
137
150
  }
138
151
 
139
152
  /**
140
- * Validates a target URL and its hostname/IP against SSRF.
153
+ * Validates that a URL is safe to fetch before making any network connections.
154
+ * Requires HTTPS protocol and verifies hostname does not resolve to private/reserved IPs.
141
155
  * @param {string} urlString
142
- * @param {object} options
156
+ * @param {object} [options]
157
+ * @param {Function} [options.lookupImpl]
158
+ * @returns {Promise<URL>}
143
159
  */
144
160
  export async function assertSafeUrl(urlString, { lookupImpl = dns.lookup } = {}) {
145
161
  let urlObj;
@@ -260,37 +276,161 @@ export async function readBoundedJson(resp, maxBytes = MAX_IMPORT_BYTES) {
260
276
  }
261
277
 
262
278
  /**
263
- * Fetches JSON from a URL with SSRF protection, connect-time rebinding guard, redirect verification, and size bounding.
279
+ * Performs native HTTPS/HTTP request with connect-time DNS validation against rebinding (#557).
280
+ */
281
+ function fetchNativeJson(urlObj, { lookup, maxBytes, signal, ca, rejectUnauthorized }) {
282
+ return new Promise((resolve, reject) => {
283
+ const isHttps = urlObj.protocol === 'https:';
284
+ const transport = isHttps ? https : http;
285
+
286
+ const reqOpts = {
287
+ method: 'GET',
288
+ headers: {
289
+ accept: 'application/json, text/plain, */*',
290
+ 'user-agent': 'dsh-key-rotation/0.8',
291
+ },
292
+ lookup,
293
+ signal,
294
+ };
295
+ if (ca !== undefined) reqOpts.ca = ca;
296
+ if (rejectUnauthorized !== undefined) reqOpts.rejectUnauthorized = rejectUnauthorized;
297
+
298
+ const req = transport.request(urlObj, reqOpts, (res) => {
299
+ const status = res.statusCode || 0;
300
+ if ([301, 302, 303, 307, 308].includes(status)) {
301
+ res.resume(); // discard body
302
+ return resolve({
303
+ status,
304
+ headers: {
305
+ get: (name) => res.headers[name.toLowerCase()] || null,
306
+ },
307
+ });
308
+ }
309
+
310
+ if (status < 200 || status >= 300) {
311
+ res.resume();
312
+ const err = new Error(`dsh-key-rotation: fetch returned ${status}`);
313
+ err.code = 'FETCH_FAILED';
314
+ err.status = status;
315
+ return reject(err);
316
+ }
317
+
318
+ const cl = Number(res.headers['content-length']);
319
+ if (!isNaN(cl) && cl > maxBytes) {
320
+ res.resume();
321
+ const err = new Error(`dsh-key-rotation: response exceeds maximum size of ${maxBytes} bytes`);
322
+ err.code = 'PAYLOAD_TOO_LARGE';
323
+ return reject(err);
324
+ }
325
+
326
+ const chunks = [];
327
+ let total = 0;
328
+ res.on('data', (chunk) => {
329
+ total += chunk.length;
330
+ if (total > maxBytes) {
331
+ res.destroy();
332
+ const err = new Error(`dsh-key-rotation: response exceeds maximum size of ${maxBytes} bytes`);
333
+ err.code = 'PAYLOAD_TOO_LARGE';
334
+ return reject(err);
335
+ }
336
+ chunks.push(chunk);
337
+ });
338
+
339
+ res.on('end', () => {
340
+ try {
341
+ const bodyStr = Buffer.concat(chunks).toString('utf8');
342
+ const data = JSON.parse(bodyStr);
343
+ resolve({ status: 200, ok: true, data });
344
+ } catch (e) {
345
+ reject(new Error(`dsh-key-rotation: invalid JSON response: ${e.message}`));
346
+ }
347
+ });
348
+
349
+ res.on('error', reject);
350
+ });
351
+
352
+ req.on('error', (err) => {
353
+ reject(err);
354
+ });
355
+
356
+ req.end();
357
+ });
358
+ }
359
+
360
+ function isNativeFetch(fn) {
361
+ if (typeof fn !== 'function') return false;
362
+ if (fn.mock && typeof fn.mock === 'object') return false;
363
+ if (fn.constructor?.name === 'AsyncFunction') return false;
364
+ try {
365
+ const s = fn.toString();
366
+ return s.includes('undici') || s.includes('[native code]');
367
+ } catch {
368
+ return false;
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Fetches JSON from a URL with SSRF protection, connect-time rebinding guard, redirect verification, and size bounding (#411, #557).
264
374
  * @param {string} url
265
375
  * @param {object} options
266
376
  */
267
377
  export async function safeFetchJson(url, {
268
378
  signal,
269
- fetchImpl = globalThis.fetch,
379
+ fetchImpl,
270
380
  lookupImpl = dns.lookup,
271
381
  maxBytes = MAX_IMPORT_BYTES,
272
382
  redirectsLeft = MAX_REDIRECTS,
273
383
  dispatcher,
384
+ ca,
385
+ rejectUnauthorized,
274
386
  } = {}) {
275
- const safeDispatcher = dispatcher;
276
387
  let currentUrl = url;
388
+ const mockFetch = fetchImpl || (globalThis.fetch && !isNativeFetch(globalThis.fetch) ? globalThis.fetch : null);
389
+
390
+ const safeLookup = dispatcher?.connect?.lookup || dispatcher?.options?.connect?.lookup || createSafeLookup({});
391
+
277
392
  while (true) {
278
- await assertSafeUrl(currentUrl, { lookupImpl });
279
- const fetchOpts = {
280
- signal,
281
- redirect: 'manual',
282
- };
283
- if (safeDispatcher) fetchOpts.dispatcher = safeDispatcher;
284
- const resp = await fetchImpl(currentUrl, fetchOpts);
393
+ const urlObj = await assertSafeUrl(currentUrl, { lookupImpl });
394
+
395
+ // If custom test mock fetch is provided or globalThis.fetch was mocked in test
396
+ if (mockFetch) {
397
+ const resp = await mockFetch(currentUrl, { signal, redirect: 'manual', dispatcher });
398
+ if ([301, 302, 303, 307, 308].includes(resp.status)) {
399
+ if (redirectsLeft <= 0) {
400
+ const err = new Error('dsh-key-rotation: too many redirects');
401
+ err.code = 'TOO_MANY_REDIRECTS';
402
+ throw err;
403
+ }
404
+ redirectsLeft--;
405
+ const loc = resp.headers?.get?.('location');
406
+ if (!loc) {
407
+ const err = new Error('dsh-key-rotation: redirect missing Location header');
408
+ err.code = 'BAD_REDIRECT';
409
+ throw err;
410
+ }
411
+ currentUrl = new URL(loc, currentUrl).href;
412
+ continue;
413
+ }
414
+ if (!resp.ok) {
415
+ const err = new Error(`dsh-key-rotation: fetch returned ${resp.status}`);
416
+ err.code = 'FETCH_FAILED';
417
+ err.status = resp.status;
418
+ throw err;
419
+ }
420
+ return await readBoundedJson(resp, maxBytes);
421
+ }
422
+
423
+ // Native safe request with socket-connect lookup pinning against DNS rebinding (#557)
424
+ const result = await fetchNativeJson(urlObj, { lookup: safeLookup, maxBytes, signal, ca, rejectUnauthorized });
285
425
 
286
- if ([301, 302, 303, 307, 308].includes(resp.status)) {
426
+ if ([301, 302, 303, 307, 308].includes(result.status)) {
287
427
  if (redirectsLeft <= 0) {
288
428
  const err = new Error('dsh-key-rotation: too many redirects');
289
429
  err.code = 'TOO_MANY_REDIRECTS';
290
430
  throw err;
291
431
  }
292
432
  redirectsLeft--;
293
- const loc = resp.headers?.get?.('location');
433
+ const loc = result.headers?.get?.('location');
294
434
  if (!loc) {
295
435
  const err = new Error('dsh-key-rotation: redirect missing Location header');
296
436
  err.code = 'BAD_REDIRECT';
@@ -300,13 +440,6 @@ export async function safeFetchJson(url, {
300
440
  continue;
301
441
  }
302
442
 
303
- if (!resp.ok) {
304
- const err = new Error(`dsh-key-rotation: fetch returned ${resp.status}`);
305
- err.code = 'FETCH_FAILED';
306
- err.status = resp.status;
307
- throw err;
308
- }
309
-
310
- return await readBoundedJson(resp, maxBytes);
443
+ return result.data;
311
444
  }
312
445
  }