@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/CHANGELOG.md +17 -0
- package/lib/atomic-io.js +47 -4
- package/lib/client.js +191 -1
- package/lib/concurrency.js +25 -5
- package/lib/index.js +2 -0
- package/lib/lifecycle.js +2 -0
- package/lib/ops-config-io.js +32 -5
- package/lib/ops-telemetry.js +3 -2
- package/lib/pool-builder.js +14 -0
- package/lib/pool.js +20 -5
- package/lib/request-budget.js +8 -1
- package/lib/rotate.js +628 -597
- package/lib/safe-fetch.js +188 -53
- package/package.json +2 -4
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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
|
}
|