@goodandready/dsh-remote-workspace 0.3.14 → 0.3.15
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 +132 -0
- package/lib/alert-service.js +18 -5
- package/lib/client.js +17 -17
- package/lib/env-service.js +17 -8
- package/lib/file-transfer.js +0 -7
- package/lib/host-projection.js +0 -4
- package/lib/index.js +25 -2
- package/lib/mirror-sync-service.js +21 -1
- package/lib/proxy-command.js +44 -5
- package/lib/remote-fs-service.js +33 -5
- package/lib/routes/docker.js +1 -1
- package/lib/routes/env.js +13 -4
- package/lib/routes/files.js +13 -11
- package/lib/routes/helpers.js +104 -28
- package/lib/routes/profiles.js +15 -3
- package/lib/routes/sync.js +1 -1
- package/lib/routes/terminal.js +1 -1
- package/lib/routes/tunnels.js +1 -1
- package/lib/ssh-config-parser.js +50 -7
- package/lib/ssh-service.js +27 -3
- package/lib/tar-sync-service.js +7 -7
- package/lib/tools.js +89 -17
- package/lib/updater-service.js +14 -11
- package/lib/vault-service.js +14 -5
- package/package.json +3 -2
- package/lib/workspace-mount.js +0 -5
package/lib/routes/env.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
|
|
2
2
|
|
|
3
|
-
export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService,
|
|
3
|
+
export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, store, remoteFs }) {
|
|
4
4
|
// 10. POST /dsh-remote-workspace/env/view
|
|
5
5
|
sctx.effect(() => webServer.register({
|
|
6
6
|
kind: 'exact',
|
|
@@ -12,7 +12,8 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
|
|
|
12
12
|
const { profileId, remotePath } = await readBody(req);
|
|
13
13
|
const profile = store.getProfile(profileId);
|
|
14
14
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
15
|
-
const
|
|
15
|
+
const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
|
|
16
|
+
const targetPath = validateRemotePath(remotePath || defaultPath);
|
|
16
17
|
const result = await envService.readEnv(profile, targetPath);
|
|
17
18
|
writeJson(res, 200, { ok: true, ...result });
|
|
18
19
|
} catch (err) {
|
|
@@ -32,11 +33,19 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
|
|
|
32
33
|
const { profileId, remotePath, content, key, value } = await readBody(req);
|
|
33
34
|
const profile = store.getProfile(profileId);
|
|
34
35
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
35
|
-
const
|
|
36
|
+
const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
|
|
37
|
+
const targetPath = validateRemotePath(remotePath || defaultPath);
|
|
38
|
+
const baseName = targetPath.split('/').pop();
|
|
39
|
+
if (!baseName.includes('env')) {
|
|
40
|
+
return writeJson(res, 400, { ok: false, error: 'Target path must be an environment file' });
|
|
41
|
+
}
|
|
36
42
|
let result;
|
|
37
43
|
if (key) {
|
|
38
44
|
result = await envService.setEnvVar(profile, targetPath, key, value || '');
|
|
39
45
|
} else if (content !== undefined) {
|
|
46
|
+
if (typeof content !== 'string' && !Buffer.isBuffer(content)) {
|
|
47
|
+
return writeJson(res, 400, { ok: false, error: 'Content must be a string or buffer' });
|
|
48
|
+
}
|
|
40
49
|
if (remoteFs && typeof remoteFs.writeFile === 'function') {
|
|
41
50
|
await remoteFs.writeFile(profile, targetPath, content);
|
|
42
51
|
}
|
package/lib/routes/files.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import { writeJson, readBody,
|
|
1
|
+
import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
|
|
2
2
|
import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
function transferTarget(req, store) {
|
|
6
6
|
const url = new URL(req.url || '/', 'http://localhost');
|
|
7
7
|
const profileId = url.searchParams.get('profileId') || '';
|
|
8
|
-
const filePath = url.searchParams.get('filePath')
|
|
9
|
-
if (!filePath || filePath.length > 1024 || filePath.includes('\0')) {
|
|
10
|
-
const err = new Error('A file path is required');
|
|
11
|
-
err.status = 400;
|
|
12
|
-
throw err;
|
|
13
|
-
}
|
|
8
|
+
const filePath = validateRemotePath(url.searchParams.get('filePath'));
|
|
14
9
|
const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
|
|
15
10
|
if (!profile) {
|
|
16
11
|
const err = new Error('Profile not found');
|
|
12
|
+
err.statusCode = 404;
|
|
17
13
|
err.status = 404;
|
|
18
14
|
throw err;
|
|
19
15
|
}
|
|
@@ -44,7 +40,8 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
44
40
|
}
|
|
45
41
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
46
42
|
|
|
47
|
-
const
|
|
43
|
+
const rawPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
|
|
44
|
+
const targetPath = validateRemotePath(rawPath);
|
|
48
45
|
const entries = await remoteFs.listDir(profile, targetPath);
|
|
49
46
|
writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
|
|
50
47
|
} catch (err) {
|
|
@@ -62,13 +59,14 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
62
59
|
if (!authorizeRequest(sctx, req, res)) return;
|
|
63
60
|
try {
|
|
64
61
|
const { profileId, filePath } = await readBody(req);
|
|
62
|
+
const targetPath = validateRemotePath(filePath);
|
|
65
63
|
const profile = store.getProfile(profileId);
|
|
66
64
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
67
|
-
const stat = await remoteFs.stat(profile,
|
|
65
|
+
const stat = await remoteFs.stat(profile, targetPath);
|
|
68
66
|
if (stat.size > 2 * 1024 * 1024) {
|
|
69
67
|
return writeJson(res, 400, { ok: false, error: 'File exceeds 2MB preview limit' });
|
|
70
68
|
}
|
|
71
|
-
const buf = await remoteFs.readFile(profile,
|
|
69
|
+
const buf = await remoteFs.readFile(profile, targetPath);
|
|
72
70
|
writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
|
|
73
71
|
} catch (err) {
|
|
74
72
|
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
|
@@ -85,9 +83,13 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
|
|
|
85
83
|
if (!authorizeRequest(sctx, req, res)) return;
|
|
86
84
|
try {
|
|
87
85
|
const { profileId, filePath, content } = await readBody(req);
|
|
86
|
+
const targetPath = validateRemotePath(filePath);
|
|
87
|
+
if (content === undefined) {
|
|
88
|
+
return writeJson(res, 400, { ok: false, error: 'Content is required' });
|
|
89
|
+
}
|
|
88
90
|
const profile = store.getProfile(profileId);
|
|
89
91
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
90
|
-
await remoteFs.writeFile(profile,
|
|
92
|
+
await remoteFs.writeFile(profile, targetPath, Buffer.from(content, 'utf8'));
|
|
91
93
|
writeJson(res, 200, { ok: true });
|
|
92
94
|
} catch (err) {
|
|
93
95
|
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
|
package/lib/routes/helpers.js
CHANGED
|
@@ -80,51 +80,76 @@ export function isLoopbackAddress(address) {
|
|
|
80
80
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export function isTrustedSettingsRequest(req) {
|
|
84
|
-
|
|
85
|
-
const
|
|
83
|
+
export function isTrustedSettingsRequest(req, expectedToken = null) {
|
|
84
|
+
if (!req || !req.headers || typeof req.headers !== 'object') return false;
|
|
85
|
+
const headers = req.headers;
|
|
86
86
|
|
|
87
|
-
const secFetch =
|
|
88
|
-
// Fail-closed: unconditionally reject cross-site and
|
|
87
|
+
const secFetch = headers['sec-fetch-site'];
|
|
88
|
+
// Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site
|
|
89
89
|
if (secFetch && secFetch !== 'same-origin') {
|
|
90
90
|
return false;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
//
|
|
94
|
-
const origin =
|
|
95
|
-
const host =
|
|
96
|
-
if (origin
|
|
93
|
+
// Reject invalid or mismatched origin when provided (CSRF protection)
|
|
94
|
+
const origin = headers['origin'];
|
|
95
|
+
const host = headers['host'];
|
|
96
|
+
if (origin !== undefined) {
|
|
97
|
+
if (origin === 'null' || !origin) return false;
|
|
97
98
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
99
|
+
const originUrl = new URL(origin);
|
|
100
|
+
if (host && originUrl.host !== host) return false;
|
|
102
101
|
} catch {
|
|
103
102
|
return false;
|
|
104
103
|
}
|
|
105
104
|
}
|
|
106
105
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
const tokenCandidate = (typeof expectedToken === 'string' && expectedToken)
|
|
107
|
+
|| process.env.DSH_AUTH_TOKEN
|
|
108
|
+
|| process.env.DSH_TOKEN
|
|
109
|
+
|| null;
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
111
|
+
const rawAuth = headers['authorization'];
|
|
112
|
+
if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
|
|
113
|
+
const bearer = rawAuth.slice(7).trim();
|
|
114
|
+
if (tokenCandidate && bearer && bearer === tokenCandidate) {
|
|
115
|
+
return true;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cookieHeader = headers['cookie'];
|
|
120
|
+
if (typeof cookieHeader === 'string' && tokenCandidate) {
|
|
121
|
+
const cookies = parseCookies(cookieHeader);
|
|
122
|
+
const token = cookies['token'] || cookies['dsh_token'];
|
|
123
|
+
if (token && token === tokenCandidate) {
|
|
124
|
+
return true;
|
|
120
125
|
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If a token is configured but neither header nor cookie matched, reject
|
|
129
|
+
if (tokenCandidate) {
|
|
121
130
|
return false;
|
|
122
131
|
}
|
|
123
132
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
const ip = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
|
|
134
|
+
const hasRemote = Boolean(ip && typeof ip === 'string');
|
|
135
|
+
const isLoopback = hasRemote && isLoopbackAddress(ip);
|
|
136
|
+
|
|
137
|
+
// Non-loopback network callers require explicit token authentication
|
|
138
|
+
if (hasRemote && !isLoopback) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Browser same-origin navigation / fetch
|
|
143
|
+
if (secFetch === 'same-origin') {
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Local loopback caller (direct CLI / loopback curl)
|
|
148
|
+
if (isLoopback) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return false;
|
|
128
153
|
}
|
|
129
154
|
|
|
130
155
|
export function getConnection(ctx) {
|
|
@@ -136,6 +161,12 @@ export function getConnection(ctx) {
|
|
|
136
161
|
}
|
|
137
162
|
|
|
138
163
|
export function authorizeRequest(sctx, req, res) {
|
|
164
|
+
// First layer: Cross-origin / CSRF / remote network trust check
|
|
165
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
166
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
139
170
|
let connection;
|
|
140
171
|
try {
|
|
141
172
|
connection = getConnection(sctx);
|
|
@@ -168,3 +199,48 @@ export function authorizeRequest(sctx, req, res) {
|
|
|
168
199
|
|
|
169
200
|
return true;
|
|
170
201
|
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
export function validateRemotePath(filePath, options = {}) {
|
|
205
|
+
const { allowEmpty = false, maxLength = 1024 } = options;
|
|
206
|
+
if (!filePath && filePath !== '') {
|
|
207
|
+
if (allowEmpty) return '';
|
|
208
|
+
const err = new Error('A file path is required');
|
|
209
|
+
err.statusCode = 400;
|
|
210
|
+
err.status = 400;
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
if (typeof filePath !== 'string') {
|
|
214
|
+
const err = new Error('File path must be a string');
|
|
215
|
+
err.statusCode = 400;
|
|
216
|
+
err.status = 400;
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
const trimmed = filePath.trim();
|
|
220
|
+
if (!trimmed) {
|
|
221
|
+
if (allowEmpty) return '';
|
|
222
|
+
const err = new Error('A file path is required');
|
|
223
|
+
err.statusCode = 400;
|
|
224
|
+
err.status = 400;
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
if (trimmed.length > maxLength) {
|
|
228
|
+
const err = new Error(`File path exceeds maximum length of ${maxLength} characters`);
|
|
229
|
+
err.statusCode = 400;
|
|
230
|
+
err.status = 400;
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
if (trimmed.includes('\0')) {
|
|
234
|
+
const err = new Error('File path cannot contain null bytes');
|
|
235
|
+
err.statusCode = 400;
|
|
236
|
+
err.status = 400;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
if (/[\x00-\x1f]/.test(trimmed)) {
|
|
240
|
+
const err = new Error('File path cannot contain control characters');
|
|
241
|
+
err.statusCode = 400;
|
|
242
|
+
err.status = 400;
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
return trimmed;
|
|
246
|
+
}
|
package/lib/routes/profiles.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isWithinDir } from '../ssh-config-parser.js';
|
|
4
|
+
import { validateProxyCommandTemplate } from '../proxy-command.js';
|
|
5
|
+
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
2
6
|
import { SshConfigParser } from '../ssh-config-parser.js';
|
|
3
7
|
import { testProfileGroup } from '../profile-groups.js';
|
|
4
8
|
import { filterClusterProfiles, runCluster } from '../cluster-service.js';
|
|
@@ -48,6 +52,9 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
48
52
|
if (!profile.id || !profile.host) {
|
|
49
53
|
return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
|
|
50
54
|
}
|
|
55
|
+
if (profile.proxyCommand) {
|
|
56
|
+
validateProxyCommandTemplate(profile.proxyCommand);
|
|
57
|
+
}
|
|
51
58
|
sshService.invalidate(profile.id);
|
|
52
59
|
await store.saveProfile(profile);
|
|
53
60
|
const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
|
|
@@ -108,7 +115,12 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
108
115
|
? body.content
|
|
109
116
|
: SshConfigParser.readDefaultConfig();
|
|
110
117
|
if (!content) return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
|
|
111
|
-
const
|
|
118
|
+
const sshDir = path.join(os.homedir(), '.ssh');
|
|
119
|
+
const allowedRoot = sshDir;
|
|
120
|
+
const baseDir = (body.baseDir && isWithinDir(body.baseDir, allowedRoot))
|
|
121
|
+
? path.resolve(body.baseDir)
|
|
122
|
+
: sshDir;
|
|
123
|
+
const report = SshConfigParser.parseReport(content, { baseDir, allowedRoot });
|
|
112
124
|
for (const p of report.profiles) {
|
|
113
125
|
await store.saveProfile(p);
|
|
114
126
|
}
|
|
@@ -218,7 +230,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
|
|
|
218
230
|
const remoteOs = result.os !== undefined ? result.os : (result.remoteOs ?? 'Linux/Unix');
|
|
219
231
|
writeJson(res, 200, { ok: true, latency: latencyMs, latencyMs, os: remoteOs, remoteOs });
|
|
220
232
|
} catch (err) {
|
|
221
|
-
writeJson(res,
|
|
233
|
+
writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 502), { ok: false, error: err.message });
|
|
222
234
|
}
|
|
223
235
|
}
|
|
224
236
|
}), 'dsh-remote-workspace: /test');
|
package/lib/routes/sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody,
|
|
1
|
+
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
2
2
|
|
|
3
3
|
export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService, watcherService, store }) {
|
|
4
4
|
// 17. POST /dsh-remote-workspace/sync
|
package/lib/routes/terminal.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody,
|
|
1
|
+
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
2
2
|
import { normalizeTerminalFont } from '../terminal-font.js';
|
|
3
3
|
|
|
4
4
|
export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
|
package/lib/routes/tunnels.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody,
|
|
1
|
+
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
2
2
|
|
|
3
3
|
export function registerTunnelRoutes(sctx, webServer, { tunnelService, store }) {
|
|
4
4
|
// 18. POST /dsh-remote-workspace/tunnels/start
|
package/lib/ssh-config-parser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateProxyCommandTemplate } from './proxy-command.js';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import os from 'node:os';
|
|
@@ -7,19 +8,48 @@ function expandHome(value) {
|
|
|
7
8
|
return value;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
|
|
12
|
+
export function isWithinDir(targetPath, parentDir) {
|
|
13
|
+
if (!targetPath || !parentDir) return false;
|
|
14
|
+
const normTarget = path.resolve(targetPath);
|
|
15
|
+
const normParent = path.resolve(parentDir);
|
|
16
|
+
const prefix = normParent.endsWith(path.sep) ? normParent : normParent + path.sep;
|
|
17
|
+
return normTarget === normParent || normTarget.startsWith(prefix);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function defaultExpandInclude(pattern, baseDir, allowedRoot = null) {
|
|
21
|
+
if (typeof pattern !== 'string' || !pattern.trim() || pattern.includes('\0')) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const expanded = expandHome(pattern.trim());
|
|
25
|
+
const full = path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(baseDir, expanded);
|
|
26
|
+
if (allowedRoot && !isWithinDir(full, allowedRoot)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
13
29
|
if (full.includes('*') || full.includes('?')) {
|
|
14
30
|
return fs.globSync(full, { windowsPathsNoEscape: true })
|
|
15
31
|
.filter((file) => {
|
|
16
|
-
try {
|
|
32
|
+
try {
|
|
33
|
+
const absFile = path.resolve(file);
|
|
34
|
+
if (allowedRoot && !isWithinDir(absFile, allowedRoot)) return false;
|
|
35
|
+
return fs.statSync(absFile).isFile();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
17
39
|
})
|
|
18
40
|
.sort();
|
|
19
41
|
}
|
|
20
|
-
|
|
42
|
+
try {
|
|
43
|
+
if (fs.existsSync(full) && fs.statSync(full).isFile()) {
|
|
44
|
+
return [full];
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
21
50
|
}
|
|
22
51
|
|
|
52
|
+
|
|
23
53
|
export class SshConfigParser {
|
|
24
54
|
/**
|
|
25
55
|
* @param {string} text
|
|
@@ -31,7 +61,8 @@ export class SshConfigParser {
|
|
|
31
61
|
|
|
32
62
|
static parseReport(text, options = {}) {
|
|
33
63
|
const baseDir = options.baseDir || path.join(os.homedir(), '.ssh');
|
|
34
|
-
const
|
|
64
|
+
const allowedRoot = options.allowedRoot !== undefined ? options.allowedRoot : (options.expandInclude ? null : baseDir);
|
|
65
|
+
const expandInclude = options.expandInclude || ((pattern) => defaultExpandInclude(pattern, baseDir, allowedRoot));
|
|
35
66
|
const readFile = options.readFile || ((file) => fs.readFileSync(file, 'utf8'));
|
|
36
67
|
const seen = options.seen || new Set();
|
|
37
68
|
const known = options.knownIds || new Set();
|
|
@@ -71,6 +102,10 @@ export class SshConfigParser {
|
|
|
71
102
|
}
|
|
72
103
|
for (const file of files) {
|
|
73
104
|
const abs = path.resolve(file);
|
|
105
|
+
if (allowedRoot && !isWithinDir(abs, allowedRoot)) {
|
|
106
|
+
skipped.push({ name: abs, reason: 'unauthorized-include' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
74
109
|
if (seen.has(abs)) {
|
|
75
110
|
skipped.push({ name: abs, reason: 'duplicate' });
|
|
76
111
|
continue;
|
|
@@ -83,6 +118,7 @@ export class SshConfigParser {
|
|
|
83
118
|
}
|
|
84
119
|
const child = this.parseReport(nested, {
|
|
85
120
|
baseDir: path.dirname(abs),
|
|
121
|
+
allowedRoot,
|
|
86
122
|
expandInclude: options.expandInclude,
|
|
87
123
|
readFile,
|
|
88
124
|
seen,
|
|
@@ -137,7 +173,14 @@ export class SshConfigParser {
|
|
|
137
173
|
current.authType = 'key';
|
|
138
174
|
current.privateKey = val;
|
|
139
175
|
} else if (key === 'proxyjump') current.jumpHostId = val;
|
|
140
|
-
else if (key === 'proxycommand')
|
|
176
|
+
else if (key === 'proxycommand') {
|
|
177
|
+
try {
|
|
178
|
+
validateProxyCommandTemplate(val);
|
|
179
|
+
current.proxyCommand = val;
|
|
180
|
+
} catch {
|
|
181
|
+
skipped.push({ block: current.name || 'unnamed', reason: 'ProxyCommand contains forbidden shell metacharacters' });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
141
184
|
}
|
|
142
185
|
pushCurrent();
|
|
143
186
|
return { profiles, skipped };
|
package/lib/ssh-service.js
CHANGED
|
@@ -364,6 +364,7 @@ export class SshService {
|
|
|
364
364
|
let pty = false;
|
|
365
365
|
let timeoutMs = 0;
|
|
366
366
|
let cleanAnsi = false;
|
|
367
|
+
let maxBufferSize = 10 * 1024 * 1024;
|
|
367
368
|
|
|
368
369
|
if (typeof optionsOrCwd === 'string') {
|
|
369
370
|
cwd = optionsOrCwd;
|
|
@@ -373,6 +374,11 @@ export class SshService {
|
|
|
373
374
|
pty = Boolean(optionsOrCwd.pty);
|
|
374
375
|
timeoutMs = optionsOrCwd.timeout ? optionsOrCwd.timeout * 1000 : 0;
|
|
375
376
|
cleanAnsi = Boolean(optionsOrCwd.cleanAnsi);
|
|
377
|
+
if (typeof optionsOrCwd.maxBufferSize === 'number') {
|
|
378
|
+
maxBufferSize = optionsOrCwd.maxBufferSize;
|
|
379
|
+
} else if (typeof optionsOrCwd.maxBuffer === 'number') {
|
|
380
|
+
maxBufferSize = optionsOrCwd.maxBuffer;
|
|
381
|
+
}
|
|
376
382
|
}
|
|
377
383
|
|
|
378
384
|
this.bumpInFlight(profile.id, 1);
|
|
@@ -405,6 +411,7 @@ export class SshService {
|
|
|
405
411
|
}
|
|
406
412
|
let stdout = '';
|
|
407
413
|
let stderr = '';
|
|
414
|
+
let truncated = false;
|
|
408
415
|
|
|
409
416
|
if (timeoutMs > 0) {
|
|
410
417
|
timer = setTimeout(() => {
|
|
@@ -423,13 +430,29 @@ export class SshService {
|
|
|
423
430
|
|
|
424
431
|
stream.on('data', (data) => {
|
|
425
432
|
const str = data.toString();
|
|
426
|
-
stdout
|
|
433
|
+
if (stdout.length < maxBufferSize) {
|
|
434
|
+
const remaining = maxBufferSize - stdout.length;
|
|
435
|
+
stdout += str.length > remaining ? str.slice(0, remaining) : str;
|
|
436
|
+
if (str.length > remaining) {
|
|
437
|
+
truncated = true;
|
|
438
|
+
stdout += '\n[Output truncated: exceeded max buffer size]';
|
|
439
|
+
try { stream.signal('SIGTERM'); stream.close(); } catch (_err) { /* ignore best-effort process termination */ }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
427
442
|
if (onData) onData({ type: 'stdout', data: str });
|
|
428
443
|
});
|
|
429
444
|
|
|
430
445
|
stream.stderr?.on('data', (data) => {
|
|
431
446
|
const str = data.toString();
|
|
432
|
-
stderr
|
|
447
|
+
if (stderr.length < maxBufferSize) {
|
|
448
|
+
const remaining = maxBufferSize - stderr.length;
|
|
449
|
+
stderr += str.length > remaining ? str.slice(0, remaining) : str;
|
|
450
|
+
if (str.length > remaining) {
|
|
451
|
+
truncated = true;
|
|
452
|
+
stderr += '\n[Stderr truncated: exceeded max buffer size]';
|
|
453
|
+
try { stream.signal('SIGTERM'); stream.close(); } catch (_err) { /* ignore best-effort process termination */ }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
433
456
|
if (onData) onData({ type: 'stderr', data: str });
|
|
434
457
|
});
|
|
435
458
|
|
|
@@ -440,7 +463,8 @@ export class SshService {
|
|
|
440
463
|
code: code ?? 0,
|
|
441
464
|
signal,
|
|
442
465
|
stdout: cleanAnsi ? stripAnsi(stdout) : stdout,
|
|
443
|
-
stderr: cleanAnsi ? stripAnsi(stderr) : stderr
|
|
466
|
+
stderr: cleanAnsi ? stripAnsi(stderr) : stderr,
|
|
467
|
+
truncated
|
|
444
468
|
});
|
|
445
469
|
});
|
|
446
470
|
});
|
package/lib/tar-sync-service.js
CHANGED
|
@@ -40,7 +40,7 @@ export class TarSyncService {
|
|
|
40
40
|
|
|
41
41
|
localTar.on('close', (code) => {
|
|
42
42
|
if (code !== 0) {
|
|
43
|
-
try { stream.destroy(); } catch (
|
|
43
|
+
try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
|
|
44
44
|
reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
|
|
45
45
|
} else {
|
|
46
46
|
resolve({ ok: true, direction: 'pull', remoteDir, localDir });
|
|
@@ -48,17 +48,17 @@ export class TarSyncService {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
localTar.on('error', (err) => {
|
|
51
|
-
try { stream.destroy(); } catch (
|
|
51
|
+
try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
|
|
52
52
|
reject(err);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
stream.on('error', (err) => {
|
|
56
|
-
try { localTar.kill('SIGTERM'); } catch (
|
|
56
|
+
try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
|
|
57
57
|
reject(err);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
localTar.stdin.on('error', (err) => {
|
|
61
|
-
try { stream.destroy(); } catch (
|
|
61
|
+
try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
|
|
62
62
|
reject(err);
|
|
63
63
|
});
|
|
64
64
|
});
|
|
@@ -93,7 +93,7 @@ export class TarSyncService {
|
|
|
93
93
|
|
|
94
94
|
stream.on('close', (code) => {
|
|
95
95
|
if (code !== 0) {
|
|
96
|
-
try { localTar.kill('SIGTERM'); } catch (
|
|
96
|
+
try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
|
|
97
97
|
reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
|
|
98
98
|
} else {
|
|
99
99
|
resolve({ ok: true, direction: 'push', localDir, remoteDir });
|
|
@@ -101,12 +101,12 @@ export class TarSyncService {
|
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
stream.on('error', (err) => {
|
|
104
|
-
try { localTar.kill('SIGTERM'); } catch (
|
|
104
|
+
try { localTar.kill('SIGTERM'); } catch (_err) { /* ignore best-effort process termination */ }
|
|
105
105
|
reject(err);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
localTar.on('error', (err) => {
|
|
109
|
-
try { stream.destroy(); } catch (
|
|
109
|
+
try { stream.destroy(); } catch (_err) { /* ignore best-effort stream cleanup */ }
|
|
110
110
|
reject(err);
|
|
111
111
|
});
|
|
112
112
|
});
|