@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 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) throw new Error(data?.error?.message || 'Import apply failed');
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) {
@@ -349,15 +349,24 @@ export function registerConfigIoRoutes(ctx, deps) {
349
349
  }
350
350
  const rawCurrent = plainConfig(currentConfig);
351
351
 
352
- // Concurrency control: if client provided expectedRevision, reject if stale (#553)
353
- if (typeof body.expectedRevision === 'number' && currentRevision !== null) {
354
- if (body.expectedRevision !== currentRevision) {
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 an incoming HTTP request is from the Desktop shell (#516). */
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 true;
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) or valid Referer.
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 true;
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.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
  }
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-key-rotation",
3
- "version": "0.8.75",
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
- "scripts": {
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
+ }