dsh-ssh-tunnel 1.0.0 → 1.0.2
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 +68 -0
- package/README.md +80 -38
- package/README_en.md +80 -38
- package/cordis.patch.yml +4 -4
- package/icon.svg +26 -0
- package/lib/client.js +1643 -1472
- package/lib/index.js +1739 -1611
- package/lib/session.js +616 -525
- package/lib/shared/args.js +23 -0
- package/lib/shared/host-key.js +32 -32
- package/lib/shared/host-summary.js +75 -75
- package/lib/shared/http-trust.js +59 -59
- package/lib/shared/path.js +21 -22
- package/lib/shared/persist.js +41 -41
- package/lib/shared/session-auth.js +47 -39
- package/lib/shared/session-policy.js +151 -125
- package/lib/shared/shell-buffer.js +10 -10
- package/lib/shared/vendor.js +49 -49
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -84
- package/scripts/install.ps1 +191 -93
- package/scripts/install.sh +278 -226
- package/scripts/lib/bundle-check.cjs +108 -0
- package/scripts/lib/strip-mount.cjs +114 -0
- package/scripts/lib/ws-exclude.cjs +143 -0
- package/scripts/smoke-test.mjs +899 -511
- package/scripts/sync-to-dsh.sh +87 -5
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict argument coercion for API/tool entry points.
|
|
3
|
+
*
|
|
4
|
+
* `String(x || fallback)` turns a missing argument into the literal string
|
|
5
|
+
* "undefined" (or the fallback), which then flows into path operations and
|
|
6
|
+
* silently creates files or remote paths named "undefined". These helpers
|
|
7
|
+
* throw instead, so the caller sees which argument is missing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function requireString(value, label) {
|
|
11
|
+
if (value == null) throw new Error(label + ' required')
|
|
12
|
+
const s = String(value).trim()
|
|
13
|
+
if (!s) throw new Error(label + ' required')
|
|
14
|
+
return s
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Like requireString, but an empty value resolves to the fallback. */
|
|
18
|
+
export function stringOrDefault(value, fallback, label) {
|
|
19
|
+
if (value == null) return requireString(fallback, label)
|
|
20
|
+
const s = String(value).trim()
|
|
21
|
+
if (!s) return requireString(fallback, label)
|
|
22
|
+
return s
|
|
23
|
+
}
|
package/lib/shared/host-key.js
CHANGED
|
@@ -1,32 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,75 +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
|
-
}
|
|
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
|
+
}
|
package/lib/shared/http-trust.js
CHANGED
|
@@ -1,59 +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
|
|
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
|
package/lib/shared/path.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { lstatSync, realpathSync } from 'node:fs'
|
|
2
2
|
import { dirname, isAbsolute, join, normalize, resolve as pathResolve } from 'node:path'
|
|
3
3
|
|
|
4
|
-
export const WORKSPACE_ROOT = '/workspace'
|
|
5
|
-
|
|
6
4
|
/**
|
|
7
5
|
* Local roots the path guard accepts, most specific first.
|
|
8
6
|
*
|
|
@@ -11,8 +9,11 @@ export const WORKSPACE_ROOT = '/workspace'
|
|
|
11
9
|
* `/workspace` at all, and a fixed root rejects every real local path — which
|
|
12
10
|
* breaks SFTP upload/download and the sidebar file browser. Relative local
|
|
13
11
|
* paths resolve against the first root, so an explicit project root wins over
|
|
14
|
-
* the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override
|
|
15
|
-
*
|
|
12
|
+
* the `DSH_SSH_TUNNEL_WORKSPACE_ROOT` operator override.
|
|
13
|
+
*
|
|
14
|
+
* There is deliberately no implicit fallback root: guessing a root (e.g. the
|
|
15
|
+
* host process cwd plus `/workspace`) silently widens the trust boundary.
|
|
16
|
+
* Callers that cannot name a project workspace must fail loudly instead.
|
|
16
17
|
*
|
|
17
18
|
* Read at call time so hosts and tests can set them after module load.
|
|
18
19
|
*/
|
|
@@ -21,7 +22,6 @@ export function workspaceRoots(explicitRoot = '') {
|
|
|
21
22
|
if (explicitRoot && String(explicitRoot).trim()) raw.push(String(explicitRoot).trim())
|
|
22
23
|
const env = process.env.DSH_SSH_TUNNEL_WORKSPACE_ROOT
|
|
23
24
|
if (env && String(env).trim()) raw.push(String(env).trim())
|
|
24
|
-
if (!raw.length) raw.push(WORKSPACE_ROOT)
|
|
25
25
|
const roots = []
|
|
26
26
|
for (const r of raw) {
|
|
27
27
|
const key = normalizeProjectKey(r)
|
|
@@ -90,11 +90,11 @@ function realExisting(abs) {
|
|
|
90
90
|
let probe = abs
|
|
91
91
|
while (true) {
|
|
92
92
|
try {
|
|
93
|
-
return realpathSync(probe)
|
|
93
|
+
return { path: realpathSync(probe), probe }
|
|
94
94
|
} catch (e) {
|
|
95
95
|
if (e && e.code === 'ENOENT') {
|
|
96
96
|
const parent = dirname(probe)
|
|
97
|
-
if (!parent || parent === probe) return
|
|
97
|
+
if (!parent || parent === probe) return null
|
|
98
98
|
probe = parent
|
|
99
99
|
continue
|
|
100
100
|
}
|
|
@@ -118,6 +118,11 @@ export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' }
|
|
|
118
118
|
throw new Error('local path required')
|
|
119
119
|
}
|
|
120
120
|
const roots = workspaceRoots(root)
|
|
121
|
+
if (!roots.length) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'no local workspace root configured (open a project workspace or set DSH_SSH_TUNNEL_WORKSPACE_ROOT)',
|
|
124
|
+
)
|
|
125
|
+
}
|
|
121
126
|
let abs = String(inputPath)
|
|
122
127
|
if (!isAbsolute(abs)) abs = join(roots[0], abs)
|
|
123
128
|
abs = pathResolve(abs)
|
|
@@ -134,23 +139,17 @@ export function constrainToWorkspace(inputPath, { forUnlink = false, root = '' }
|
|
|
134
139
|
if (!(e && e.code === 'ENOENT')) throw e
|
|
135
140
|
}
|
|
136
141
|
}
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
|
|
142
|
+
const found = realExisting(abs)
|
|
143
|
+
if (found && found.path && !isPathInsideRoots(found.path, roots)) {
|
|
144
|
+
// A link was traversed only if the nearest existing ancestor resolves to a
|
|
145
|
+
// path other than its own lexical form; a missing leaf must not be
|
|
146
|
+
// reported as a symlink escape.
|
|
147
|
+
if (comparablePath(found.path) !== comparablePath(found.probe)) {
|
|
148
|
+
throw new Error('local path escapes ' + roots.join(' or ') + ' via symlink')
|
|
149
|
+
}
|
|
150
|
+
throw new Error('local path must be under ' + roots.join(' or '))
|
|
140
151
|
}
|
|
141
152
|
return abs
|
|
142
153
|
}
|
|
143
154
|
|
|
144
|
-
export function joinUnderRoot(root, relOrAbs) {
|
|
145
|
-
const roots = workspaceRoots()
|
|
146
|
-
const r = normalizeProjectKey(root) || roots[0]
|
|
147
|
-
let abs = String(relOrAbs || r)
|
|
148
|
-
if (!isAbsolute(abs)) abs = join(r, abs)
|
|
149
|
-
abs = pathResolve(abs)
|
|
150
|
-
if (!isPathInsideRoots(abs, [...roots, r])) {
|
|
151
|
-
throw new Error('path outside allowed roots')
|
|
152
|
-
}
|
|
153
|
-
return constrainToWorkspace(abs)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
155
|
export { isAbsolute, join, normalize, pathResolve }
|
package/lib/shared/persist.js
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
|
|
6
|
-
*/
|
|
7
|
-
export function readJsonFile(path, fallback) {
|
|
8
|
-
if (!existsSync(path)) return fallback
|
|
9
|
-
let raw
|
|
10
|
-
try {
|
|
11
|
-
raw = readFileSync(path, 'utf8')
|
|
12
|
-
} catch (e) {
|
|
13
|
-
throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
|
|
14
|
-
}
|
|
15
|
-
try {
|
|
16
|
-
return JSON.parse(raw)
|
|
17
|
-
} catch {
|
|
18
|
-
throw new Error('corrupt json: ' + path)
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function writeJsonAtomic(path, data, mode = 0o600) {
|
|
23
|
-
const tmp = path + '.tmp'
|
|
24
|
-
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
|
|
25
|
-
try { chmodSync(tmp, mode) } catch {}
|
|
26
|
-
renameSync(tmp, path)
|
|
27
|
-
try { chmodSync(path, mode) } catch {}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function migrateMarkerPath(dataDir) {
|
|
31
|
-
return join(dataDir, '.migrated-draft')
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function hostEndpointKey(host, port, username) {
|
|
35
|
-
return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function findExistingHostByEndpoint(hosts, host, port, username) {
|
|
39
|
-
const key = hostEndpointKey(host, port, username)
|
|
40
|
-
return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
|
|
41
|
-
}
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Missing file → fallback. Corrupt JSON → throw (never treat as empty store).
|
|
6
|
+
*/
|
|
7
|
+
export function readJsonFile(path, fallback) {
|
|
8
|
+
if (!existsSync(path)) return fallback
|
|
9
|
+
let raw
|
|
10
|
+
try {
|
|
11
|
+
raw = readFileSync(path, 'utf8')
|
|
12
|
+
} catch (e) {
|
|
13
|
+
throw new Error('cannot read ' + path + ': ' + (e && e.message ? e.message : e))
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw)
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error('corrupt json: ' + path)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function writeJsonAtomic(path, data, mode = 0o600) {
|
|
23
|
+
const tmp = path + '.tmp'
|
|
24
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode })
|
|
25
|
+
try { chmodSync(tmp, mode) } catch {}
|
|
26
|
+
renameSync(tmp, path)
|
|
27
|
+
try { chmodSync(path, mode) } catch {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function migrateMarkerPath(dataDir) {
|
|
31
|
+
return join(dataDir, '.migrated-draft')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hostEndpointKey(host, port, username) {
|
|
35
|
+
return `${String(username || '').trim()}@${String(host || '').trim()}:${Number(port) || 22}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findExistingHostByEndpoint(hosts, host, port, username) {
|
|
39
|
+
const key = hostEndpointKey(host, port, username)
|
|
40
|
+
return (hosts || []).find((h) => hostEndpointKey(h.host, h.port, h.username) === key)
|
|
41
|
+
}
|
|
@@ -1,39 +1,47 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Result-delivery authorization gates for SSH session operations (security).
|
|
3
|
-
* Pure helpers kept in shared/ so they are testable without an ssh2 install.
|
|
4
|
-
*
|
|
5
|
-
* Background (v0.4.3): a host grant can be revoked while an SSHManager
|
|
6
|
-
* operation is already in flight. Closing the session stops the transport,
|
|
7
|
-
* but a command can complete and a file can be pulled in the same tick as the
|
|
8
|
-
* revocation. These gates are the *second* boundary: even if the transport
|
|
9
|
-
* work finished, nothing from the revoked host may be delivered to the agent
|
|
10
|
-
* (and no downloaded file may remain on disk).
|
|
11
|
-
*
|
|
12
|
-
* v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
|
|
13
|
-
* write actions re-check the same gate.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (session
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Result-delivery authorization gates for SSH session operations (security).
|
|
3
|
+
* Pure helpers kept in shared/ so they are testable without an ssh2 install.
|
|
4
|
+
*
|
|
5
|
+
* Background (v0.4.3): a host grant can be revoked while an SSHManager
|
|
6
|
+
* operation is already in flight. Closing the session stops the transport,
|
|
7
|
+
* but a command can complete and a file can be pulled in the same tick as the
|
|
8
|
+
* revocation. These gates are the *second* boundary: even if the transport
|
|
9
|
+
* work finished, nothing from the revoked host may be delivered to the agent
|
|
10
|
+
* (and no downloaded file may remain on disk).
|
|
11
|
+
*
|
|
12
|
+
* v0.4.4: missing predicate is fail-closed (never a silent no-op). HTTP and
|
|
13
|
+
* write actions re-check the same gate.
|
|
14
|
+
*
|
|
15
|
+
* A session without a project binding is also denied: every creation path
|
|
16
|
+
* normalizes projectPathKey, so an empty binding means corrupted state, and
|
|
17
|
+
* skipping the comparison would silently authorize it.
|
|
18
|
+
*/
|
|
19
|
+
import { unlinkSync } from 'node:fs'
|
|
20
|
+
|
|
21
|
+
export function assertSessionStillAuthorized(session, isHostAuthorized, deniedMessage) {
|
|
22
|
+
if (!session) throw new Error('SSH session missing')
|
|
23
|
+
if (typeof isHostAuthorized !== 'function') {
|
|
24
|
+
throw new Error('SSH authorization predicate required')
|
|
25
|
+
}
|
|
26
|
+
if (!isHostAuthorized(session.projectPathKey, session.hostId)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
typeof deniedMessage === 'string' && deniedMessage
|
|
29
|
+
? deniedMessage
|
|
30
|
+
: 'SSH session host is not authorized for the current project.',
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function discardRevokedLocalFile(session, localPath, isHostAuthorized) {
|
|
36
|
+
if (!session) return false
|
|
37
|
+
if (typeof isHostAuthorized !== 'function') {
|
|
38
|
+
throw new Error('SSH authorization predicate required')
|
|
39
|
+
}
|
|
40
|
+
if (!isHostAuthorized(session.projectPathKey, session.hostId)) {
|
|
41
|
+
try {
|
|
42
|
+
unlinkSync(localPath)
|
|
43
|
+
} catch {}
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
return false
|
|
47
|
+
}
|