@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 +9 -0
- package/lib/remote-fs-service.js +16 -0
- package/lib/routes/env.js +23 -16
- package/lib/routes/helpers.js +15 -2
- package/lib/routes.js +1 -1
- package/lib/tools.js +61 -5
- package/package.json +1 -1
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
|
package/lib/remote-fs-service.js
CHANGED
|
@@ -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
|
|
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: '
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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
|
|
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 = {
|
|
58
|
+
result = { filePath: targetPath, updated: true };
|
|
53
59
|
} else {
|
|
54
|
-
return writeJson(res, 400, { ok: false, error: 'Either key
|
|
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
|
-
|
|
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:
|
|
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: '
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
88
|
+
}), 'dsh-remote-workspace:route:diagnose');
|
|
82
89
|
}
|
package/lib/routes/helpers.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function timingSafeCompare(a, b) {
|
|
4
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
5
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
6
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
7
|
+
if (bufA.length !== bufB.length) {
|
|
8
|
+
timingSafeEqual(bufA, bufA);
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
return timingSafeEqual(bufA, bufB);
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
export function writeJson(res, statusCode, body) {
|
|
2
15
|
if (typeof res.setHeader === 'function') {
|
|
3
16
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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.
|
|
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",
|