@goodandready/dsh-key-rotation 0.8.75 → 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 +7 -0
- package/lib/client.js +13 -1
- package/lib/ops-config-io.js +32 -5
- package/lib/pool.js +20 -5
- package/lib/safe-fetch.js +161 -28
- package/package.json +11 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.76 (2026-10-30)
|
|
4
|
+
|
|
5
|
+
### Security Hardening, DNS Pinning & Mutation Integrity (Block 4 Release)
|
|
6
|
+
- **SSRF DNS Rebinding Prevention (Issue #557)**: Replaced single-shot DNS precheck with connect-time socket IP validation (`createSafeLookup`) via native HTTPS request handling (`fetchNativeJson`). Enforces loopback and private IP rejection at the exact moment the socket establishes connection across all attempts and redirects, completely neutralizing TOCTOU DNS rebinding with zero runtime dependencies.
|
|
7
|
+
- **Write-Origin Guard & Desktop Spoofing Elimination (Issue #516)**: Added `hasNonLoopbackForwarding` to detect requests proxied through LAN reverse proxies (`x-forwarded-for`, `x-real-ip`). Refused mutating requests (POST/PUT/DELETE/PATCH) lacking Origin or attempting to spoof Desktop headers (`@deepseek-ai/dsh-desktop/` User-Agent, `dsh-auth-` cookies, or `dsh-app://app` Origin) over LAN; ensured `/auto-import` strictly validates bodies and fails closed.
|
|
8
|
+
- **Config Import Optimistic Concurrency & Revision Integrity (Issue #553)**: End-to-end optimistic revision tracking in config import; dry-run preview returns settings revision, WebUI client (`50-card-main.js`) stores preview revision and transmits `expectedRevision` upon apply; backend validates revision match and converts `SETTINGS_CONFLICT` during `settings.replace` into HTTP 409 Conflict with clear preview invalidation guidance instead of generic 500 errors.
|
|
9
|
+
|
|
3
10
|
## 0.8.75 (2026-10-30)
|
|
4
11
|
|
|
5
12
|
### Dependencies, Cleanup & Audit Finalization (Block 3 Release)
|
package/lib/client.js
CHANGED
|
@@ -1316,6 +1316,7 @@ window.__ModuleLoader__.load({
|
|
|
1316
1316
|
const [importPassphrase, setImportPassphrase] = React.useState('');
|
|
1317
1317
|
const [importMode, setImportMode] = React.useState('merge');
|
|
1318
1318
|
const [importPreview, setImportPreview] = React.useState(null);
|
|
1319
|
+
const [importRevision, setImportRevision] = React.useState(null);
|
|
1319
1320
|
const [importError, setImportError] = React.useState('');
|
|
1320
1321
|
const [importRunning, setImportRunning] = React.useState(false);
|
|
1321
1322
|
|
|
@@ -1358,6 +1359,7 @@ window.__ModuleLoader__.load({
|
|
|
1358
1359
|
const runImportPreview = async () => {
|
|
1359
1360
|
setImportError('');
|
|
1360
1361
|
setImportPreview(null);
|
|
1362
|
+
setImportRevision(null);
|
|
1361
1363
|
setImportRunning(true);
|
|
1362
1364
|
try {
|
|
1363
1365
|
let parsed;
|
|
@@ -1381,6 +1383,7 @@ window.__ModuleLoader__.load({
|
|
|
1381
1383
|
const data = await res.json();
|
|
1382
1384
|
if (!res.ok || !data.ok) throw new Error(data?.error?.message || 'Import preview failed');
|
|
1383
1385
|
setImportPreview(data.preview);
|
|
1386
|
+
setImportRevision(data.revision ?? null);
|
|
1384
1387
|
} catch (err) {
|
|
1385
1388
|
setImportError(String(err?.message ?? err));
|
|
1386
1389
|
} finally {
|
|
@@ -1406,14 +1409,23 @@ window.__ModuleLoader__.load({
|
|
|
1406
1409
|
encrypted: parsed.encrypted,
|
|
1407
1410
|
passphrase: importPassphrase.trim() || undefined,
|
|
1408
1411
|
mode: importMode,
|
|
1412
|
+
expectedRevision: importRevision !== null ? importRevision : undefined,
|
|
1409
1413
|
dryRun: false,
|
|
1410
1414
|
}),
|
|
1411
1415
|
signal: (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') ? AbortSignal.timeout(10000) : undefined,
|
|
1412
1416
|
});
|
|
1413
1417
|
const data = await res.json();
|
|
1414
|
-
if (!res.ok || !data.ok)
|
|
1418
|
+
if (!res.ok || !data.ok) {
|
|
1419
|
+
if (res.status === 409 || data?.error?.code === 'conflict') {
|
|
1420
|
+
setImportPreview(null);
|
|
1421
|
+
setImportRevision(null);
|
|
1422
|
+
throw new Error(data?.error?.message || 'Configuration was modified concurrently. Please preview again before applying.');
|
|
1423
|
+
}
|
|
1424
|
+
throw new Error(data?.error?.message || 'Import apply failed');
|
|
1425
|
+
}
|
|
1415
1426
|
setImportModal(false);
|
|
1416
1427
|
setImportPreview(null);
|
|
1428
|
+
setImportRevision(null);
|
|
1417
1429
|
setImportText('');
|
|
1418
1430
|
void load();
|
|
1419
1431
|
} catch (err) {
|
package/lib/ops-config-io.js
CHANGED
|
@@ -349,15 +349,24 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
349
349
|
}
|
|
350
350
|
const rawCurrent = plainConfig(currentConfig);
|
|
351
351
|
|
|
352
|
-
// Concurrency control:
|
|
353
|
-
if (
|
|
354
|
-
if (body.expectedRevision
|
|
352
|
+
// Concurrency control: validate expectedRevision format and match (#553)
|
|
353
|
+
if (body.expectedRevision !== undefined && body.expectedRevision !== null) {
|
|
354
|
+
if (!Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) {
|
|
355
|
+
json(res, 400, {
|
|
356
|
+
error: {
|
|
357
|
+
code: 'bad-request',
|
|
358
|
+
message: 'expectedRevision must be a non-negative integer',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (currentRevision !== null && body.expectedRevision !== currentRevision) {
|
|
355
364
|
json(res, 409, {
|
|
356
365
|
error: {
|
|
357
366
|
code: 'conflict',
|
|
358
367
|
message: `Configuration has been modified concurrently (expected revision ${body.expectedRevision}, current is ${currentRevision})`,
|
|
359
368
|
currentRevision,
|
|
360
|
-
}
|
|
369
|
+
},
|
|
361
370
|
});
|
|
362
371
|
return;
|
|
363
372
|
}
|
|
@@ -406,8 +415,26 @@ export function registerConfigIoRoutes(ctx, deps) {
|
|
|
406
415
|
}
|
|
407
416
|
|
|
408
417
|
try {
|
|
409
|
-
await settings.replace(NS, mergedConfig, currentRevision ?? undefined);
|
|
418
|
+
await settings.replace(NS, mergedConfig, body.expectedRevision ?? currentRevision ?? undefined);
|
|
410
419
|
} catch (err) {
|
|
420
|
+
const isConflict = err?.code === 'SETTINGS_CONFLICT' ||
|
|
421
|
+
err?.status === 409 ||
|
|
422
|
+
err?.code === 'CONFLICT' ||
|
|
423
|
+
/conflict|revision|stale/i.test(err?.message || '');
|
|
424
|
+
if (isConflict) {
|
|
425
|
+
let latestRevision = null;
|
|
426
|
+
try {
|
|
427
|
+
latestRevision = settings.describe({ redactSecrets: false })?.find((c) => c.ns === NS)?.revision ?? null;
|
|
428
|
+
} catch (_) {}
|
|
429
|
+
json(res, 409, {
|
|
430
|
+
error: {
|
|
431
|
+
code: 'conflict',
|
|
432
|
+
message: `Configuration has been modified concurrently: ${err.message}`,
|
|
433
|
+
currentRevision: latestRevision ?? currentRevision,
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
411
438
|
json(res, 500, { error: { code: 'settings-write-failed', message: err.message } });
|
|
412
439
|
return;
|
|
413
440
|
}
|
package/lib/pool.js
CHANGED
|
@@ -39,8 +39,23 @@ export function isLoopbackAddress(ip) {
|
|
|
39
39
|
return false;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Check if
|
|
42
|
+
/** Check if request contains non-loopback forwarding headers (e.g. proxied from LAN/Tailscale) (#516). */
|
|
43
|
+
export function hasNonLoopbackForwarding(req) {
|
|
44
|
+
const xff = req?.headers?.['x-forwarded-for'];
|
|
45
|
+
if (xff && typeof xff === 'string') {
|
|
46
|
+
const ips = xff.split(',').map((s) => s.trim());
|
|
47
|
+
if (ips.some((ip) => ip && !isLoopbackAddress(ip))) return true;
|
|
48
|
+
}
|
|
49
|
+
const xRealIp = req?.headers?.['x-real-ip'];
|
|
50
|
+
if (xRealIp && typeof xRealIp === 'string' && !isLoopbackAddress(xRealIp.trim())) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check if an incoming HTTP request is from an authentic local Desktop shell (#516). */
|
|
43
57
|
export function isDesktopShellRequest(req) {
|
|
58
|
+
if (hasNonLoopbackForwarding(req)) return false;
|
|
44
59
|
const agent = req?.headers?.['user-agent'] || '';
|
|
45
60
|
const cookie = req?.headers?.cookie || '';
|
|
46
61
|
return typeof agent === 'string' && agent.includes('@deepseek-ai/dsh-desktop/') &&
|
|
@@ -69,9 +84,9 @@ export function isTrustedBridgeRequest(req) {
|
|
|
69
84
|
if (!origin) return false; // Explicit empty string or null-like falsy
|
|
70
85
|
try {
|
|
71
86
|
const originUrl = new URL(origin);
|
|
72
|
-
// Official Desktop build shell origin (#22, #465, #516)
|
|
87
|
+
// Official Desktop build shell origin (#22, #465, #516) - requires authentic local Desktop shell (rejects LAN forwarding)
|
|
73
88
|
if (originUrl.protocol === 'dsh-app:' && (originUrl.host === 'app' || originUrl.hostname === 'app' || originUrl.host === 'localhost')) {
|
|
74
|
-
return
|
|
89
|
+
return !hasNonLoopbackForwarding(req);
|
|
75
90
|
}
|
|
76
91
|
if (originUrl.host !== hostHeader) return false;
|
|
77
92
|
const hostname = originUrl.hostname.replace(/^\[|\]$/g, '');
|
|
@@ -89,7 +104,7 @@ export function isTrustedBridgeRequest(req) {
|
|
|
89
104
|
return true;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
|
-
// Mutating requests without Origin: allow authentic Desktop shell requests (#516, #465)
|
|
107
|
+
// Mutating requests without Origin: allow authentic local Desktop shell requests (#516, #465)
|
|
93
108
|
if (isDesktopShellRequest(req)) {
|
|
94
109
|
return true;
|
|
95
110
|
}
|
|
@@ -98,7 +113,7 @@ export function isTrustedBridgeRequest(req) {
|
|
|
98
113
|
try {
|
|
99
114
|
const refUrl = new URL(referer);
|
|
100
115
|
if (refUrl.protocol === 'dsh-app:' && (refUrl.host === 'app' || refUrl.hostname === 'app' || refUrl.host === 'localhost')) {
|
|
101
|
-
return
|
|
116
|
+
return !hasNonLoopbackForwarding(req);
|
|
102
117
|
}
|
|
103
118
|
if (refUrl.host !== hostHeader) return false;
|
|
104
119
|
const hostname = refUrl.hostname.replace(/^\[|\]$/g, '');
|
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
|
|
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
|
|
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 {
|
|
108
|
+
* @returns {Function}
|
|
105
109
|
*/
|
|
106
|
-
export function
|
|
107
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-key-rotation",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.76",
|
|
4
|
+
"packageManager": "pnpm@10.33.2",
|
|
4
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.",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"deepseek-harness",
|
|
@@ -59,6 +60,13 @@
|
|
|
59
60
|
"@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || >=0.2.0-rc.1 <0.2.1-0 || >=0.2.1-alpha.1 <0.2.2-0",
|
|
60
61
|
"@deepseek-ai/schemastery": "^3.18.4"
|
|
61
62
|
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"test": "node tools/test.mjs",
|
|
65
|
+
"test:core": "node tools/test.mjs --core",
|
|
66
|
+
"check": "node tools/check.mjs",
|
|
67
|
+
"build:client": "node scripts/build-client.mjs",
|
|
68
|
+
"pretest": "node scripts/build-client.mjs"
|
|
69
|
+
},
|
|
62
70
|
"devDependencies": {
|
|
63
71
|
"react": "18.3.1",
|
|
64
72
|
"react-test-renderer": "18.3.1",
|
|
@@ -67,12 +75,5 @@
|
|
|
67
75
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
68
76
|
"semver": "^7.6.3"
|
|
69
77
|
},
|
|
70
|
-
"dependencies": {}
|
|
71
|
-
|
|
72
|
-
"test": "node tools/test.mjs",
|
|
73
|
-
"test:core": "node tools/test.mjs --core",
|
|
74
|
-
"check": "node tools/check.mjs",
|
|
75
|
-
"build:client": "node scripts/build-client.mjs",
|
|
76
|
-
"pretest": "node scripts/build-client.mjs"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
78
|
+
"dependencies": {}
|
|
79
|
+
}
|