@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.
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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: 'POST only' });
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 targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
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
- writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
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: /env/view');
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: 'POST only' });
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 targetPath = remotePath || (profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env');
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 = { ok: true, filePath: targetPath };
58
+ result = { filePath: targetPath, updated: true };
44
59
  } else {
45
- return writeJson(res, 400, { ok: false, error: 'Either key/value or content is required' });
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
- writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
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: /env/save');
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: 'POST only' });
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, checks, target } = await readBody(req);
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 checkCategory = category || checks || 'memory';
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
- writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
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: /diagnose');
88
+ }), 'dsh-remote-workspace:route:diagnose');
73
89
  }
@@ -1,19 +1,15 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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 targetPath = body.remotePath || body.path || profile.remoteWorkspace || profile.remotePath || '.';
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, filePath);
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, filePath);
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, filePath, Buffer.from(content, 'utf8'));
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 });
@@ -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
- const ip = req?.socket?.remoteAddress || '';
85
- const isLoopback = isLoopbackAddress(ip);
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 = req?.headers?.['sec-fetch-site'];
88
- // Fail-closed: unconditionally reject cross-site and cross-origin requests
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
- // If origin is present, ensure it matches the request host
94
- const origin = req?.headers?.['origin'];
95
- const host = req?.headers?.['host'];
96
- if (origin && host) {
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 url = new URL(origin);
99
- if (url.host !== host) {
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 rawAuth = req?.headers?.['authorization'];
108
- const cookieHeader = req?.headers?.['cookie'];
109
- const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
119
+ const tokenCandidate = (typeof expectedToken === 'string' && expectedToken)
120
+ || process.env.DSH_AUTH_TOKEN
121
+ || process.env.DSH_TOKEN
122
+ || null;
110
123
 
111
- if (expectedToken) {
112
- if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
113
- const token = rawAuth.slice(7).trim();
114
- if (token && token === expectedToken) return true;
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
- if (typeof cookieHeader === 'string') {
117
- const cookies = parseCookies(cookieHeader);
118
- const token = cookies['token'] || cookies['dsh_token'];
119
- if (token && token === expectedToken) return true;
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
- // Non-loopback requests without valid token must not pass simply on Sec-Fetch-Site: same-origin
125
- if (!isLoopback) return false;
126
- if (secFetch === 'same-origin') return true;
127
- return true;
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
+ }
@@ -1,4 +1,8 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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 report = SshConfigParser.parseReport(content, { baseDir: body.baseDir });
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, 200, { ok: false, error: err.message });
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');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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 }) {
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
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,
@@ -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
- function defaultExpandInclude(pattern, baseDir) {
11
- const expanded = expandHome(pattern);
12
- const full = path.isAbsolute(expanded) ? expanded : path.join(baseDir, expanded);
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 { return fs.statSync(file).isFile(); } catch { return false; }
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
- return fs.existsSync(full) ? [full] : [];
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 expandInclude = options.expandInclude || ((pattern) => defaultExpandInclude(pattern, baseDir));
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') current.proxyCommand = val;
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 };
@@ -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 += str;
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 += str;
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
  });