@goodandready/dsh-remote-workspace 0.3.15 → 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.16
4
+
5
+ ### Security & Hardening
6
+ - **Strict environment file matching in `/env/save` (#119)**: Replaced loose substring check (`baseName.includes('env')`) with exact filename validation (`isEnvFileName`: `.env`, `.env.<suffix>`, or `*.env`), preventing arbitrary file writes to paths such as `/any/dir/env` or `environment.json`.
7
+ - **Constant-time token comparison (#119)**: Introduced `timingSafeCompare` using `node:crypto.timingSafeEqual` for Bearer and Cookie token validation in `isTrustedSettingsRequest` (`lib/routes/helpers.js`).
8
+ - **Absolute remoteWorkspace enforcement (#119)**: Required `remoteWorkspace` in `resolveWorkspacePath` (`lib/tools.js`) to be an explicit absolute POSIX path starting with `/`, eliminating cwd-dependent path resolution.
9
+ - **SFTP realpath symlink-escape protection (#119)**: Added `RemoteFsService.realpath` and canonical path boundary verification (`verifyWorkspaceRealpath`) in `remote_fs` tool to block symlink targets (and symlinked parent directories on write/mkdir) that resolve outside the configured `remoteWorkspace`.
10
+
11
+
3
12
  ## 0.3.15
4
13
 
5
14
  ### Security & Hardening
@@ -80,6 +80,22 @@ export class RemoteFsService {
80
80
  });
81
81
  }
82
82
 
83
+ /**
84
+ * Resolve canonical realpath on remote host via SFTP
85
+ */
86
+ async realpath(profile, remotePath) {
87
+ const sftp = await this.sshService.getSftp(profile);
88
+ if (!sftp || typeof sftp.realpath !== 'function') {
89
+ return path.posix.normalize(remotePath);
90
+ }
91
+ return new Promise((resolve, reject) => {
92
+ sftp.realpath(remotePath, (err, absPath) => {
93
+ if (err) return reject(err);
94
+ resolve(absPath);
95
+ });
96
+ });
97
+ }
98
+
83
99
  /**
84
100
  * Stat remote path with cache
85
101
  */
package/lib/routes/env.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { writeJson, readBody, authorizeRequest, validateRemotePath } from './helpers.js';
2
2
 
3
- export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, store, remoteFs }) {
3
+ export function isEnvFileName(baseName) {
4
+ if (typeof baseName !== 'string' || !baseName) return false;
5
+ return baseName === '.env' || baseName.startsWith('.env.') || baseName.endsWith('.env');
6
+ }
7
+
8
+ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store, remoteFs }) {
4
9
  // 10. POST /dsh-remote-workspace/env/view
5
10
  sctx.effect(() => webServer.register({
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);
@@ -17,17 +22,18 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
17
22
  const result = await envService.readEnv(profile, targetPath);
18
23
  writeJson(res, 200, { ok: true, ...result });
19
24
  } catch (err) {
20
- 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 });
21
27
  }
22
28
  }
23
- }), 'dsh-remote-workspace: /env/view');
29
+ }), 'dsh-remote-workspace:route:env-view');
24
30
 
25
31
  // 11. POST /dsh-remote-workspace/env/save
26
32
  sctx.effect(() => webServer.register({
27
33
  kind: 'exact',
28
34
  path: '/dsh-remote-workspace/env/save',
29
35
  handler: async (req, res) => {
30
- 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' });
31
37
  if (!authorizeRequest(sctx, req, res)) return;
32
38
  try {
33
39
  const { profileId, remotePath, content, key, value } = await readBody(req);
@@ -36,7 +42,7 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
36
42
  const defaultPath = profile.remoteWorkspace ? `${profile.remoteWorkspace.replace(/\/+$/, '')}/.env` : '.env';
37
43
  const targetPath = validateRemotePath(remotePath || defaultPath);
38
44
  const baseName = targetPath.split('/').pop();
39
- if (!baseName.includes('env')) {
45
+ if (!isEnvFileName(baseName)) {
40
46
  return writeJson(res, 400, { ok: false, error: 'Target path must be an environment file' });
41
47
  }
42
48
  let result;
@@ -49,34 +55,35 @@ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService
49
55
  if (remoteFs && typeof remoteFs.writeFile === 'function') {
50
56
  await remoteFs.writeFile(profile, targetPath, content);
51
57
  }
52
- result = { ok: true, filePath: targetPath };
58
+ result = { filePath: targetPath, updated: true };
53
59
  } else {
54
- 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' });
55
61
  }
56
62
  writeJson(res, 200, { ok: true, ...result });
57
63
  } catch (err) {
58
- 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 });
59
66
  }
60
67
  }
61
- }), 'dsh-remote-workspace: /env/save');
68
+ }), 'dsh-remote-workspace:route:env-save');
62
69
 
63
70
  // 12. POST /dsh-remote-workspace/diagnose
64
71
  sctx.effect(() => webServer.register({
65
72
  kind: 'exact',
66
73
  path: '/dsh-remote-workspace/diagnose',
67
74
  handler: async (req, res) => {
68
- 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' });
69
76
  if (!authorizeRequest(sctx, req, res)) return;
70
77
  try {
71
- const { profileId, category, checks, target } = await readBody(req);
78
+ const { profileId, category, target } = await readBody(req);
72
79
  const profile = store.getProfile(profileId);
73
80
  if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
74
- const checkCategory = category || checks || 'memory';
75
- const result = await diagnoseService.diagnose(profile, checkCategory, target || '');
81
+ const result = await diagnoseService.diagnose(profile, category, target);
76
82
  writeJson(res, 200, { ok: true, ...result });
77
83
  } catch (err) {
78
- 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 });
79
86
  }
80
87
  }
81
- }), 'dsh-remote-workspace: /diagnose');
88
+ }), 'dsh-remote-workspace:route:diagnose');
82
89
  }
@@ -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');
@@ -111,7 +124,7 @@ export function isTrustedSettingsRequest(req, expectedToken = null) {
111
124
  const rawAuth = headers['authorization'];
112
125
  if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) {
113
126
  const bearer = rawAuth.slice(7).trim();
114
- if (tokenCandidate && bearer && bearer === tokenCandidate) {
127
+ if (tokenCandidate && bearer && timingSafeCompare(bearer, tokenCandidate)) {
115
128
  return true;
116
129
  }
117
130
  }
@@ -120,7 +133,7 @@ export function isTrustedSettingsRequest(req, expectedToken = null) {
120
133
  if (typeof cookieHeader === 'string' && tokenCandidate) {
121
134
  const cookies = parseCookies(cookieHeader);
122
135
  const token = cookies['token'] || cookies['dsh_token'];
123
- if (token && token === tokenCandidate) {
136
+ if (token && timingSafeCompare(token, tokenCandidate)) {
124
137
  return true;
125
138
  }
126
139
  }
package/lib/routes.js CHANGED
@@ -8,7 +8,7 @@ import { registerTunnelRoutes } from './routes/tunnels.js';
8
8
  import { registerDockerRoutes } from './routes/docker.js';
9
9
  import { registerSyncRoutes } from './routes/sync.js';
10
10
 
11
- export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES } from './routes/helpers.js';
11
+ export { isTrustedSettingsRequest, authorizeRequest, readBody, MAX_BODY_BYTES, timingSafeCompare } from './routes/helpers.js';
12
12
 
13
13
  export function registerApiRoutes(
14
14
  ctx,
package/lib/tools.js CHANGED
@@ -5,25 +5,76 @@ import { renderHostTable } from './host-projection.js';
5
5
  import { filterClusterProfiles, runCluster } from './cluster-service.js';
6
6
 
7
7
 
8
+ export function isWithinWorkspace(normWorkspace, candidatePath) {
9
+ const normCandidate = path.posix.normalize(candidatePath);
10
+ const prefix = normWorkspace.endsWith('/') ? normWorkspace : normWorkspace + '/';
11
+ return normCandidate === normWorkspace || normCandidate.startsWith(prefix);
12
+ }
13
+
8
14
  export function resolveWorkspacePath(workspace, targetPath) {
9
15
  if (!workspace || typeof workspace !== 'string' || !workspace.trim()) {
10
16
  throw new Error('Remote workspace is not configured for active profile');
11
17
  }
18
+ const trimmedWorkspace = workspace.trim();
19
+ if (trimmedWorkspace.includes('\0') || !path.posix.isAbsolute(trimmedWorkspace)) {
20
+ throw new Error('Remote workspace must be an absolute path starting with "/"');
21
+ }
12
22
  if (!targetPath || typeof targetPath !== 'string' || targetPath.includes('\0')) {
13
23
  throw new Error('Path must be a non-empty string without null bytes');
14
24
  }
15
- const normWorkspace = path.posix.normalize(workspace.trim());
25
+ const normWorkspace = path.posix.normalize(trimmedWorkspace);
16
26
  const resolved = path.posix.isAbsolute(targetPath)
17
27
  ? path.posix.normalize(targetPath)
18
28
  : path.posix.resolve(normWorkspace, targetPath);
19
29
 
20
- const prefix = normWorkspace.endsWith('/') ? normWorkspace : normWorkspace + '/';
21
- if (resolved !== normWorkspace && !resolved.startsWith(prefix)) {
30
+ if (!isWithinWorkspace(normWorkspace, resolved)) {
22
31
  throw new Error(`Access denied: path "${targetPath}" is outside configured remoteWorkspace "${workspace}"`);
23
32
  }
24
33
  return resolved;
25
34
  }
26
35
 
36
+ export async function verifyWorkspaceRealpath(remoteFs, profile, workspace, resolvedPath, targetPath) {
37
+ if (!remoteFs || typeof remoteFs.realpath !== 'function') {
38
+ return resolvedPath;
39
+ }
40
+ const normWorkspace = path.posix.normalize(workspace.trim());
41
+ let realWorkspace = normWorkspace;
42
+ try {
43
+ const resolvedWs = await remoteFs.realpath(profile, normWorkspace);
44
+ if (resolvedWs && typeof resolvedWs === 'string') {
45
+ realWorkspace = path.posix.normalize(resolvedWs);
46
+ }
47
+ } catch (_err) {
48
+ // Fallback to normalized configured workspace if workspace root itself cannot be resolved yet
49
+ }
50
+
51
+ let checkPath = resolvedPath;
52
+ let realCandidate = null;
53
+ try {
54
+ realCandidate = await remoteFs.realpath(profile, checkPath);
55
+ } catch (_err) {
56
+ // For non-existent leaf targets (e.g., write / mkdir), verify the parent directory realpath
57
+ const parentPath = path.posix.dirname(checkPath);
58
+ try {
59
+ const realParent = await remoteFs.realpath(profile, parentPath);
60
+ if (realParent && typeof realParent === 'string') {
61
+ realCandidate = path.posix.join(realParent, path.posix.basename(checkPath));
62
+ }
63
+ } catch (_parentErr) {
64
+ realCandidate = null;
65
+ }
66
+ }
67
+
68
+ if (realCandidate && typeof realCandidate === 'string') {
69
+ const normReal = path.posix.normalize(realCandidate);
70
+ if (!isWithinWorkspace(realWorkspace, normReal)) {
71
+ throw new Error(`Access denied: symlink target "${normReal}" for path "${targetPath}" escapes configured remoteWorkspace "${workspace}"`);
72
+ }
73
+ return normReal;
74
+ }
75
+ return resolvedPath;
76
+ }
77
+
27
78
  export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService, listProfiles = null) {
28
79
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
29
80
  return;
@@ -93,8 +144,13 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
93
144
  if (!profile) throw new Error('No active remote workspace profile selected');
94
145
  const { action, path: targetPath, content, recursive, bypassCache } = args;
95
146
 
96
- const resolvedPath = resolveWorkspacePath(profile.remoteWorkspace, targetPath);
97
- if (action === 'remove' && resolvedPath === path.posix.normalize(profile.remoteWorkspace.trim())) {
147
+ const lexicalPath = resolveWorkspacePath(profile.remoteWorkspace, targetPath);
148
+ const normWs = path.posix.normalize(profile.remoteWorkspace.trim());
149
+ if (action === 'remove' && lexicalPath === normWs) {
150
+ throw new Error('Cannot remove root workspace directory');
151
+ }
152
+ const resolvedPath = await verifyWorkspaceRealpath(remoteFs, profile, profile.remoteWorkspace, lexicalPath, targetPath);
153
+ if (action === 'remove' && resolvedPath === normWs) {
98
154
  throw new Error('Cannot remove root workspace directory');
99
155
  }
100
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.3.15",
3
+ "version": "0.3.16",
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",