@goodandready/dsh-key-rotation 0.8.74 → 0.8.76

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/safe-fetch.js CHANGED
@@ -1,8 +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 { Agent } from 'undici';
5
+ import https from 'node:https';
6
+ import http from 'node:http';
7
+ import { URL } from 'node:url';
6
8
 
7
9
  export const MAX_IMPORT_BYTES = 1024 * 1024; // 1 MB limit
8
10
  export const MAX_REDIRECTS = 5;
@@ -31,7 +33,7 @@ export function parseIpv6Words(ip) {
31
33
  const halves = s.split('::');
32
34
  if (halves.length > 2) return null;
33
35
  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)) : [];
36
+ const right = halves[1] && halves[1] ? halves[1].split(':').map(x => parseInt(x, 16)) : [];
35
37
  if (left.some(isNaN) || right.some(isNaN)) return null;
36
38
  const missing = 8 - (left.length + right.length);
37
39
  if (halves.length === 1 && missing !== 0) return null;
@@ -99,45 +101,61 @@ export function isPrivateOrReservedIp(ip) {
99
101
  }
100
102
 
101
103
  /**
102
- * 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.
103
106
  * @param {object} [opts]
104
107
  * @param {Function} [opts.lookupImpl]
105
- * @returns {Agent}
108
+ * @returns {Function}
109
+ */
110
+ export function createSafeLookup({ lookupImpl = nodeDns.lookup } = {}) {
111
+ return function safeLookup(hostname, options, callback) {
112
+ const cb = typeof options === 'function' ? options : callback;
113
+ const opts = typeof options === 'function' ? {} : (typeof options === 'number' ? { family: options } : (options || {}));
114
+ lookupImpl(hostname, { ...opts, all: true }, (err, addresses) => {
115
+ if (err) return cb(err);
116
+ const list = Array.isArray(addresses) ? addresses : [addresses];
117
+ for (const item of list) {
118
+ const addr = typeof item === 'string' ? item : item.address;
119
+ if (isPrivateOrReservedIp(addr)) {
120
+ const ssrfErr = new Error(`dsh-key-rotation: connect blocked: host ${hostname} resolves to private or reserved IP ${addr}`);
121
+ ssrfErr.code = 'SSRF_BLOCKED';
122
+ return cb(ssrfErr);
123
+ }
124
+ }
125
+ if (!opts.all) {
126
+ const first = list[0];
127
+ const addr = typeof first === 'string' ? first : first?.address;
128
+ const fam = typeof first === 'object' && first?.family ? first.family : (net.isIP(addr) || 4);
129
+ return cb(null, addr, fam);
130
+ }
131
+ return cb(null, addresses);
132
+ });
133
+ };
134
+ }
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}
106
141
  */
107
142
  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' ? {} : (typeof options === 'number' ? { family: options } : (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 (!opts.all) {
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
- },
143
+ const lookup = createSafeLookup({ lookupImpl });
144
+ return {
145
+ options: {
146
+ connect: { lookup },
133
147
  },
134
- });
148
+ connect: { lookup },
149
+ };
135
150
  }
136
151
 
137
152
  /**
138
- * 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.
139
155
  * @param {string} urlString
140
- * @param {object} options
156
+ * @param {object} [options]
157
+ * @param {Function} [options.lookupImpl]
158
+ * @returns {Promise<URL>}
141
159
  */
142
160
  export async function assertSafeUrl(urlString, { lookupImpl = dns.lookup } = {}) {
143
161
  let urlObj;
@@ -258,37 +276,161 @@ export async function readBoundedJson(resp, maxBytes = MAX_IMPORT_BYTES) {
258
276
  }
259
277
 
260
278
  /**
261
- * 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).
262
374
  * @param {string} url
263
375
  * @param {object} options
264
376
  */
265
377
  export async function safeFetchJson(url, {
266
378
  signal,
267
- fetchImpl = globalThis.fetch,
379
+ fetchImpl,
268
380
  lookupImpl = dns.lookup,
269
381
  maxBytes = MAX_IMPORT_BYTES,
270
382
  redirectsLeft = MAX_REDIRECTS,
271
383
  dispatcher,
384
+ ca,
385
+ rejectUnauthorized,
272
386
  } = {}) {
273
- const safeDispatcher = dispatcher ?? (fetchImpl === globalThis.fetch ? createSafeDispatcher() : undefined);
274
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
+
275
392
  while (true) {
276
- await assertSafeUrl(currentUrl, { lookupImpl });
277
- const fetchOpts = {
278
- signal,
279
- redirect: 'manual',
280
- };
281
- if (safeDispatcher) fetchOpts.dispatcher = safeDispatcher;
282
- const resp = await fetchImpl(currentUrl, fetchOpts);
393
+ const urlObj = await assertSafeUrl(currentUrl, { lookupImpl });
283
394
 
284
- if ([301, 302, 303, 307, 308].includes(resp.status)) {
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 });
425
+
426
+ if ([301, 302, 303, 307, 308].includes(result.status)) {
285
427
  if (redirectsLeft <= 0) {
286
428
  const err = new Error('dsh-key-rotation: too many redirects');
287
429
  err.code = 'TOO_MANY_REDIRECTS';
288
430
  throw err;
289
431
  }
290
432
  redirectsLeft--;
291
- const loc = resp.headers?.get?.('location');
433
+ const loc = result.headers?.get?.('location');
292
434
  if (!loc) {
293
435
  const err = new Error('dsh-key-rotation: redirect missing Location header');
294
436
  err.code = 'BAD_REDIRECT';
@@ -298,13 +440,6 @@ export async function safeFetchJson(url, {
298
440
  continue;
299
441
  }
300
442
 
301
- if (!resp.ok) {
302
- const err = new Error(`dsh-key-rotation: fetch returned ${resp.status}`);
303
- err.code = 'FETCH_FAILED';
304
- err.status = resp.status;
305
- throw err;
306
- }
307
-
308
- return await readBoundedJson(resp, maxBytes);
443
+ return result.data;
309
444
  }
310
445
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.74",
3
+ "version": "0.8.76",
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": [
@@ -75,7 +75,5 @@
75
75
  "@deepseek-ai/schemastery": "^3.18.4",
76
76
  "semver": "^7.6.3"
77
77
  },
78
- "dependencies": {
79
- "undici": "^6.29.0"
80
- }
78
+ "dependencies": {}
81
79
  }