@goodandready/dsh-remote-workspace 0.3.14 → 0.3.16
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 +141 -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 +49 -5
- package/lib/routes/docker.js +1 -1
- package/lib/routes/env.js +33 -17
- package/lib/routes/files.js +13 -11
- package/lib/routes/helpers.js +117 -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/routes.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 +145 -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/docker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeJson, readBody,
|
|
1
|
+
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
2
2
|
|
|
3
3
|
export function registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
|
|
4
4
|
// 21. GET /dsh-remote-workspace/health
|
package/lib/routes/env.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import { writeJson, readBody, authorizeRequest } from './helpers.js';
|
|
1
|
+
import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
|
|
2
|
+
|
|
3
|
+
export function isEnvFileName(baseName) {
|
|
4
|
+
if (typeof baseName !== 'string' || !baseName) return false;
|
|
5
|
+
return baseName === '.env' || baseName.startsWith('.env.') || baseName.endsWith('.env');
|
|
6
|
+
}
|
|
2
7
|
|
|
3
8
|
export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs }) {
|
|
4
9
|
// 10. POST /dsh-remote-workspace/env/view
|
|
@@ -6,68 +11,79 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
|
|
|
6
11
|
kind: 'exact',
|
|
7
12
|
path: '/dsh-remote-workspace/env/view',
|
|
8
13
|
handler: async (req, res) => {
|
|
9
|
-
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: '
|
|
14
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
10
15
|
if (!authorizeRequest(sctx, req, res)) return;
|
|
11
16
|
try {
|
|
12
17
|
const { profileId, remotePath } = await readBody(req);
|
|
13
18
|
const profile = store.getProfile(profileId);
|
|
14
19
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
15
|
-
const
|
|
20
|
+
const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
|
|
21
|
+
const targetPath = validateRemotePath(remotePath || defaultPath);
|
|
16
22
|
const result = await envService.readEnv(profile, targetPath);
|
|
17
23
|
writeJson(res, 200, { ok: true, ...result });
|
|
18
24
|
} catch (err) {
|
|
19
|
-
|
|
25
|
+
const status = err.statusCode || err.status || 500;
|
|
26
|
+
writeJson(res, status, { ok: false, error: err.message });
|
|
20
27
|
}
|
|
21
28
|
}
|
|
22
|
-
}), 'dsh-remote-workspace:
|
|
29
|
+
}), 'dsh-remote-workspace:route:env-view');
|
|
23
30
|
|
|
24
31
|
// 11. POST /dsh-remote-workspace/env/save
|
|
25
32
|
sctx.effect(() => webServer.register({
|
|
26
33
|
kind: 'exact',
|
|
27
34
|
path: '/dsh-remote-workspace/env/save',
|
|
28
35
|
handler: async (req, res) => {
|
|
29
|
-
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: '
|
|
36
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
30
37
|
if (!authorizeRequest(sctx, req, res)) return;
|
|
31
38
|
try {
|
|
32
39
|
const { profileId, remotePath, content, key, value } = await readBody(req);
|
|
33
40
|
const profile = store.getProfile(profileId);
|
|
34
41
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
35
|
-
const
|
|
42
|
+
const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
|
|
43
|
+
const targetPath = validateRemotePath(remotePath || defaultPath);
|
|
44
|
+
const baseName = targetPath.split('/').pop();
|
|
45
|
+
if (!isEnvFileName(baseName)) {
|
|
46
|
+
return writeJson(res, 400, { ok: false, error: 'Target path must be an environment file' });
|
|
47
|
+
}
|
|
36
48
|
let result;
|
|
37
49
|
if (key) {
|
|
38
50
|
result = await envService.setEnvVar(profile, targetPath, key, value || '');
|
|
39
51
|
} else if (content !== undefined) {
|
|
52
|
+
if (typeof content !== 'string' && !Buffer.isBuffer(content)) {
|
|
53
|
+
return writeJson(res, 400, { ok: false, error: 'Content must be a string or buffer' });
|
|
54
|
+
}
|
|
40
55
|
if (remoteFs && typeof remoteFs.writeFile === 'function') {
|
|
41
56
|
await remoteFs.writeFile(profile, targetPath, content);
|
|
42
57
|
}
|
|
43
|
-
result = {
|
|
58
|
+
result = { filePath: targetPath, updated: true };
|
|
44
59
|
} else {
|
|
45
|
-
return writeJson(res, 400, { ok: false, error: 'Either key
|
|
60
|
+
return writeJson(res, 400, { ok: false, error: 'Either key or content must be provided' });
|
|
46
61
|
}
|
|
47
62
|
writeJson(res, 200, { ok: true, ...result });
|
|
48
63
|
} catch (err) {
|
|
49
|
-
|
|
64
|
+
const status = err.statusCode || err.status || 500;
|
|
65
|
+
writeJson(res, status, { ok: false, error: err.message });
|
|
50
66
|
}
|
|
51
67
|
}
|
|
52
|
-
}), 'dsh-remote-workspace:
|
|
68
|
+
}), 'dsh-remote-workspace:route:env-save');
|
|
53
69
|
|
|
54
70
|
// 12. POST /dsh-remote-workspace/diagnose
|
|
55
71
|
sctx.effect(() => webServer.register({
|
|
56
72
|
kind: 'exact',
|
|
57
73
|
path: '/dsh-remote-workspace/diagnose',
|
|
58
74
|
handler: async (req, res) => {
|
|
59
|
-
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: '
|
|
75
|
+
if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'Method Not Allowed' });
|
|
60
76
|
if (!authorizeRequest(sctx, req, res)) return;
|
|
61
77
|
try {
|
|
62
|
-
const { profileId, category,
|
|
78
|
+
const { profileId, category, target } = await readBody(req);
|
|
63
79
|
const profile = store.getProfile(profileId);
|
|
64
80
|
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
65
|
-
const
|
|
66
|
-
const result = await diagnoseService.diagnose(profile, checkCategory, target || '');
|
|
81
|
+
const result = await diagnoseService.diagnose(profile, category, target);
|
|
67
82
|
writeJson(res, 200, { ok: true, ...result });
|
|
68
83
|
} catch (err) {
|
|
69
|
-
|
|
84
|
+
const status = err.statusCode || err.status || 500;
|
|
85
|
+
writeJson(res, status, { ok: false, error: err.message });
|
|
70
86
|
}
|
|
71
87
|
}
|
|
72
|
-
}), 'dsh-remote-workspace:
|
|
88
|
+
}), 'dsh-remote-workspace:route:diagnose');
|
|
73
89
|
}
|
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
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function timingSafeCompare(a, b) {
|
|
4
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
5
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
6
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
7
|
+
if (bufA.length !== bufB.length) {
|
|
8
|
+
timingSafeEqual(bufA, bufA);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return timingSafeEqual(bufA, bufB);
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
export function writeJson(res, statusCode, body) {
|
|
2
15
|
if (typeof res.setHeader === 'function') {
|
|
3
16
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -80,51 +93,76 @@ export function isLoopbackAddress(address) {
|
|
|
80
93
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
|
|
81
94
|
}
|
|
82
95
|
|
|
83
|
-
export function isTrustedSettingsRequest(req) {
|
|
84
|
-
|
|
85
|
-
const
|
|
96
|
+
export function isTrustedSettingsRequest(req, expectedToken = null) {
|
|
97
|
+
if (!req || !req.headers || typeof req.headers !== 'object') return false;
|
|
98
|
+
const headers = req.headers;
|
|
86
99
|
|
|
87
|
-
const secFetch =
|
|
88
|
-
// Fail-closed: unconditionally reject cross-site and
|
|
100
|
+
const secFetch = headers['sec-fetch-site'];
|
|
101
|
+
// Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site
|
|
89
102
|
if (secFetch && secFetch !== 'same-origin') {
|
|
90
103
|
return false;
|
|
91
104
|
}
|
|
92
105
|
|
|
93
|
-
//
|
|
94
|
-
const origin =
|
|
95
|
-
const host =
|
|
96
|
-
if (origin
|
|
106
|
+
// Reject invalid or mismatched origin when provided (CSRF protection)
|
|
107
|
+
const origin = headers['origin'];
|
|
108
|
+
const host = headers['host'];
|
|
109
|
+
if (origin !== undefined) {
|
|
110
|
+
if (origin === 'null' || !origin) return false;
|
|
97
111
|
try {
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
112
|
+
const originUrl = new URL(origin);
|
|
113
|
+
if (host && originUrl.host !== host) return false;
|
|
102
114
|
} catch {
|
|
103
115
|
return false;
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
const tokenCandidate = (typeof expectedToken === 'string' && expectedToken)
|
|
120
|
+
|| process.env.DSH_AUTH_TOKEN
|
|
121
|
+
|| process.env.DSH_TOKEN
|
|
122
|
+
|| null;
|
|
110
123
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
const rawAuth = headers['authorization'];
|
|
125
|
+
if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
|
|
126
|
+
const bearer = rawAuth.slice(7).trim();
|
|
127
|
+
if (tokenCandidate && bearer && timingSafeCompare(bearer, tokenCandidate)) {
|
|
128
|
+
return true;
|
|
115
129
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cookieHeader = headers['cookie'];
|
|
133
|
+
if (typeof cookieHeader === 'string' && tokenCandidate) {
|
|
134
|
+
const cookies = parseCookies(cookieHeader);
|
|
135
|
+
const token = cookies['token'] || cookies['dsh_token'];
|
|
136
|
+
if (token && timingSafeCompare(token, tokenCandidate)) {
|
|
137
|
+
return true;
|
|
120
138
|
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// If a token is configured but neither header nor cookie matched, reject
|
|
142
|
+
if (tokenCandidate) {
|
|
121
143
|
return false;
|
|
122
144
|
}
|
|
123
145
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
146
|
+
const ip = req.socket?.remoteAddress || req.connection?.remoteAddress || '';
|
|
147
|
+
const hasRemote = Boolean(ip && typeof ip === 'string');
|
|
148
|
+
const isLoopback = hasRemote && isLoopbackAddress(ip);
|
|
149
|
+
|
|
150
|
+
// Non-loopback network callers require explicit token authentication
|
|
151
|
+
if (hasRemote && !isLoopback) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Browser same-origin navigation / fetch
|
|
156
|
+
if (secFetch === 'same-origin') {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Local loopback caller (direct CLI / loopback curl)
|
|
161
|
+
if (isLoopback) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return false;
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
export function getConnection(ctx) {
|
|
@@ -136,6 +174,12 @@ export function getConnection(ctx) {
|
|
|
136
174
|
}
|
|
137
175
|
|
|
138
176
|
export function authorizeRequest(sctx, req, res) {
|
|
177
|
+
// First layer: Cross-origin / CSRF / remote network trust check
|
|
178
|
+
if (!isTrustedSettingsRequest(req)) {
|
|
179
|
+
writeJson(res, 403, { ok: false, error: 'Forbidden' });
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
139
183
|
let connection;
|
|
140
184
|
try {
|
|
141
185
|
connection = getConnection(sctx);
|
|
@@ -168,3 +212,48 @@ export function authorizeRequest(sctx, req, res) {
|
|
|
168
212
|
|
|
169
213
|
return true;
|
|
170
214
|
}
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
export function validateRemotePath(filePath, options = {}) {
|
|
218
|
+
const { allowEmpty = false, maxLength = 1024 } = options;
|
|
219
|
+
if (!filePath && filePath !== '') {
|
|
220
|
+
if (allowEmpty) return '';
|
|
221
|
+
const err = new Error('A file path is required');
|
|
222
|
+
err.statusCode = 400;
|
|
223
|
+
err.status = 400;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
if (typeof filePath !== 'string') {
|
|
227
|
+
const err = new Error('File path must be a string');
|
|
228
|
+
err.statusCode = 400;
|
|
229
|
+
err.status = 400;
|
|
230
|
+
throw err;
|
|
231
|
+
}
|
|
232
|
+
const trimmed = filePath.trim();
|
|
233
|
+
if (!trimmed) {
|
|
234
|
+
if (allowEmpty) return '';
|
|
235
|
+
const err = new Error('A file path is required');
|
|
236
|
+
err.statusCode = 400;
|
|
237
|
+
err.status = 400;
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
if (trimmed.length > maxLength) {
|
|
241
|
+
const err = new Error(`File path exceeds maximum length of ${maxLength} characters`);
|
|
242
|
+
err.statusCode = 400;
|
|
243
|
+
err.status = 400;
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
246
|
+
if (trimmed.includes('\0')) {
|
|
247
|
+
const err = new Error('File path cannot contain null bytes');
|
|
248
|
+
err.statusCode = 400;
|
|
249
|
+
err.status = 400;
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
if (/[\x00-\x1f]/.test(trimmed)) {
|
|
253
|
+
const err = new Error('File path cannot contain control characters');
|
|
254
|
+
err.statusCode = 400;
|
|
255
|
+
err.status = 400;
|
|
256
|
+
throw err;
|
|
257
|
+
}
|
|
258
|
+
return trimmed;
|
|
259
|
+
}
|
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/routes.js
CHANGED
|
@@ -8,7 +8,7 @@ import { registerTunnelRoutes } from './routes/tunnels.js';
|
|
|
8
8
|
import { registerDockerRoutes } from './routes/docker.js';
|
|
9
9
|
import { registerSyncRoutes } from './routes/sync.js';
|
|
10
10
|
|
|
11
|
-
export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES } from './routes/helpers.js';
|
|
11
|
+
export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES, timingSafeCompare } from './routes/helpers.js';
|
|
12
12
|
|
|
13
13
|
export function registerApiRoutes(
|
|
14
14
|
ctx,
|
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
|
});
|