@goodandready/dsh-remote-workspace 0.3.9 → 0.3.10

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/lib/index.js CHANGED
@@ -305,6 +305,7 @@ export function apply(ctx, config = {}) {
305
305
 
306
306
  // Cleanup on unload
307
307
  ctx.on('dispose', () => {
308
+ clearInterval(idleTimer);
308
309
  alertService.stop();
309
310
  watcherService.stopAll();
310
311
  tunnelService.stopAll();
@@ -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 registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
4
4
  // 21. GET /dsh-remote-workspace/health
@@ -7,7 +7,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
7
7
  path: '/dsh-remote-workspace/health',
8
8
  handler: async (req, res) => {
9
9
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET 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 url = new URL(req.url, 'http://localhost');
13
13
  const profileId = url.searchParams.get('profileId');
@@ -17,7 +17,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
17
17
  const health = await healthService.getHealth(profile);
18
18
  writeJson(res, 200, { ok: true, health });
19
19
  } catch (err) {
20
- writeJson(res, 500, { ok: false, error: err.message });
20
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
21
21
  }
22
22
  }
23
23
  }), 'dsh-remote-workspace: /health');
@@ -28,7 +28,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
28
28
  path: '/dsh-remote-workspace/docker/list',
29
29
  handler: async (req, res) => {
30
30
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
31
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
31
+ if (!authorizeRequest(sctx, req, res)) return;
32
32
  try {
33
33
  const url = new URL(req.url, 'http://localhost');
34
34
  const profileId = url.searchParams.get('profileId');
@@ -38,7 +38,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
38
38
  const result = await dockerService.listContainers(profile, true);
39
39
  writeJson(res, 200, { ok: true, ...result });
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: /docker/list');
@@ -49,7 +49,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
49
49
  path: '/dsh-remote-workspace/docker/action',
50
50
  handler: async (req, res) => {
51
51
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST 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
  const { profileId, containerId, action } = await readBody(req);
55
55
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -59,7 +59,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
59
59
  const result = await dockerService.containerAction(profile, containerId, action);
60
60
  writeJson(res, 200, { ok: result.success, ...result });
61
61
  } catch (err) {
62
- writeJson(res, 500, { ok: false, error: err.message });
62
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
63
63
  }
64
64
  }
65
65
  }), 'dsh-remote-workspace: /docker/action');
@@ -70,7 +70,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
70
70
  path: '/dsh-remote-workspace/docker/logs',
71
71
  handler: async (req, res) => {
72
72
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
73
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
73
+ if (!authorizeRequest(sctx, req, res)) return;
74
74
  try {
75
75
  const { profileId, containerId, tail } = await readBody(req);
76
76
  const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
@@ -80,7 +80,7 @@ export function registerDockerRoutes(sctx, webServer, { dockerService, healthSer
80
80
  const result = await dockerService.containerLogs(profile, containerId, tail || 100);
81
81
  writeJson(res, 200, { ok: result.success, ...result });
82
82
  } catch (err) {
83
- writeJson(res, 500, { ok: false, error: err.message });
83
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
84
84
  }
85
85
  }
86
86
  }), 'dsh-remote-workspace: /docker/logs');
package/lib/routes/env.js CHANGED
@@ -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 registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store }) {
4
4
  // 10. POST /dsh-remote-workspace/env/view
@@ -7,7 +7,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
7
7
  path: '/dsh-remote-workspace/env/view',
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, remotePath } = await readBody(req);
13
13
  const profile = store.getProfile(profileId);
@@ -15,7 +15,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
15
15
  const result = await envService.readRemoteEnv(sshService, profile, remotePath);
16
16
  writeJson(res, 200, { ok: true, ...result });
17
17
  } catch (err) {
18
- writeJson(res, 500, { ok: false, error: err.message });
18
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
19
19
  }
20
20
  }
21
21
  }), 'dsh-remote-workspace: /env/view');
@@ -26,7 +26,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
26
26
  path: '/dsh-remote-workspace/env/save',
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, remotePath, content } = await readBody(req);
32
32
  const profile = store.getProfile(profileId);
@@ -34,7 +34,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
34
34
  const result = await envService.saveRemoteEnv(sshService, profile, remotePath, content);
35
35
  writeJson(res, 200, { ok: true, ...result });
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: /env/save');
@@ -45,7 +45,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
45
45
  path: '/dsh-remote-workspace/diagnose',
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 { profileId, checks } = await readBody(req);
51
51
  const profile = store.getProfile(profileId);
@@ -53,7 +53,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
53
53
  const results = await diagnoseService.diagnose(sshService, profile, checks);
54
54
  writeJson(res, 200, { ok: true, results });
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: /diagnose');
@@ -1,4 +1,4 @@
1
- import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
1
+ import { writeJson, readBody, isTrustedSettingsRequest, authorizeRequest } from './helpers.js';
2
2
  import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
3
3
 
4
4
 
@@ -27,7 +27,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
27
27
  path: '/dsh-remote-workspace/browse',
28
28
  handler: async (req, res) => {
29
29
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
30
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
+ if (!authorizeRequest(sctx, req, res)) return;
31
31
  try {
32
32
  const body = await readBody(req);
33
33
  let profile = null;
@@ -48,7 +48,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
48
48
  const entries = await remoteFs.listDir(profile, targetPath);
49
49
  writeJson(res, 200, { ok: true, entries, items: entries, currentPath: targetPath });
50
50
  } catch (err) {
51
- writeJson(res, 500, { ok: false, error: err.message });
51
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
52
52
  }
53
53
  }
54
54
  }), 'dsh-remote-workspace: /browse');
@@ -59,7 +59,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
59
59
  path: '/dsh-remote-workspace/file/view',
60
60
  handler: async (req, res) => {
61
61
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
62
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
62
+ if (!authorizeRequest(sctx, req, res)) return;
63
63
  try {
64
64
  const { profileId, filePath } = await readBody(req);
65
65
  const profile = store.getProfile(profileId);
@@ -71,7 +71,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
71
71
  const buf = await remoteFs.readFile(profile, filePath);
72
72
  writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
73
73
  } catch (err) {
74
- writeJson(res, 500, { ok: false, error: err.message });
74
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
75
75
  }
76
76
  }
77
77
  }), 'dsh-remote-workspace: /file/view');
@@ -82,7 +82,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
82
82
  path: '/dsh-remote-workspace/file/save',
83
83
  handler: async (req, res) => {
84
84
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
85
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
85
+ if (!authorizeRequest(sctx, req, res)) return;
86
86
  try {
87
87
  const { profileId, filePath, content } = await readBody(req);
88
88
  const profile = store.getProfile(profileId);
@@ -90,7 +90,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
90
90
  await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
91
91
  writeJson(res, 200, { ok: true });
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: /file/save');
@@ -100,7 +100,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
100
100
  path: '/dsh-remote-workspace/file/download',
101
101
  handler: async (req, res) => {
102
102
  if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
103
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
103
+ if (!authorizeRequest(sctx, req, res)) return;
104
104
  try {
105
105
  const { profile, filePath } = transferTarget(req, store);
106
106
  const stat = await remoteFs.stat(profile, filePath);
@@ -128,7 +128,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
128
128
  path: '/dsh-remote-workspace/file/upload',
129
129
  handler: async (req, res) => {
130
130
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
131
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
131
+ if (!authorizeRequest(sctx, req, res)) return;
132
132
  let upload = null;
133
133
  try {
134
134
  const { profile, filePath } = transferTarget(req, store);
@@ -156,7 +156,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
156
156
  path: '/dsh-remote-workspace/transfer',
157
157
  handler: async (req, res) => {
158
158
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
159
- if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
159
+ if (!authorizeRequest(sctx, req, res)) return;
160
160
  try {
161
161
  const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
162
162
  const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
@@ -169,7 +169,7 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
169
169
  const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
170
170
  writeJson(res, 200, { ok: result.success, ...result });
171
171
  } catch (err) {
172
- writeJson(res, 500, { ok: false, error: err.message });
172
+ writeJson(res, err.statusCode || (err.name === 'SyntaxError' ? 400 : 500), { ok: false, error: err.message });
173
173
  }
174
174
  }
175
175
  }), 'dsh-remote-workspace: /transfer');
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.9",
3
+ "version": "0.3.10",
4
4
  "description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",