@goodandready/dsh-remote-workspace 0.3.9 → 0.3.11

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.
@@ -10,18 +10,54 @@ export function writeJson(res, statusCode, body) {
10
10
  res.end(JSON.stringify(body));
11
11
  }
12
12
 
13
- export function readBody(req) {
13
+ export const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MiB
14
+
15
+ export function readBody(req, maxBytes = MAX_BODY_BYTES) {
14
16
  return new Promise((resolve, reject) => {
17
+ const rawCl = req?.headers?.['content-length'];
18
+ const contentLength = rawCl !== undefined ? parseInt(Array.isArray(rawCl) ? rawCl[0] : rawCl, 10) : NaN;
19
+ if (!isNaN(contentLength) && contentLength > maxBytes) {
20
+ if (typeof req.destroy === 'function') req.destroy();
21
+ const error = new Error('Payload too large');
22
+ error.statusCode = 413;
23
+ return reject(error);
24
+ }
25
+
15
26
  let body = '';
16
- req.on('data', (chunk) => { body += chunk.toString(); });
27
+ let bytesReceived = 0;
28
+ let exceeded = false;
29
+
30
+ function onData(chunk) {
31
+ if (exceeded) return;
32
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
33
+ bytesReceived += buffer.length;
34
+ if (bytesReceived > maxBytes) {
35
+ exceeded = true;
36
+ if (typeof req.removeListener === 'function') {
37
+ req.removeListener('data', onData);
38
+ } else if (typeof req.off === 'function') {
39
+ req.off('data', onData);
40
+ }
41
+ if (typeof req.destroy === 'function') req.destroy();
42
+ const error = new Error('Payload too large');
43
+ error.statusCode = 413;
44
+ return reject(error);
45
+ }
46
+ body += buffer.toString('utf8');
47
+ }
48
+
49
+ req.on('data', onData);
17
50
  req.on('end', () => {
51
+ if (exceeded) return;
18
52
  try {
19
53
  resolve(body ? JSON.parse(body) : {});
20
54
  } catch (err) {
21
55
  reject(err);
22
56
  }
23
57
  });
24
- req.on('error', reject);
58
+ req.on('error', (err) => {
59
+ if (!exceeded) reject(err);
60
+ });
25
61
  });
26
62
  }
27
63
 
@@ -39,16 +75,24 @@ export function parseCookies(cookieHeader) {
39
75
  return cookies;
40
76
  }
41
77
 
78
+ export function isLoopbackAddress(address) {
79
+ if (!address || typeof address !== 'string') return false;
80
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address.startsWith('127.');
81
+ }
82
+
42
83
  export function isTrustedSettingsRequest(req) {
43
- const secFetch = req.headers['sec-fetch-site'];
84
+ const ip = req?.socket?.remoteAddress || '';
85
+ const isLoopback = isLoopbackAddress(ip);
86
+
87
+ const secFetch = req?.headers?.['sec-fetch-site'];
44
88
  // Fail-closed: unconditionally reject cross-site and cross-origin requests
45
89
  if (secFetch && secFetch !== 'same-origin') {
46
90
  return false;
47
91
  }
48
92
 
49
93
  // If origin is present, ensure it matches the request host
50
- const origin = req.headers['origin'];
51
- const host = req.headers['host'];
94
+ const origin = req?.headers?.['origin'];
95
+ const host = req?.headers?.['host'];
52
96
  if (origin && host) {
53
97
  try {
54
98
  const url = new URL(origin);
@@ -60,8 +104,8 @@ export function isTrustedSettingsRequest(req) {
60
104
  }
61
105
  }
62
106
 
63
- const rawAuth = req.headers['authorization'];
64
- const cookieHeader = req.headers['cookie'];
107
+ const rawAuth = req?.headers?.['authorization'];
108
+ const cookieHeader = req?.headers?.['cookie'];
65
109
  const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN;
66
110
 
67
111
  if (expectedToken) {
@@ -74,9 +118,53 @@ export function isTrustedSettingsRequest(req) {
74
118
  const token = cookies['token'] || cookies['dsh_token'];
75
119
  if (token && token === expectedToken) return true;
76
120
  }
121
+ return false;
77
122
  }
123
+
124
+ // Non-loopback requests without valid token must not pass simply on Sec-Fetch-Site: same-origin
125
+ if (!isLoopback) return false;
78
126
  if (secFetch === 'same-origin') return true;
79
- const ip = req.socket?.remoteAddress || '';
80
- if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
81
- return false;
127
+ return true;
128
+ }
129
+
130
+ export function getConnection(ctx) {
131
+ try {
132
+ return ctx?.reflect?.get ? ctx.reflect.get('connection') : (ctx?.get ? ctx.get('connection') : Reflect.get(ctx || {}, 'connection'));
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ export function authorizeRequest(sctx, req, res) {
139
+ let connection;
140
+ try {
141
+ connection = getConnection(sctx);
142
+ } catch {
143
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
144
+ return false;
145
+ }
146
+
147
+ if (!connection || typeof connection.requestRejection !== 'function') {
148
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
149
+ return false;
150
+ }
151
+
152
+ let rejection;
153
+ try {
154
+ rejection = connection.requestRejection(req);
155
+ } catch {
156
+ writeJson(res, 503, { ok: false, error: 'DSH browser authentication is unavailable' });
157
+ return false;
158
+ }
159
+
160
+ if (rejection !== undefined) {
161
+ const status = rejection === 401 ? 401 : 403;
162
+ writeJson(res, status, {
163
+ ok: false,
164
+ error: status === 401 ? 'DSH browser authentication is required' : 'Forbidden'
165
+ });
166
+ return false;
167
+ }
168
+
169
+ return true;
82
170
  }
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
  import { SshConfigParser } from '../ssh-config-parser.js';
3
3
  import { testProfileGroup } from '../profile-groups.js';
4
4
  import { filterClusterProfiles, runCluster } from '../cluster-service.js';
@@ -19,7 +19,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
19
19
  path: '/dsh-remote-workspace/state',
20
20
  handler: async (req, res) => {
21
21
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
22
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
22
+ if (!authorizeRequest(sctx, req, res)) return;
23
23
  try {
24
24
  const profiles = store.getProfiles();
25
25
  const activeId = store.getActiveId() || (profiles[0]?.id || '');
@@ -31,7 +31,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
31
31
  const terminalFontFamily = store.getTerminalFont ? store.getTerminalFont() : '';
32
32
  writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts, authPrompts, terminalFontFamily });
33
33
  } catch (err) {
34
- writeJson(res, 500, { ok: false, error: err.message });
34
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
35
35
  }
36
36
  }
37
37
  }), 'dsh-remote-workspace: /state');
@@ -42,7 +42,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
42
42
  path: '/dsh-remote-workspace/profiles/save',
43
43
  handler: async (req, res) => {
44
44
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
45
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
45
+ if (!authorizeRequest(sctx, req, res)) return;
46
46
  try {
47
47
  const profile = await readBody(req);
48
48
  if (!profile.id || !profile.host) {
@@ -53,7 +53,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
53
53
  const sanitized = store.vault ? store.vault.sanitizeProfile(profile) : maskProfile(profile);
54
54
  writeJson(res, 200, { ok: true, profile: sanitized });
55
55
  } catch (err) {
56
- writeJson(res, 500, { ok: false, error: err.message });
56
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
57
57
  }
58
58
  }
59
59
  }), 'dsh-remote-workspace: /profiles/save');
@@ -64,7 +64,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
64
64
  path: '/dsh-remote-workspace/profiles/delete',
65
65
  handler: async (req, res) => {
66
66
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
67
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
67
+ if (!authorizeRequest(sctx, req, res)) return;
68
68
  try {
69
69
  const { id } = await readBody(req);
70
70
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
@@ -72,7 +72,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
72
72
  await store.deleteProfile(id);
73
73
  writeJson(res, 200, { ok: true });
74
74
  } catch (err) {
75
- writeJson(res, 500, { ok: false, error: err.message });
75
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
76
76
  }
77
77
  }
78
78
  }), 'dsh-remote-workspace: /profiles/delete');
@@ -83,14 +83,14 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
83
83
  path: '/dsh-remote-workspace/profiles/active',
84
84
  handler: async (req, res) => {
85
85
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
86
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
86
+ if (!authorizeRequest(sctx, req, res)) return;
87
87
  try {
88
88
  const { id } = await readBody(req);
89
89
  if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
90
90
  await store.setActiveId(id);
91
91
  writeJson(res, 200, { ok: true, activeId: id });
92
92
  } catch (err) {
93
- writeJson(res, 500, { ok: false, error: err.message });
93
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
94
94
  }
95
95
  }
96
96
  }), 'dsh-remote-workspace: /profiles/active');
@@ -101,7 +101,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
101
101
  path: '/dsh-remote-workspace/profiles/import-ssh-config',
102
102
  handler: async (req, res) => {
103
103
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
104
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
104
+ if (!authorizeRequest(sctx, req, res)) return;
105
105
  try {
106
106
  const body = await readBody(req);
107
107
  const content = typeof body.content === 'string' && body.content.trim()
@@ -119,7 +119,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
119
119
  skipped: report.skipped
120
120
  });
121
121
  } catch (err) {
122
- writeJson(res, 500, { ok: false, error: err.message });
122
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
123
123
  }
124
124
  }
125
125
  }), 'dsh-remote-workspace: /profiles/import-ssh-config');
@@ -130,7 +130,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
130
130
  path: '/dsh-remote-workspace/auth/keyboard',
131
131
  handler: async (req, res) => {
132
132
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
133
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
133
+ if (!authorizeRequest(sctx, req, res)) return;
134
134
  try {
135
135
  const body = await readBody(req);
136
136
  const answers = Array.isArray(body.answers) ? body.answers.slice(0, 20).map((item) => String(item ?? '').slice(0, 256)) : [];
@@ -138,7 +138,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
138
138
  if (!accepted) return writeJson(res, 404, { ok: false, error: 'No authentication prompt is waiting' });
139
139
  writeJson(res, 200, { ok: true });
140
140
  } catch (err) {
141
- writeJson(res, 500, { ok: false, error: err.message });
141
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
142
142
  }
143
143
  }
144
144
  }), 'dsh-remote-workspace: /auth/keyboard');
@@ -149,7 +149,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
149
149
  path: '/dsh-remote-workspace/profiles/test-group',
150
150
  handler: async (req, res) => {
151
151
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
152
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
152
+ if (!authorizeRequest(sctx, req, res)) return;
153
153
  try {
154
154
  const body = await readBody(req);
155
155
  const ids = Array.isArray(body.ids) ? body.ids.map((item) => String(item)) : [];
@@ -157,7 +157,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
157
157
  const results = await testProfileGroup(selected, (profile) => sshService.testConnection(profile));
158
158
  writeJson(res, 200, { ok: true, results });
159
159
  } catch (err) {
160
- writeJson(res, 500, { ok: false, error: err.message });
160
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
161
161
  }
162
162
  }
163
163
  }), 'dsh-remote-workspace: /profiles/test-group');
@@ -168,7 +168,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
168
168
  path: '/dsh-remote-workspace/cluster',
169
169
  handler: async (req, res) => {
170
170
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
171
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
171
+ if (!authorizeRequest(sctx, req, res)) return;
172
172
  try {
173
173
  const body = await readBody(req);
174
174
  if (!body.command || typeof body.command !== 'string') return writeJson(res, 400, { ok: false, error: 'Command is required' });
@@ -183,7 +183,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
183
183
  });
184
184
  writeJson(res, 200, { ok: true, results });
185
185
  } catch (err) {
186
- writeJson(res, 500, { ok: false, error: err.message });
186
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
187
187
  }
188
188
  }
189
189
  }), 'dsh-remote-workspace: /cluster');
@@ -194,7 +194,7 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
194
194
  path: '/dsh-remote-workspace/test',
195
195
  handler: async (req, res) => {
196
196
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
197
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
197
+ if (!authorizeRequest(sctx, req, res)) return;
198
198
  try {
199
199
  const profile = await readBody(req);
200
200
  if (!profile.host) return writeJson(res, 400, { ok: false, error: 'Host is required' });
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, 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
@@ -7,7 +7,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
7
7
  path: '/dsh-remote-workspace/sync',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
+ if (!authorizeRequest(sctx, req, res)) return;
11
11
  try {
12
12
  const { profileId, direction, force, mode } = await readBody(req);
13
13
  const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
@@ -28,7 +28,7 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
28
28
  : await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
29
29
  writeJson(res, 200, { ok: resSync.success, ...resSync });
30
30
  } catch (err) {
31
- writeJson(res, 500, { ok: false, error: err.message });
31
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
32
32
  }
33
33
  }
34
34
  }), 'dsh-remote-workspace: /sync');
@@ -39,13 +39,13 @@ export function registerSyncRoutes(sctx, webServer, { mirrorSync, tarSyncService
39
39
  path: '/dsh-remote-workspace/watcher/toggle',
40
40
  handler: async (req, res) => {
41
41
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
42
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
42
+ if (!authorizeRequest(sctx, req, res)) return;
43
43
  try {
44
44
  const { enabled } = await readBody(req);
45
45
  await store.setAutoSyncEnabled(Boolean(enabled));
46
46
  writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
47
47
  } catch (err) {
48
- writeJson(res, 500, { ok: false, error: err.message });
48
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
49
49
  }
50
50
  }
51
51
  }), 'dsh-remote-workspace: /watcher/toggle');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
  import { normalizeTerminalFont } from '../terminal-font.js';
3
3
 
4
4
  export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
@@ -8,7 +8,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
8
8
  path: '/dsh-remote-workspace/terminal/font',
9
9
  handler: async (req, res) => {
10
10
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
11
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
+ if (!authorizeRequest(sctx, req, res)) return;
12
12
  try {
13
13
  const body = await readBody(req);
14
14
  const fontFamily = await store.setTerminalFont(body.fontFamily);
@@ -26,7 +26,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
26
26
  path: '/dsh-remote-workspace/terminal/create',
27
27
  handler: async (req, res) => {
28
28
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
29
+ if (!authorizeRequest(sctx, req, res)) return;
30
30
  try {
31
31
  const { profileId, cols, rows } = await readBody(req);
32
32
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -34,7 +34,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
34
34
  const session = await sshService.createTerminalSession(profile, { cols: cols || 80, rows: rows || 24 });
35
35
  writeJson(res, 200, { ok: true, sessionId: session.id });
36
36
  } catch (err) {
37
- writeJson(res, 500, { ok: false, error: err.message });
37
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
38
38
  }
39
39
  }
40
40
  }), 'dsh-remote-workspace: /terminal/create');
@@ -45,7 +45,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
45
45
  path: '/dsh-remote-workspace/terminal/input',
46
46
  handler: async (req, res) => {
47
47
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
48
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
48
+ if (!authorizeRequest(sctx, req, res)) return;
49
49
  try {
50
50
  const { sessionId, data, resize } = await readBody(req);
51
51
  const session = sshService.getTerminalSession(sessionId);
@@ -58,7 +58,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
58
58
  }
59
59
  writeJson(res, 200, { ok: true });
60
60
  } catch (err) {
61
- writeJson(res, 500, { ok: false, error: err.message });
61
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
62
62
  }
63
63
  }
64
64
  }), 'dsh-remote-workspace: /terminal/input');
@@ -69,7 +69,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
69
69
  path: '/dsh-remote-workspace/terminal/stream',
70
70
  handler: async (req, res) => {
71
71
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
72
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
72
+ if (!authorizeRequest(sctx, req, res)) return;
73
73
  try {
74
74
  const url = new URL(req.url, 'http://localhost');
75
75
  const sessionId = url.searchParams.get('sessionId');
@@ -99,7 +99,7 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
99
99
  session.subscribers.delete(sub);
100
100
  });
101
101
  } catch (err) {
102
- writeJson(res, 500, { ok: false, error: err.message });
102
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
103
103
  }
104
104
  }
105
105
  }), 'dsh-remote-workspace: /terminal/stream');
@@ -110,13 +110,13 @@ export function registerTerminalRoutes(sctx, webServer, { sshService, store }) {
110
110
  path: '/dsh-remote-workspace/terminal/close',
111
111
  handler: async (req, res) => {
112
112
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
113
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
113
+ if (!authorizeRequest(sctx, req, res)) return;
114
114
  try {
115
115
  const { sessionId } = await readBody(req);
116
116
  sshService.closeTerminalSession(sessionId);
117
117
  writeJson(res, 200, { ok: true });
118
118
  } catch (err) {
119
- writeJson(res, 500, { ok: false, error: err.message });
119
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
120
120
  }
121
121
  }
122
122
  }), 'dsh-remote-workspace: /terminal/close');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
 
3
3
  export function registerTunnelRoutes(sctx, webServer, { tunnelService, store }) {
4
4
  // 18. POST /dsh-remote-workspace/tunnels/start
@@ -7,7 +7,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
7
7
  path: '/dsh-remote-workspace/tunnels/start',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
10
+ if (!authorizeRequest(sctx, req, res)) return;
11
11
  try {
12
12
  const { profileId, localPort, targetHost, remotePort } = await readBody(req);
13
13
  const profile = (profileId ? store.getProfile(profileId) : null) || store.getActiveProfile();
@@ -20,7 +20,7 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
20
20
  const result = await tunnelService.startLocalTunnel(profile, lPort, targetHost || '127.0.0.1', rPort);
21
21
  writeJson(res, 200, { ok: result.success, ...result });
22
22
  } catch (err) {
23
- writeJson(res, 500, { ok: false, error: err.message });
23
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
24
24
  }
25
25
  }
26
26
  }), 'dsh-remote-workspace: /tunnels/start');
@@ -31,14 +31,14 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
31
31
  path: '/dsh-remote-workspace/tunnels/stop',
32
32
  handler: async (req, res) => {
33
33
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
34
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
34
+ if (!authorizeRequest(sctx, req, res)) return;
35
35
  try {
36
36
  const { tunnelId } = await readBody(req);
37
37
  if (!tunnelId) return writeJson(res, 400, { ok: false, error: 'tunnelId required' });
38
38
  const stopped = tunnelService.stopTunnel(tunnelId);
39
39
  writeJson(res, 200, { ok: stopped });
40
40
  } catch (err) {
41
- writeJson(res, 500, { ok: false, error: err.message });
41
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
42
42
  }
43
43
  }
44
44
  }), 'dsh-remote-workspace: /tunnels/stop');
@@ -49,11 +49,11 @@ export function registerTunnelRoutes(sctx, webServer, { tunnelService, store })
49
49
  path: '/dsh-remote-workspace/tunnels/telemetry',
50
50
  handler: async (req, res) => {
51
51
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
52
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
52
+ if (!authorizeRequest(sctx, req, res)) return;
53
53
  try {
54
54
  writeJson(res, 200, { ok: true, tunnels: tunnelService.listActiveTunnels() });
55
55
  } catch (err) {
56
- writeJson(res, 500, { ok: false, error: err.message });
56
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
57
57
  }
58
58
  }
59
59
  }), 'dsh-remote-workspace: /tunnels/telemetry');
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 } from './routes/helpers.js';
11
+ export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES } from './routes/helpers.js';
12
12
 
13
13
  export function registerApiRoutes(
14
14
  ctx,
@@ -44,7 +44,7 @@ export function registerApiRoutes(
44
44
 
45
45
  registerProfileRoutes(sctx, webServer, { store, sshService, alertService, tunnelService });
46
46
  registerFileRoutes(sctx, webServer, { remoteFs, store, sshService });
47
- registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store });
47
+ registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs });
48
48
  registerTerminalRoutes(sctx, webServer, { sshService, store });
49
49
  registerTunnelRoutes(sctx, webServer, { tunnelService, store });
50
50
  registerDockerRoutes(sctx, webServer, { dockerService, healthService, store });
@@ -93,6 +93,21 @@ export class SshService {
93
93
  this.connMeta.delete(id);
94
94
  closed.push(id);
95
95
  }
96
+ this.sweepAbandonedTerminalSessions(now);
97
+ return closed;
98
+ }
99
+
100
+ sweepAbandonedTerminalSessions(now = Date.now(), idleTimeoutMs = 10 * 60 * 1000) {
101
+ const closed = [];
102
+ for (const [id, session] of this.terminalSessions.entries()) {
103
+ const hasSubscribers = session.subscribers && session.subscribers.size > 0;
104
+ const lastActive = session.lastActive || session.createdAt || now;
105
+ if (!hasSubscribers && (now - lastActive >= idleTimeoutMs)) {
106
+ try { session.close(); } catch (err) { /* best-effort cleanup */ }
107
+ this.terminalSessions.delete(id);
108
+ closed.push(id);
109
+ }
110
+ }
96
111
  return closed;
97
112
  }
98
113
 
@@ -368,7 +383,8 @@ export class SshService {
368
383
  this.bumpInFlight(profile.id, -1);
369
384
  throw err;
370
385
  }
371
- const shellCmd = cwd ? `cd ${JSON.stringify(cwd)} && ${command}` : command;
386
+ const safeCwd = cwd ? "'" + String(cwd).replace(/'/g, "'\\''") + "'" : '';
387
+ const shellCmd = cwd ? `cd ${safeCwd} && ${command}` : command;
372
388
 
373
389
  const execOptions = {};
374
390
  if (pty) {
@@ -486,12 +502,15 @@ export class SshService {
486
502
  profileId: profile.id,
487
503
  stream,
488
504
  createdAt: Date.now(),
505
+ lastActive: Date.now(),
489
506
  buffer: [],
490
507
  subscribers: new Set(),
491
508
  write(data) {
509
+ session.lastActive = Date.now();
492
510
  try { stream.write(data); } catch (err) { session.status = 'disconnected'; const lost = '\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'; session.buffer.push(lost); for (const sub of session.subscribers) { try { sub(lost); } catch (e) { /* best-effort notify */ } } }
493
511
  },
494
512
  resize(c, r) {
513
+ session.lastActive = Date.now();
495
514
  try { stream.setWindow(r, c, 0, 0); } catch (err) { /* best-effort cleanup */ }
496
515
  },
497
516
  close() {
@@ -503,6 +522,7 @@ export class SshService {
503
522
  };
504
523
 
505
524
  stream.on('data', (d) => {
525
+ session.lastActive = Date.now();
506
526
  const chunk = d.toString('utf8');
507
527
  session.buffer.push(chunk);
508
528
  if (session.buffer.length > 500) session.buffer.shift();
@@ -40,14 +40,27 @@ export class TarSyncService {
40
40
 
41
41
  localTar.on('close', (code) => {
42
42
  if (code !== 0) {
43
+ try { stream.destroy(); } catch (_) {}
43
44
  reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
44
45
  } else {
45
46
  resolve({ ok: true, direction: 'pull', remoteDir, localDir });
46
47
  }
47
48
  });
48
49
 
49
- stream.on('error', reject);
50
- localTar.stdin.on('error', reject);
50
+ localTar.on('error', (err) => {
51
+ try { stream.destroy(); } catch (_) {}
52
+ reject(err);
53
+ });
54
+
55
+ stream.on('error', (err) => {
56
+ try { localTar.kill('SIGTERM'); } catch (_) {}
57
+ reject(err);
58
+ });
59
+
60
+ localTar.stdin.on('error', (err) => {
61
+ try { stream.destroy(); } catch (_) {}
62
+ reject(err);
63
+ });
51
64
  });
52
65
  });
53
66
  }
@@ -80,14 +93,22 @@ export class TarSyncService {
80
93
 
81
94
  stream.on('close', (code) => {
82
95
  if (code !== 0) {
96
+ try { localTar.kill('SIGTERM'); } catch (_) {}
83
97
  reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
84
98
  } else {
85
99
  resolve({ ok: true, direction: 'push', localDir, remoteDir });
86
100
  }
87
101
  });
88
102
 
89
- stream.on('error', reject);
90
- localTar.on('error', reject);
103
+ stream.on('error', (err) => {
104
+ try { localTar.kill('SIGTERM'); } catch (_) {}
105
+ reject(err);
106
+ });
107
+
108
+ localTar.on('error', (err) => {
109
+ try { stream.destroy(); } catch (_) {}
110
+ reject(err);
111
+ });
91
112
  });
92
113
  });
93
114
  }
@@ -73,8 +73,13 @@ export class TunnelService {
73
73
  clientSocket.pipe(stream);
74
74
  stream.pipe(clientSocket);
75
75
 
76
+ let cleaned = false;
76
77
  const cleanup = () => {
78
+ if (cleaned) return;
79
+ cleaned = true;
77
80
  tunnelMeta.activeConnections = Math.max(0, tunnelMeta.activeConnections - 1);
81
+ try { clientSocket.destroy(); } catch (err) { /* best-effort */ }
82
+ try { stream.destroy(); } catch (err) { /* best-effort */ }
78
83
  };
79
84
  clientSocket.on('close', cleanup);
80
85
  clientSocket.on('error', cleanup);