dsh-ssh-tunnel 1.0.0

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/session.js ADDED
@@ -0,0 +1,525 @@
1
+ /**
2
+ * In-memory SSH session registry: live / tombstone / close.
3
+ * Transport connect (host-key prompts) is injected as connectClient.
4
+ */
5
+ import { randomUUID } from 'node:crypto'
6
+ import { chunkFromBuffer } from './shared/shell-buffer.js'
7
+ import { normalizeProjectKey } from './shared/path.js'
8
+ import {
9
+ SSH_EXEC_DEFAULT_TIMEOUT_MS,
10
+ SSH_READY_TIMEOUT_MS,
11
+ SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
12
+ SSH_RECONNECT_DELAYS_MS,
13
+ SSH_RECONNECT_MAX_ATTEMPTS,
14
+ SSH_SFTP_META_TIMEOUT_MS,
15
+ SSH_SHELL_OPEN_TIMEOUT_MS,
16
+ SSH_STATUS_CONNECTED,
17
+ SSH_STATUS_DISCONNECTED,
18
+ SSH_STATUS_RECONNECTING,
19
+ clampTimeoutMs,
20
+ findReusableLive,
21
+ isLiveStatus,
22
+ kbiReconnectError,
23
+ kbiUnsupportedError,
24
+ withTimeout,
25
+ } from './shared/session-policy.js'
26
+
27
+ const MAX_OUT = 512 * 1024
28
+
29
+ async function openSftp(client, opts = {}) {
30
+ return withTimeout(
31
+ new Promise((resolve, reject) => {
32
+ client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
33
+ }),
34
+ opts.timeoutMs ?? SSH_SFTP_META_TIMEOUT_MS,
35
+ { signal: opts.signal },
36
+ )
37
+ }
38
+
39
+ function applyStatus(rec, status) {
40
+ rec.status = status
41
+ rec.running = status === SSH_STATUS_CONNECTED
42
+ rec.updatedAt = Date.now()
43
+ }
44
+
45
+ function tombstone(rec) {
46
+ if (rec.closing) return
47
+ applyStatus(rec, SSH_STATUS_DISCONNECTED)
48
+ try {
49
+ rec.shellStream?.close()
50
+ } catch {}
51
+ rec.shellStream = null
52
+ rec.sftp = null
53
+ rec.client = null
54
+ }
55
+
56
+ function attachTransport(rec, client, onDrop) {
57
+ const handle = () => {
58
+ if (rec.closing) return
59
+ if (rec.client !== client) return
60
+ onDrop(rec, client)
61
+ }
62
+ client.on('close', handle)
63
+ client.on('end', handle)
64
+ client.on('error', handle)
65
+ }
66
+
67
+ function sleep(ms, signal) {
68
+ return new Promise((resolve, reject) => {
69
+ const timer = setTimeout(resolve, ms)
70
+ const onAbort = () => {
71
+ clearTimeout(timer)
72
+ const err = new Error('Cancelled')
73
+ err.aborted = true
74
+ reject(err)
75
+ }
76
+ if (!signal) return
77
+ if (signal.aborted) {
78
+ onAbort()
79
+ return
80
+ }
81
+ signal.addEventListener('abort', onAbort, { once: true })
82
+ })
83
+ }
84
+
85
+ function sessionSummary(s) {
86
+ return {
87
+ session_id: s.id,
88
+ host_id: s.hostId,
89
+ projectPathKey: s.projectPathKey,
90
+ status: s.status,
91
+ sftpEnabled: !!s.sftpEnabled,
92
+ title: s.title,
93
+ running: !!s.running,
94
+ created_at: s.createdAt,
95
+ updated_at: s.updatedAt,
96
+ endpoint: s.endpoint || '',
97
+ reconnect_attempt: s.reconnectAttempt || 0,
98
+ reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS,
99
+ }
100
+ }
101
+
102
+ function makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now }) {
103
+ const outputBuf = []
104
+ let outputBytes = 0
105
+ const rec = {
106
+ id,
107
+ hostId: host.id,
108
+ projectPathKey: normalizeProjectKey(projectPathKey),
109
+ status: SSH_STATUS_CONNECTED,
110
+ sftpEnabled: !!sftp,
111
+ title: title || `SSH: ${host.name || host.host}`,
112
+ running: true,
113
+ createdAt: now,
114
+ updatedAt: now,
115
+ endpoint,
116
+ client,
117
+ sftp,
118
+ shellStream: null,
119
+ closing: false,
120
+ reconnectRunner: false,
121
+ reconnectAttempt: 0,
122
+ connectionGeneration: 0,
123
+ outputBuf,
124
+ outputSeq: 0,
125
+ pushOutput(chunk) {
126
+ const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
127
+ outputBuf.push(s)
128
+ outputBytes += Buffer.byteLength(s)
129
+ rec.outputSeq = (rec.outputSeq || 0) + 1
130
+ while (outputBytes > MAX_OUT && outputBuf.length > 1) {
131
+ outputBytes -= Buffer.byteLength(outputBuf.shift())
132
+ }
133
+ },
134
+ getOutputText() {
135
+ return outputBuf.join('')
136
+ },
137
+ chunkFrom(since) {
138
+ const full = outputBuf.join('')
139
+ const sliced = chunkFromBuffer(full, since)
140
+ return { full, chunk: sliced.chunk, length: sliced.length, since: sliced.since }
141
+ },
142
+ }
143
+ return rec
144
+ }
145
+
146
+ export function createSessionRegistry({
147
+ connectClient,
148
+ loadSecrets,
149
+ getPrompt,
150
+ getHostById,
151
+ isHostAuthorized,
152
+ }) {
153
+ const sessions = new Map()
154
+
155
+ function listSessionsForProject(projectPathKey) {
156
+ const key = normalizeProjectKey(projectPathKey)
157
+ return [...sessions.values()].filter((s) => s.projectPathKey === key).map(sessionSummary)
158
+ }
159
+
160
+ function findReusableSession(projectPathKey, hostId, needsSftp) {
161
+ return findReusableLive([...sessions.values()], {
162
+ projectPathKey: normalizeProjectKey(projectPathKey),
163
+ hostId,
164
+ needsSftp,
165
+ })
166
+ }
167
+
168
+ async function createLiveSession(params) {
169
+ const { host, projectPathKey, title, sftpEnabled, trustHostKey } = params
170
+ const secrets = loadSecrets()
171
+ const id = randomUUID()
172
+ const now = Date.now()
173
+ const endpoint = `${host.username}@${host.host}:${host.port || 22}`
174
+
175
+ if (host.authType === 'keyboardInteractive') {
176
+ throw new Error(kbiUnsupportedError())
177
+ }
178
+
179
+ let client
180
+ try {
181
+ client = await withTimeout(
182
+ connectClient(host, secrets, {
183
+ trustHostKey: !!trustHostKey,
184
+ }),
185
+ SSH_READY_TIMEOUT_MS,
186
+ { signal: params.signal },
187
+ )
188
+ } catch (e) {
189
+ if (e && e.message === 'SSH_PROMPT') {
190
+ return {
191
+ ok: false,
192
+ sshPrompt: getPrompt?.(e.promptId),
193
+ promptId: e.promptId,
194
+ message: '请先在 SSH 隧道 Tab 手动完成连接/信任/MFA 后重试。',
195
+ }
196
+ }
197
+ throw e
198
+ }
199
+
200
+ let sftp = null
201
+ if (sftpEnabled !== false) {
202
+ try {
203
+ sftp = await openSftp(client)
204
+ } catch {
205
+ sftp = null
206
+ }
207
+ }
208
+
209
+ const rec = makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now })
210
+ rec.connectionGeneration += 1
211
+ attachTransport(rec, client, onTransportDrop)
212
+ sessions.set(id, rec)
213
+ return { ok: true, session: sessionSummary(rec) }
214
+ }
215
+
216
+ function markReconnecting(rec, attempt) {
217
+ rec.reconnectAttempt = attempt
218
+ applyStatus(rec, SSH_STATUS_RECONNECTING)
219
+ rec.sftp = null
220
+ rec.shellStream = null
221
+ }
222
+
223
+ async function reconnectOnce(rec, { signal } = {}) {
224
+ if (rec.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
225
+ throw new Error('SSH session host is not authorized for the current project.')
226
+ }
227
+ const host = getHostById?.(rec.hostId)
228
+ if (!host) throw new Error('host not found')
229
+ if (host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
230
+ const secrets = loadSecrets()
231
+ try {
232
+ rec.shellStream?.close()
233
+ } catch {}
234
+ rec.shellStream = null
235
+ const oldClient = rec.client
236
+ rec.client = null
237
+ try {
238
+ oldClient?.end()
239
+ } catch {}
240
+ const client = await withTimeout(
241
+ connectClient(host, secrets, { trustHostKey: false }),
242
+ SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
243
+ { signal },
244
+ )
245
+ if (rec.closing) {
246
+ try { client.end() } catch {}
247
+ throw new Error('SSH session is closing')
248
+ }
249
+ let sftp = null
250
+ if (rec.sftpEnabled) {
251
+ try {
252
+ sftp = await openSftp(client)
253
+ } catch {
254
+ sftp = null
255
+ }
256
+ }
257
+ if (rec.closing) {
258
+ try { client.end() } catch {}
259
+ throw new Error('SSH session is closing')
260
+ }
261
+ rec.client = client
262
+ rec.sftp = sftp
263
+ rec.connectionGeneration += 1
264
+ rec.reconnectAttempt = 0
265
+ applyStatus(rec, SSH_STATUS_CONNECTED)
266
+ attachTransport(rec, client, onTransportDrop)
267
+ return sessionSummary(rec)
268
+ }
269
+
270
+ async function runAutoReconnect(rec) {
271
+ if (rec.closing || rec.reconnectRunner) return
272
+ const host = getHostById?.(rec.hostId)
273
+ if (host && host.authType === 'keyboardInteractive') {
274
+ tombstone(rec)
275
+ return
276
+ }
277
+ rec.reconnectRunner = true
278
+ try {
279
+ for (let attempt = 1; attempt <= SSH_RECONNECT_MAX_ATTEMPTS; attempt++) {
280
+ if (rec.closing) return
281
+ if (
282
+ rec.projectPathKey &&
283
+ typeof isHostAuthorized === 'function' &&
284
+ !isHostAuthorized(rec.projectPathKey, rec.hostId)
285
+ ) {
286
+ // Host was revoked while disconnected: tombstone it, never resurrect.
287
+ tombstone(rec)
288
+ return
289
+ }
290
+ markReconnecting(rec, attempt)
291
+ const delay = SSH_RECONNECT_DELAYS_MS[attempt - 1] || SSH_RECONNECT_DELAYS_MS[SSH_RECONNECT_DELAYS_MS.length - 1]
292
+ try {
293
+ await sleep(delay)
294
+ } catch {
295
+ return
296
+ }
297
+ if (rec.closing) return
298
+ try {
299
+ await reconnectOnce(rec)
300
+ return
301
+ } catch {
302
+ // try next attempt
303
+ }
304
+ }
305
+ tombstone(rec)
306
+ } finally {
307
+ rec.reconnectRunner = false
308
+ }
309
+ }
310
+
311
+ function onTransportDrop(rec) {
312
+ if (rec.closing) return
313
+ const host = getHostById?.(rec.hostId)
314
+ if (host && host.authType === 'keyboardInteractive') {
315
+ tombstone(rec)
316
+ return
317
+ }
318
+ void runAutoReconnect(rec)
319
+ }
320
+
321
+ async function reconnectSession(sessionId, { manual = true, signal, projectPathKey } = {}) {
322
+ const rec = sessions.get(String(sessionId || ''))
323
+ if (!rec) throw new Error('session not found')
324
+ const key = normalizeProjectKey(projectPathKey)
325
+ if (!key) throw new Error('projectPathKey required')
326
+ if (rec.projectPathKey !== key) throw new Error('session not in this project')
327
+ if (rec.closing) throw new Error('SSH session is closing')
328
+ if (isLiveStatus(rec.status, rec.running) && rec.client) return { ok: true, session: sessionSummary(rec) }
329
+ if (rec.reconnectRunner) throw new Error('SSH reconnect already in progress')
330
+ const host = getHostById?.(rec.hostId)
331
+ if (host && host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
332
+ rec.reconnectRunner = true
333
+ try {
334
+ markReconnecting(rec, 1)
335
+ const session = await reconnectOnce(rec, { signal })
336
+ return { ok: true, session }
337
+ } catch (e) {
338
+ tombstone(rec)
339
+ throw e
340
+ } finally {
341
+ rec.reconnectRunner = false
342
+ }
343
+ }
344
+
345
+ function waitForLive(sessionId, timeoutMs) {
346
+ const rec = sessions.get(String(sessionId || ''))
347
+ if (!rec) return Promise.reject(new Error('session not found'))
348
+ if (isLiveStatus(rec.status, rec.running) && rec.client) return Promise.resolve(rec)
349
+ const deadline = Date.now() + (Number(timeoutMs) || SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
350
+ return new Promise((resolve, reject) => {
351
+ const tick = () => {
352
+ const cur = sessions.get(String(sessionId || ''))
353
+ if (!cur) {
354
+ reject(new Error('session not found'))
355
+ return
356
+ }
357
+ if (isLiveStatus(cur.status, cur.running) && cur.client) {
358
+ resolve(cur)
359
+ return
360
+ }
361
+ if (cur.status === SSH_STATUS_DISCONNECTED || Date.now() >= deadline) {
362
+ reject(new Error('session not running'))
363
+ return
364
+ }
365
+ setTimeout(tick, 100)
366
+ }
367
+ tick()
368
+ })
369
+ }
370
+
371
+ function requireLiveSession(sessionId, projectPathKey) {
372
+ const s = sessions.get(String(sessionId || ''))
373
+ if (!s) throw new Error('session not found')
374
+ const key = normalizeProjectKey(projectPathKey)
375
+ if (!key) throw new Error('projectPathKey required')
376
+ if (s.projectPathKey !== key) throw new Error('session not in this project')
377
+ if (!isLiveStatus(s.status, s.running) || !s.client) throw new Error('session not running')
378
+ // Grant revocation must take effect on established sessions, not only new
379
+ // connections. The session is bound to one project; re-check it on every use.
380
+ if (s.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(s.projectPathKey, s.hostId)) {
381
+ throw new Error('SSH session host is not authorized for the current project.')
382
+ }
383
+ return s
384
+ }
385
+
386
+ function closeSessionsForHost(projectPathKey, hostId) {
387
+ const key = normalizeProjectKey(projectPathKey)
388
+ const closed = []
389
+ for (const s of [...sessions.values()]) {
390
+ if (s.projectPathKey === key && s.hostId === String(hostId) && !s.closing) {
391
+ closeSession(s.id, s.projectPathKey, { force: true })
392
+ closed.push(s.id)
393
+ }
394
+ }
395
+ return { closed: closed.length, sessionIds: closed }
396
+ }
397
+
398
+ function closeSession(sessionId, projectPathKey, { force = false } = {}) {
399
+ const s = sessions.get(sessionId)
400
+ if (!s) return { ok: false, error: 'session not found' }
401
+ if (!force) {
402
+ const key = normalizeProjectKey(projectPathKey)
403
+ if (!key) throw new Error('projectPathKey required')
404
+ if (s.projectPathKey !== key) throw new Error('session not in this project')
405
+ }
406
+ s.closing = true
407
+ try {
408
+ if (s.shellStream) s.shellStream.close()
409
+ } catch {}
410
+ try {
411
+ s.client?.end()
412
+ } catch {}
413
+ applyStatus(s, SSH_STATUS_DISCONNECTED)
414
+ sessions.delete(sessionId)
415
+ return { ok: true }
416
+ }
417
+
418
+ async function execOnSession(session, command, opts = {}) {
419
+ const client = session.client
420
+ if (!isLiveStatus(session.status, session.running) || !client) {
421
+ throw new Error('session not running')
422
+ }
423
+ const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_EXEC_DEFAULT_TIMEOUT_MS)
424
+ const maxBytes = opts.maxBytes || 256 * 1024
425
+ const cwd = opts.cwd
426
+ const cmd =
427
+ cwd && String(cwd).startsWith('/')
428
+ ? `cd -- ${JSON.stringify(cwd)} && ${command}`
429
+ : command
430
+ let stream
431
+ return withTimeout(
432
+ new Promise((resolve, reject) => {
433
+ client.exec(cmd, (err, st) => {
434
+ if (err) {
435
+ reject(err)
436
+ return
437
+ }
438
+ stream = st
439
+ let stdout = ''
440
+ let stderr = ''
441
+ stream.on('data', (d) => {
442
+ stdout += d
443
+ if (Buffer.byteLength(stdout) > maxBytes) stdout = stdout.slice(0, maxBytes)
444
+ })
445
+ stream.stderr.on('data', (d) => {
446
+ stderr += d
447
+ if (Buffer.byteLength(stderr) > maxBytes) stderr = stderr.slice(0, maxBytes)
448
+ })
449
+ stream.on('close', (code, sig) => {
450
+ resolve({ stdout, stderr, code: code ?? 0, signal: sig || null })
451
+ })
452
+ stream.on('error', reject)
453
+ })
454
+ }),
455
+ timeoutMs,
456
+ {
457
+ signal: opts.signal,
458
+ onTimeout: () => {
459
+ try {
460
+ stream?.close()
461
+ } catch {}
462
+ try {
463
+ stream?.destroy?.()
464
+ } catch {}
465
+ },
466
+ },
467
+ )
468
+ }
469
+
470
+ async function ensureShell(session, opts = {}) {
471
+ if (session.shellStream) return session.shellStream
472
+ if (!session.client) throw new Error('session not running')
473
+ const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_SHELL_OPEN_TIMEOUT_MS)
474
+ let opened
475
+ let cancelled = false
476
+ return withTimeout(
477
+ new Promise((resolve, reject) => {
478
+ session.client.shell({ term: 'xterm-color' }, (err, stream) => {
479
+ if (cancelled) {
480
+ try { stream?.close() } catch {}
481
+ return
482
+ }
483
+ if (err) return reject(err)
484
+ opened = stream
485
+ session.shellStream = stream
486
+ stream.on('data', (d) => session.pushOutput(d))
487
+ stream.stderr?.on?.('data', (d) => session.pushOutput(d))
488
+ stream.on('close', () => {
489
+ session.shellStream = null
490
+ })
491
+ resolve(stream)
492
+ })
493
+ }),
494
+ timeoutMs,
495
+ {
496
+ signal: opts.signal,
497
+ onTimeout: () => {
498
+ cancelled = true
499
+ try { opened?.close() } catch {}
500
+ try { opened?.destroy?.() } catch {}
501
+ if (session.shellStream === opened) session.shellStream = null
502
+ },
503
+ },
504
+ )
505
+ }
506
+
507
+ return {
508
+ sessions,
509
+ sessionSummary,
510
+ listSessionsForProject,
511
+ findReusableSession,
512
+ createLiveSession,
513
+ requireLiveSession,
514
+ closeSession,
515
+ closeSessionsForHost,
516
+ execOnSession,
517
+ ensureShell,
518
+ openSftp,
519
+ reconnectSession,
520
+ waitForLive,
521
+ get(id) {
522
+ return sessions.get(id)
523
+ },
524
+ }
525
+ }
@@ -0,0 +1,32 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /** True when stored value is a sha256 hex digest (not legacy raw-key String(Buffer)). */
4
+ export function isUsableHostFingerprint(fp) {
5
+ return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp.trim().toLowerCase())
6
+ }
7
+
8
+ /**
9
+ * Normalize ssh2 hostVerifier input to a stable sha256 hex fingerprint.
10
+ * - With connect option hostHash:'sha256', key is already a hex string.
11
+ * - Raw Buffer (no hostHash) is hashed here as a safe fallback.
12
+ */
13
+ export function fingerprintFromHostKey(key) {
14
+ if (key == null) return ''
15
+ if (typeof key === 'string') {
16
+ const s = key.trim().toLowerCase()
17
+ if (/^[0-9a-f]{64}$/.test(s)) return s
18
+ if (/^[0-9a-f]+$/.test(s) && s.length >= 32) return createHash('sha256').update(Buffer.from(s, 'hex')).digest('hex')
19
+ return createHash('sha256').update(Buffer.from(key, 'utf8')).digest('hex')
20
+ }
21
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(key)) {
22
+ return createHash('sha256').update(key).digest('hex')
23
+ }
24
+ return createHash('sha256').update(Buffer.from(String(key))).digest('hex')
25
+ }
26
+
27
+ /** Human-facing label for trust prompts / logs. */
28
+ export function formatHostFingerprint(fp, algo = 'SHA256') {
29
+ if (!fp) return ''
30
+ const hex = String(fp).trim().toLowerCase()
31
+ return `${algo}:${hex}`
32
+ }
@@ -0,0 +1,75 @@
1
+ /** Credential presence without exposing secret material. */
2
+ export function hostCredentialConfigured(host, secrets) {
3
+ if (!host) return false
4
+ if (host.authType === 'keyboardInteractive') return false
5
+ const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
6
+ if (host.authType === 'privateKey') {
7
+ return !!(host.privateKeyPath || sec.privateKeyPem)
8
+ }
9
+ return !!sec.password
10
+ }
11
+
12
+ /** UI-safe host record (never includes password/PEM). */
13
+ export function publicHost(host, secrets) {
14
+ const configured = hostCredentialConfigured(host, secrets)
15
+ const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
16
+ return {
17
+ id: host.id,
18
+ name: host.name,
19
+ host: host.host,
20
+ port: host.port || 22,
21
+ username: host.username,
22
+ authType: host.authType || 'privateKey',
23
+ privateKeyPath: host.privateKeyPath || '',
24
+ source: host.source || 'manual',
25
+ updatedAt: host.updatedAt || 0,
26
+ credentialConfigured: configured,
27
+ credentialStatus:
28
+ host.authType === 'keyboardInteractive'
29
+ ? 'unsupported'
30
+ : configured
31
+ ? 'saved'
32
+ : 'missing',
33
+ passwordConfigured: !!sec.password,
34
+ privateKeyConfigured: !!(host.privateKeyPath || sec.privateKeyPem),
35
+ }
36
+ }
37
+
38
+ /** Model-facing summary: no paths to key files beyond endpoint metadata. */
39
+ export function modelHostSummary(host, secrets) {
40
+ const p = publicHost(host, secrets)
41
+ return {
42
+ host_id: p.id,
43
+ name: p.name,
44
+ endpoint: `${p.username}@${p.host}:${p.port}`,
45
+ username: p.username,
46
+ host: p.host,
47
+ port: p.port,
48
+ authType: p.authType,
49
+ credentialConfigured: p.credentialConfigured,
50
+ credentialStatus: p.credentialStatus,
51
+ }
52
+ }
53
+
54
+ /** Fail if object looks like it still carries raw secrets (for tests / asserts). */
55
+ export function assertNoSecretFields(obj, label = 'object') {
56
+ const banned = ['password', 'privateKeyPem', 'privateKey', 'passphrase', 'secret']
57
+ const stack = [{ value: obj, path: label }]
58
+ while (stack.length) {
59
+ const { value, path } = stack.pop()
60
+ if (!value || typeof value !== 'object') continue
61
+ if (Array.isArray(value)) {
62
+ value.forEach((v, i) => stack.push({ value: v, path: `${path}[${i}]` }))
63
+ continue
64
+ }
65
+ for (const [k, v] of Object.entries(value)) {
66
+ const key = k.toLowerCase()
67
+ if (banned.includes(key) && v) {
68
+ throw new Error(`${path}.${k} must not carry secret material`)
69
+ }
70
+ // allow *Configured boolean flags
71
+ if (typeof v === 'object' && v) stack.push({ value: v, path: `${path}.${k}` })
72
+ }
73
+ }
74
+ return true
75
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Loopback / Origin checks for the local HTTP API.
3
+ * 0.0.0.0 is NOT loopback (it is a bind-all address).
4
+ */
5
+
6
+ export function isLoopbackHostname(hostname) {
7
+ const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
8
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1'
9
+ }
10
+
11
+ function originHostPort(originHeader) {
12
+ try {
13
+ const u = new URL(String(originHeader))
14
+ const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
15
+ const port = u.port || (u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : '')
16
+ return { host, port, hostHeaderLike: port ? `${host}:${port}` : host }
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {object} req Node HTTP request
24
+ * @param {string[]} trustedHosts
25
+ */
26
+ export function isTrustedRequest(req, trustedHosts) {
27
+ try {
28
+ const hostHeader = req.headers?.host || req.headers?.Host
29
+ if (!hostHeader) return false
30
+ const url = new URL('http://' + hostHeader)
31
+ const reqHost = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
32
+ const list = Array.isArray(trustedHosts) ? trustedHosts : []
33
+ const hostOk =
34
+ isLoopbackHostname(reqHost) ||
35
+ list.some((entry) => {
36
+ const e = String(entry)
37
+ return e === hostHeader || e === url.hostname || e === url.host
38
+ })
39
+ if (!hostOk) return false
40
+
41
+ const origin = req.headers?.origin || req.headers?.Origin
42
+ if (!origin) return true
43
+ const parsed = originHostPort(origin)
44
+ if (!parsed) return false
45
+ if (isLoopbackHostname(parsed.host) && isLoopbackHostname(reqHost)) {
46
+ const reqPort = url.port || '80'
47
+ if (parsed.port && parsed.port !== reqPort) return false
48
+ return true
49
+ }
50
+ return list.some((entry) => {
51
+ const e = String(entry)
52
+ return e === parsed.hostHeaderLike || e === parsed.host || e === origin
53
+ })
54
+ } catch {
55
+ return false
56
+ }
57
+ }
58
+
59
+ export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024