@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/CHANGELOG.md +14 -0
- package/lib/client.js +13 -1
- package/lib/error-taxonomy.js +59 -10
- package/lib/errors.js +302 -0
- package/lib/index.js +3 -0
- package/lib/ops-config-io.js +32 -5
- package/lib/pool-selection.js +92 -17
- package/lib/pool.js +39 -5
- package/lib/rotate.js +61 -11
- package/lib/safe-fetch.js +161 -28
- package/lib/session-router.js +265 -0
- package/package.json +1 -1
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|