ftp-mcp 1.2.1 → 1.3.0

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/README.md CHANGED
@@ -151,6 +151,10 @@ Deploy environments utilizing deep comparison trees via `ftp_sync`.
151
151
  - Audit Logging is continuously enabled. Ensure the terminal running the MCP protocol maintains structural write-permissions to the local project deployment folder.
152
152
  - Ensure any `currentConfig` caching resets are tied exclusively to `ftp_connect` profile pivoting.
153
153
 
154
+ ## Changelog
155
+
156
+ See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes in each release.
157
+
154
158
  ## License
155
159
 
156
160
  [MIT](LICENSE)
package/index.js CHANGED
@@ -21,13 +21,24 @@ import { SnapshotManager } from "./snapshot-manager.js";
21
21
  import { PolicyEngine } from "./policy-engine.js";
22
22
  import { SyncManifestManager } from "./sync-manifest.js";
23
23
  import crypto from "crypto";
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = path.dirname(__filename);
27
+
28
+ // Read version from package.json to avoid version drift (CODE-1)
29
+ let SERVER_VERSION = "1.3.0";
30
+ try {
31
+ const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
32
+ SERVER_VERSION = pkg.version || SERVER_VERSION;
33
+ } catch (e) { /* fallback to hardcoded */ }
34
+
24
35
  const DEFAULT_CONFIG_NAME = ".ftpconfig";
25
36
  const CONFIG_FILE = process.env.FTP_CONFIG_PATH || path.join(process.cwd(), DEFAULT_CONFIG_NAME);
26
37
 
27
38
  // --init: scaffold .ftpconfig.example into the user's current working directory
28
39
  if (process.argv.includes("--init")) {
29
40
  try {
30
- const { intro, outro, text, password: promptPassword, select, confirm, note } = await import("@clack/prompts");
41
+ const { intro, outro, text, password: promptPassword, select, confirm, note, isCancel } = await import("@clack/prompts");
31
42
 
32
43
  intro('🚀 Welcome to FTP-MCP Initialization Wizard');
33
44
 
@@ -36,30 +47,35 @@ if (process.argv.includes("--init")) {
36
47
  placeholder: 'sftp://127.0.0.1',
37
48
  validate: (val) => val.length === 0 ? "Host is required!" : undefined,
38
49
  });
50
+ if (isCancel(host)) { outro('Setup cancelled.'); process.exit(0); }
39
51
 
40
52
  const user = await text({
41
53
  message: 'Enter your Username',
42
54
  validate: (val) => val.length === 0 ? "User is required!" : undefined,
43
55
  });
56
+ if (isCancel(user)) { outro('Setup cancelled.'); process.exit(0); }
44
57
 
45
58
  const pass = await promptPassword({
46
59
  message: 'Enter your Password (optional if using keys)',
47
60
  });
61
+ if (isCancel(pass)) { outro('Setup cancelled.'); process.exit(0); }
48
62
 
49
63
  const port = await text({
50
64
  message: 'Enter port (optional, defaults to 21 for FTP, 22 for SFTP)',
51
65
  placeholder: '22'
52
66
  });
67
+ if (isCancel(port)) { outro('Setup cancelled.'); process.exit(0); }
53
68
 
54
- const isSFTP = host.startsWith('sftp://');
69
+ const isSFTP = typeof host === 'string' && host.startsWith('sftp://');
55
70
  let privateKey = '';
56
71
 
57
72
  if (isSFTP) {
58
73
  const usesKey = await confirm({ message: 'Are you using an SSH Private Key instead of a password?' });
59
- if (usesKey) {
74
+ if (!isCancel(usesKey) && usesKey) {
60
75
  privateKey = await text({
61
76
  message: 'Path to your private key (e.g. ~/.ssh/id_rsa)',
62
77
  });
78
+ if (isCancel(privateKey)) { outro('Setup cancelled.'); process.exit(0); }
63
79
  }
64
80
  }
65
81
 
@@ -157,9 +173,64 @@ function isSecretFile(filePath) {
157
173
  name.includes('token');
158
174
  }
159
175
 
176
+ /**
177
+ * SEC-1/2/3: Validate that a local path stays within the configured safe root.
178
+ * Prevents path traversal / arbitrary file read-write attacks.
179
+ * If no safeRoot configured, defaults to user home directory as a broad guard.
180
+ */
181
+ function validateLocalPath(localPath, safeRoot = null) {
182
+ const resolved = path.resolve(localPath);
183
+ const root = safeRoot ? path.resolve(safeRoot) : null;
184
+ if (root) {
185
+ if (!resolved.startsWith(root + path.sep) && resolved !== root) {
186
+ throw new Error(`Security: Local path '${localPath}' is outside the allowed directory '${root}'.`);
187
+ }
188
+ }
189
+ // Always block traversal separators in the raw input regardless of root config
190
+ const normalized = localPath.replace(/\\/g, '/');
191
+ if (normalized.includes('../')) {
192
+ throw new Error(`Security: Path traversal detected in local path '${localPath}'.`);
193
+ }
194
+ return resolved;
195
+ }
196
+
197
+ /**
198
+ * SEC-4: Safely compile a user-supplied regex pattern.
199
+ * Returns null and an error message if the pattern is invalid or too long.
200
+ */
201
+ function safeRegex(pattern, flags = 'i') {
202
+ if (!pattern || typeof pattern !== 'string') return { regex: null, error: null };
203
+ if (pattern.length > 250) {
204
+ return { regex: null, error: 'Regex pattern too long (max 250 chars).' };
205
+ }
206
+ try {
207
+ return { regex: new RegExp(pattern, flags), error: null };
208
+ } catch (e) {
209
+ return { regex: null, error: `Invalid regex: ${e.message}` };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * CODE-5: Reject dangerously shallow remote paths on destructive operations.
215
+ * Prevents accidental or malicious deletion of root-level directories.
216
+ */
217
+ function assertSafeRemotePath(remotePath) {
218
+ const clean = (remotePath || '').replace(/\/+$/, '') || '/';
219
+ const depth = clean.split('/').filter(Boolean).length;
220
+ if (depth < 1 || clean === '/') {
221
+ throw new Error(`Safety: Refusing to operate on root path '${clean}'. Provide a more specific path.`);
222
+ }
223
+ // Also block well-known dangerous Unix roots
224
+ const dangerous = ['/etc', '/bin', '/sbin', '/usr', '/var', '/lib', '/home', '/root', '/boot', '/dev', '/proc', '/sys'];
225
+ if (dangerous.includes(clean)) {
226
+ throw new Error(`Safety: Refusing to operate on system path '${clean}'.`);
227
+ }
228
+ }
229
+
160
230
  function shouldIgnore(filePath, ignorePatterns, basePath) {
161
231
  const relativePath = path.relative(basePath, filePath).replace(/\\/g, '/');
162
232
 
233
+
163
234
  if (!ignorePatterns._ig) {
164
235
  Object.defineProperty(ignorePatterns, '_ig', {
165
236
  value: ignore().add(ignorePatterns),
@@ -238,13 +309,15 @@ async function loadFTPConfig(profileName = null, forceEnv = false) {
238
309
  }
239
310
 
240
311
  function getPort(host, configPort) {
241
- if (configPort) return parseInt(configPort);
242
- if (host && (host.includes('sftp') || host.startsWith('sftp://'))) return 22;
312
+ if (configPort) return parseInt(configPort, 10);
313
+ // LOW-4: Use strict prefix check, not includes() which matches e.g. "mysftp-server.com"
314
+ if (host && host.startsWith('sftp://')) return 22;
243
315
  return 21;
244
316
  }
245
317
 
246
318
  function isSFTP(host) {
247
- return host && (host.includes('sftp') || host.startsWith('sftp://'));
319
+ // LOW-4: Only match the sftp:// protocol prefix
320
+ return !!(host && host.startsWith('sftp://'));
248
321
  }
249
322
 
250
323
  async function connectFTP(config) {
@@ -303,12 +376,19 @@ function getCached(poolKey, type, path) {
303
376
  telemetry.cacheHits++;
304
377
  return entry.data;
305
378
  }
379
+ // CODE-3: Evict stale entry while we're here
380
+ if (entry) dirCache.delete(`${poolKey}:${type}:${path}`);
306
381
  telemetry.cacheMisses++;
307
382
  return null;
308
383
  }
309
384
 
310
385
  function setCached(poolKey, type, path, data) {
311
386
  dirCache.set(`${poolKey}:${type}:${path}`, { timestamp: Date.now(), data });
387
+ // CODE-3: Evict any entries older than TTL to prevent unbounded growth
388
+ const now = Date.now();
389
+ for (const [key, entry] of dirCache.entries()) {
390
+ if (now - entry.timestamp >= CACHE_TTL) dirCache.delete(key);
391
+ }
312
392
  }
313
393
 
314
394
  function invalidatePoolCache(poolKey) {
@@ -427,7 +507,8 @@ async function auditLog(toolName, args, status, user, errorMsg = null) {
427
507
  };
428
508
  await fs.appendFile(path.join(process.cwd(), '.ftp-mcp-audit.log'), JSON.stringify(logEntry) + '\n', 'utf8');
429
509
  } catch (e) {
430
- // safely ignore audit log failures for now
510
+ // QUAL-5: Emit to stderr so audit failures are not silently swallowed
511
+ console.error('[ftp-mcp] Warning: Failed to write audit log:', e.message);
431
512
  }
432
513
  }
433
514
 
@@ -450,7 +531,7 @@ function releaseClient(config) {
450
531
  async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth = 10) {
451
532
  if (depth > maxDepth) return [];
452
533
 
453
- const files = useSFTP ? await client.list(remotePath) : await client.list(remotePath);
534
+ const files = await client.list(remotePath);
454
535
  const results = [];
455
536
 
456
537
  for (const file of files) {
@@ -475,12 +556,13 @@ async function getTreeRecursive(client, useSFTP, remotePath, depth = 0, maxDepth
475
556
  return results;
476
557
  }
477
558
 
478
- async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true) {
559
+ async function syncFiles(client, useSFTP, localPath, remotePath, direction, ignorePatterns = null, basePath = null, extraExclude = [], dryRun = false, useManifest = true, _isTopLevel = false) {
479
560
  const stats = { uploaded: 0, downloaded: 0, skipped: 0, errors: [], ignored: 0, filesToChange: [] };
480
561
 
481
562
  if (ignorePatterns === null) {
482
563
  ignorePatterns = await loadIgnorePatterns(localPath);
483
564
  basePath = localPath;
565
+ _isTopLevel = true;
484
566
  if (useManifest) await syncManifestManager.load();
485
567
  }
486
568
 
@@ -496,8 +578,8 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
496
578
  const remoteFilePath = `${remotePath}/${file.name}`;
497
579
 
498
580
  // In some environments (like Windows with ftp-srv), rapid transfers cause ECONNRESET.
499
- // A slightly longer delay helps stabilize the socket state during sequence.
500
- await new Promise(r => setTimeout(r, 250));
581
+ // A short delay helps stabilize the socket state during sequence (FTP only).
582
+ if (!useSFTP) await new Promise(r => setTimeout(r, 50));
501
583
 
502
584
  // Security check first so we can warn even if it's in .gitignore/.ftpignore
503
585
  if (isSecretFile(localFilePath)) {
@@ -602,7 +684,7 @@ async function syncFiles(client, useSFTP, localPath, remotePath, direction, igno
602
684
  }
603
685
  }
604
686
 
605
- if (ignorePatterns === null && useManifest && !dryRun) {
687
+ if (_isTopLevel && useManifest && !dryRun) {
606
688
  await syncManifestManager.save();
607
689
  }
608
690
 
@@ -657,7 +739,7 @@ function generateSemanticPreview(filesToChange) {
657
739
  const server = new Server(
658
740
  {
659
741
  name: "ftp-mcp-server",
660
- version: "1.2.1",
742
+ version: SERVER_VERSION, // CODE-1: reads from package.json at startup
661
743
  },
662
744
  {
663
745
  capabilities: {
@@ -980,8 +1062,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
980
1062
  },
981
1063
  direction: {
982
1064
  type: "string",
983
- description: "Sync direction: 'upload', 'download', or 'both'",
984
- enum: ["upload", "download", "both"],
1065
+ // QUAL-2: Only 'upload' is implemented; removed 'download'/'both' to avoid silent no-ops
1066
+ description: "Sync direction: currently only 'upload' is supported",
1067
+ enum: ["upload"],
985
1068
  default: "upload"
986
1069
  },
987
1070
  dryRun: {
@@ -1182,8 +1265,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1182
1265
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1183
1266
  if (request.params.name === "ftp_list_deployments") {
1184
1267
  try {
1185
- const configPath = path.join(process.cwd(), '.ftpconfig');
1186
- const configData = await fs.readFile(configPath, 'utf8');
1268
+ const configData = await fs.readFile(CONFIG_FILE, 'utf8');
1187
1269
  const config = JSON.parse(configData);
1188
1270
 
1189
1271
  if (!config.deployments || Object.keys(config.deployments).length === 0) {
@@ -1216,8 +1298,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1216
1298
  if (request.params.name === "ftp_deploy") {
1217
1299
  try {
1218
1300
  const { deployment } = request.params.arguments;
1219
- const configPath = path.join(process.cwd(), '.ftpconfig');
1220
- const configData = await fs.readFile(configPath, 'utf8');
1301
+ const configData = await fs.readFile(CONFIG_FILE, 'utf8');
1221
1302
  const config = JSON.parse(configData);
1222
1303
 
1223
1304
  if (!config.deployments || !config.deployments[deployment]) {
@@ -1243,16 +1324,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1243
1324
  };
1244
1325
  }
1245
1326
 
1246
- currentConfig = profileConfig;
1247
- currentProfile = deployConfig.profile;
1248
-
1249
- const useSFTP = isSFTP(currentConfig.host);
1250
- const client = await getClient(currentConfig);
1327
+ // CODE-2: Use a local-scoped config to avoid mutating the module-level
1328
+ // currentConfig/currentProfile globals, which would corrupt subsequent tool calls.
1329
+ const deployProfileConfig = profileConfig;
1330
+ const deployProfileName = deployConfig.profile;
1331
+ const useSFTP = isSFTP(deployProfileConfig.host);
1332
+ const deployEntry = await getClient(deployProfileConfig);
1251
1333
 
1252
1334
  try {
1253
1335
  const localPath = path.resolve(deployConfig.local);
1254
1336
  const stats = await syncFiles(
1255
- client,
1337
+ deployEntry.client,
1256
1338
  useSFTP,
1257
1339
  localPath,
1258
1340
  deployConfig.remote,
@@ -1265,11 +1347,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1265
1347
  return {
1266
1348
  content: [{
1267
1349
  type: "text",
1268
- text: `Deployment "${deployment}" complete:\n${deployConfig.description || ''}\n\nProfile: ${deployConfig.profile}\nLocal: ${deployConfig.local}\nRemote: ${deployConfig.remote}\n\nUploaded: ${stats.uploaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
1350
+ text: `Deployment "${deployment}" complete:\n${deployConfig.description || ''}\n\nProfile: ${deployProfileName}\nLocal: ${deployConfig.local}\nRemote: ${deployConfig.remote}\n\nUploaded: ${stats.uploaded}\nSkipped: ${stats.skipped}\nIgnored: ${stats.ignored}\n${stats.errors.length > 0 ? '\nErrors:\n' + stats.errors.join('\n') : ''}`
1269
1351
  }]
1270
1352
  };
1271
1353
  } finally {
1272
- releaseClient(currentConfig);
1354
+ releaseClient(deployProfileConfig);
1273
1355
  }
1274
1356
  } catch (error) {
1275
1357
  return {
@@ -1297,6 +1379,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1297
1379
  };
1298
1380
  }
1299
1381
 
1382
+ // LOW-5: Perform a real connection test so credential errors surface early
1383
+ try {
1384
+ const testEntry = await getClient(currentConfig);
1385
+ await testEntry.execute(c => c.list('.'));
1386
+ } catch (connErr) {
1387
+ currentConfig = null;
1388
+ return {
1389
+ content: [{ type: "text", text: `Error: Could not connect — ${connErr.message}` }],
1390
+ isError: true
1391
+ };
1392
+ }
1393
+
1300
1394
  let warning = "";
1301
1395
  const isProd = (profile || currentProfile || '').toLowerCase().includes('prod');
1302
1396
  if (isProd && !isSFTP(currentConfig.host)) {
@@ -1376,7 +1470,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1376
1470
 
1377
1471
  let files = getCached(poolKey, 'LIST', path);
1378
1472
  if (!files) {
1379
- files = useSFTP ? await client.list(path) : await client.list(path);
1473
+ files = await client.list(path);
1380
1474
  files.sort((a, b) => a.name.localeCompare(b.name));
1381
1475
  setCached(poolKey, 'LIST', path, files);
1382
1476
  }
@@ -1536,10 +1630,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1536
1630
  }
1537
1631
 
1538
1632
  case "ftp_stat": {
1539
- const { path } = request.params.arguments;
1633
+ const { path: filePath } = request.params.arguments;
1540
1634
 
1541
1635
  if (useSFTP) {
1542
- const stats = await client.stat(path);
1636
+ const stats = await client.stat(filePath);
1543
1637
  return {
1544
1638
  content: [{
1545
1639
  type: "text",
@@ -1554,13 +1648,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1554
1648
  }]
1555
1649
  };
1556
1650
  } else {
1557
- const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
1558
- const fileName = path.substring(path.lastIndexOf('/') + 1);
1559
- const files = await client.list(dirPath);
1651
+ // CODE-4: use filePath (not path module) for string operations
1652
+ const dirPart = filePath.substring(0, filePath.lastIndexOf('/')) || '.';
1653
+ const fileName = filePath.substring(filePath.lastIndexOf('/') + 1);
1654
+ const files = await client.list(dirPart);
1560
1655
  const file = files.find(f => f.name === fileName);
1561
1656
 
1562
1657
  if (!file) {
1563
- throw new Error(`File not found: ${path}`);
1658
+ throw new Error(`File not found: ${filePath}`);
1564
1659
  }
1565
1660
 
1566
1661
  return {
@@ -1578,17 +1673,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1578
1673
  }
1579
1674
 
1580
1675
  case "ftp_exists": {
1581
- const { path } = request.params.arguments;
1676
+ const { path: filePath } = request.params.arguments;
1582
1677
  let exists = false;
1583
1678
 
1584
1679
  try {
1585
1680
  if (useSFTP) {
1586
- await client.stat(path);
1681
+ await client.stat(filePath);
1587
1682
  exists = true;
1588
1683
  } else {
1589
- const dirPath = path.substring(0, path.lastIndexOf('/')) || '.';
1590
- const fileName = path.substring(path.lastIndexOf('/') + 1);
1591
- const files = await client.list(dirPath);
1684
+ // CODE-4: use filePath string, not the path module
1685
+ const dirPart = filePath.substring(0, filePath.lastIndexOf('/')) || '.';
1686
+ const fileName = filePath.substring(filePath.lastIndexOf('/') + 1);
1687
+ const files = await client.list(dirPart);
1592
1688
  exists = files.some(f => f.name === fileName);
1593
1689
  }
1594
1690
  } catch (e) {
@@ -1626,6 +1722,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1626
1722
  return { content: [{ type: "text", text: "Error: Must provide pattern, contentPattern, or findLikelyConfigs" }], isError: true };
1627
1723
  }
1628
1724
 
1725
+ // SEC-4: Validate user-supplied regex patterns to prevent ReDoS
1726
+ let compiledPattern = null;
1727
+ if (pattern) {
1728
+ const safeGlob = pattern.replace(/\*/g, '.*').replace(/\?/g, '.');
1729
+ const result = safeRegex(safeGlob);
1730
+ if (result.error) return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
1731
+ compiledPattern = result.regex;
1732
+ }
1733
+
1734
+ let compiledContentPattern = null;
1735
+ if (contentPattern) {
1736
+ const result = safeRegex(contentPattern);
1737
+ if (result.error) return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
1738
+ compiledContentPattern = result.regex;
1739
+ }
1740
+
1629
1741
  const cacheKey = `${searchPath}:10`;
1630
1742
  let tree = getCached(poolKey, 'TREE', cacheKey);
1631
1743
  if (!tree) {
@@ -1640,9 +1752,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1640
1752
  matches = matches.filter(item => configRegex.test(item.name));
1641
1753
  }
1642
1754
 
1643
- if (pattern) {
1644
- const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.'), 'i');
1645
- matches = matches.filter(item => regex.test(item.name));
1755
+ if (compiledPattern) {
1756
+ matches = matches.filter(item => compiledPattern.test(item.name));
1646
1757
  }
1647
1758
 
1648
1759
  if (extension) {
@@ -1654,8 +1765,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1654
1765
  let sliced = matches.slice(offset, offset + limit);
1655
1766
  let formatted = "";
1656
1767
 
1657
- if (contentPattern) {
1658
- const contentRegex = new RegExp(contentPattern, 'gi');
1768
+ if (compiledContentPattern) {
1659
1769
  const contentMatches = [];
1660
1770
 
1661
1771
  for (const item of sliced) {
@@ -1674,12 +1784,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1674
1784
 
1675
1785
  const lines = content.split('\n');
1676
1786
  for (let i = 0; i < lines.length; i++) {
1677
- if (contentRegex.test(lines[i])) {
1787
+ if (compiledContentPattern.test(lines[i])) {
1678
1788
  const start = Math.max(0, i - 1);
1679
1789
  const end = Math.min(lines.length - 1, i + 1);
1680
1790
  const context = lines.slice(start, end + 1).map((l, idx) => `${start + idx + 1}: ${l}`).join('\n');
1681
1791
  contentMatches.push(`File: ${item.path}\n${context}\n---`);
1682
- break; // Just show first match per file to save space
1792
+ break;
1683
1793
  }
1684
1794
  }
1685
1795
  } catch (e) {
@@ -1703,7 +1813,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1703
1813
  const path = request.params.arguments?.path || ".";
1704
1814
  let files = getCached(poolKey, 'LIST', path);
1705
1815
  if (!files) {
1706
- files = useSFTP ? await client.list(path) : await client.list(path);
1816
+ files = await client.list(path);
1707
1817
  setCached(poolKey, 'LIST', path, files);
1708
1818
  }
1709
1819
 
@@ -1800,6 +1910,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1800
1910
  };
1801
1911
  }
1802
1912
 
1913
+ // LOW-3: Validate both paths against policy engine
1914
+ try {
1915
+ policyEngine.validateOperation('overwrite', { path: destPath });
1916
+ policyEngine.validateOperation('patch', { path: sourcePath });
1917
+ } catch (e) {
1918
+ return { content: [{ type: "text", text: e.message }], isError: true };
1919
+ }
1920
+
1803
1921
  const buffer = await client.get(sourcePath);
1804
1922
  await client.put(buffer, destPath);
1805
1923
 
@@ -1811,8 +1929,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1811
1929
  case "ftp_batch_upload": {
1812
1930
  const { files } = request.params.arguments;
1813
1931
  const results = { success: [], failed: [] };
1932
+ const snapshotPaths = files.map(f => f.remotePath);
1933
+ const batchTxId = await snapshotManager.createSnapshot(client, useSFTP, snapshotPaths);
1814
1934
 
1815
1935
  for (const file of files) {
1936
+ // CODE-6/SEC-3: Apply same security guards as ftp_upload
1937
+ try { validateLocalPath(file.localPath); } catch (e) {
1938
+ results.failed.push({ path: file.remotePath, error: e.message });
1939
+ continue;
1940
+ }
1941
+ if (isSecretFile(file.localPath)) {
1942
+ results.failed.push({ path: file.remotePath, error: `Blocked: likely secret file` });
1943
+ continue;
1944
+ }
1945
+ try {
1946
+ const stat = await fs.stat(file.localPath);
1947
+ policyEngine.validateOperation('overwrite', { path: file.remotePath, size: stat.size });
1948
+ } catch (e) {
1949
+ results.failed.push({ path: file.remotePath, error: e.message });
1950
+ continue;
1951
+ }
1816
1952
  try {
1817
1953
  if (useSFTP) {
1818
1954
  await client.put(file.localPath, file.remotePath);
@@ -1828,7 +1964,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1828
1964
  return {
1829
1965
  content: [{
1830
1966
  type: "text",
1831
- text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
1967
+ text: `Uploaded: ${results.success.length}\nFailed: ${results.failed.length}\nTransaction ID: ${batchTxId}\n${results.failed.length > 0 ? '\nErrors:\n' + results.failed.map(f => `${f.path}: ${f.error}`).join('\n') : ''}`
1832
1968
  }]
1833
1969
  };
1834
1970
  }
@@ -1838,6 +1974,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1838
1974
  const results = { success: [], failed: [] };
1839
1975
 
1840
1976
  for (const file of files) {
1977
+ // CODE-7/SEC-2: Validate local paths to prevent path traversal
1978
+ try { validateLocalPath(file.localPath); } catch (e) {
1979
+ results.failed.push({ path: file.remotePath, error: e.message });
1980
+ continue;
1981
+ }
1841
1982
  try {
1842
1983
  if (useSFTP) {
1843
1984
  await client.get(file.remotePath, file.localPath);
@@ -1922,6 +2063,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1922
2063
  case "ftp_upload": {
1923
2064
  const { localPath, remotePath } = request.params.arguments;
1924
2065
 
2066
+ // SEC-3: Block path traversal and enforce local path safety
2067
+ try { validateLocalPath(localPath); } catch (e) {
2068
+ return { content: [{ type: "text", text: e.message }], isError: true };
2069
+ }
2070
+
1925
2071
  if (isSecretFile(localPath)) {
1926
2072
  return {
1927
2073
  content: [{ type: "text", text: `Security Warning: Blocked upload of likely secret file: ${localPath}` }],
@@ -1936,7 +2082,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1936
2082
  return { content: [{ type: "text", text: e.message }], isError: true };
1937
2083
  }
1938
2084
 
1939
- const txId = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
2085
+ const txIdUpload = await snapshotManager.createSnapshot(client, useSFTP, [remotePath]);
1940
2086
 
1941
2087
  if (useSFTP) {
1942
2088
  await client.put(localPath, remotePath);
@@ -1945,13 +2091,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1945
2091
  }
1946
2092
 
1947
2093
  return {
1948
- content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txId}` }]
2094
+ content: [{ type: "text", text: `Successfully uploaded ${localPath} to ${remotePath}\nTransaction ID: ${txIdUpload}` }]
1949
2095
  };
1950
2096
  }
1951
2097
 
1952
2098
  case "ftp_download": {
1953
2099
  const { remotePath, localPath } = request.params.arguments;
1954
2100
 
2101
+ // SEC-1: Block path traversal and enforce local path safety
2102
+ try { validateLocalPath(localPath); } catch (e) {
2103
+ return { content: [{ type: "text", text: e.message }], isError: true };
2104
+ }
2105
+
1955
2106
  if (useSFTP) {
1956
2107
  await client.get(remotePath, localPath);
1957
2108
  } else {
@@ -1966,13 +2117,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1966
2117
  case "ftp_delete": {
1967
2118
  const { path: filePath } = request.params.arguments;
1968
2119
 
2120
+ // CODE-5: Block dangerous root-level paths
2121
+ try { assertSafeRemotePath(filePath); } catch (e) {
2122
+ return { content: [{ type: "text", text: e.message }], isError: true };
2123
+ }
2124
+
1969
2125
  try {
1970
2126
  policyEngine.validateOperation('delete', { path: filePath });
1971
2127
  } catch (e) {
1972
2128
  return { content: [{ type: "text", text: e.message }], isError: true };
1973
2129
  }
1974
2130
 
1975
- const txId = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
2131
+ const txIdDelete = await snapshotManager.createSnapshot(client, useSFTP, [filePath]);
1976
2132
 
1977
2133
  if (useSFTP) {
1978
2134
  await client.delete(filePath);
@@ -1981,7 +2137,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1981
2137
  }
1982
2138
 
1983
2139
  return {
1984
- content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txId}` }]
2140
+ content: [{ type: "text", text: `Successfully deleted ${filePath}\nTransaction ID: ${txIdDelete}` }]
1985
2141
  };
1986
2142
  }
1987
2143
 
@@ -2000,25 +2156,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2000
2156
  }
2001
2157
 
2002
2158
  case "ftp_rmdir": {
2003
- const { path, recursive } = request.params.arguments;
2159
+ const { path: rmPath, recursive } = request.params.arguments;
2160
+
2161
+ // CODE-5: Block dangerous root-level paths
2162
+ try { assertSafeRemotePath(rmPath); } catch (e) {
2163
+ return { content: [{ type: "text", text: e.message }], isError: true };
2164
+ }
2004
2165
 
2005
2166
  if (useSFTP) {
2006
- await client.rmdir(path, recursive);
2167
+ await client.rmdir(rmPath, recursive);
2007
2168
  } else {
2008
2169
  if (recursive) {
2009
- await client.removeDir(path);
2170
+ await client.removeDir(rmPath);
2010
2171
  } else {
2011
- await client.remove(path);
2172
+ await client.removeEmptyDir(rmPath);
2012
2173
  }
2013
2174
  }
2014
2175
 
2015
2176
  return {
2016
- content: [{ type: "text", text: `Successfully removed directory ${path}` }]
2177
+ content: [{ type: "text", text: `Successfully removed directory ${rmPath}` }]
2017
2178
  };
2018
2179
  }
2019
2180
 
2020
2181
  case "ftp_chmod": {
2021
- const { path, mode } = request.params.arguments;
2182
+ const { path: chmodPath, mode } = request.params.arguments;
2022
2183
 
2023
2184
  if (!useSFTP) {
2024
2185
  return {
@@ -2026,10 +2187,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2026
2187
  };
2027
2188
  }
2028
2189
 
2029
- await client.chmod(path, mode);
2190
+ // LOW-2: Parse octal string to number; reject invalid modes
2191
+ const modeInt = parseInt(mode, 8);
2192
+ if (isNaN(modeInt) || modeInt < 0 || modeInt > 0o7777) {
2193
+ return {
2194
+ content: [{ type: "text", text: `Error: Invalid chmod mode '${mode}'. Use octal notation e.g. '755'.` }],
2195
+ isError: true
2196
+ };
2197
+ }
2198
+
2199
+ await client.chmod(chmodPath, modeInt);
2030
2200
 
2031
2201
  return {
2032
- content: [{ type: "text", text: `Successfully changed permissions of ${path} to ${mode}` }]
2202
+ content: [{ type: "text", text: `Successfully changed permissions of ${chmodPath} to ${mode}` }]
2033
2203
  };
2034
2204
  }
2035
2205
 
@@ -2045,11 +2215,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2045
2215
 
2046
2216
  const txId = await snapshotManager.createSnapshot(client, useSFTP, [oldPath, newPath]);
2047
2217
 
2048
- if (useSFTP) {
2049
- await client.rename(oldPath, newPath);
2050
- } else {
2051
- await client.rename(oldPath, newPath);
2052
- }
2218
+ await client.rename(oldPath, newPath);
2053
2219
 
2054
2220
  return {
2055
2221
  content: [{ type: "text", text: `Successfully renamed ${oldPath} to ${newPath}\nTransaction ID: ${txId}` }]
@@ -2212,7 +2378,27 @@ async function main() {
2212
2378
  console.error("FTP MCP Server running on stdio");
2213
2379
  }
2214
2380
 
2381
+ // LOW-1: Graceful shutdown — close all pooled connections before exiting
2382
+ async function shutdown(signal) {
2383
+ console.error(`[ftp-mcp] Received ${signal}, closing connections...`);
2384
+ for (const [poolKey, entry] of connectionPool.entries()) {
2385
+ try {
2386
+ if (!entry.closed) {
2387
+ entry.closed = true;
2388
+ if (entry.client._isSFTP) await entry.client.end();
2389
+ else entry.client.close();
2390
+ }
2391
+ } catch (e) { /* ignore */ }
2392
+ }
2393
+ connectionPool.clear();
2394
+ process.exit(0);
2395
+ }
2396
+
2397
+ process.on('SIGINT', () => shutdown('SIGINT'));
2398
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
2399
+
2215
2400
  main().catch((error) => {
2216
2401
  console.error("Fatal error:", error);
2217
2402
  process.exit(1);
2218
2403
  });
2404
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftp-mcp",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Enterprise-grade MCP server providing heavily optimized FTP/SFTP operations with smart sync, patch/chunk streaming, caching, and explicit read-only security mappings for AI code assistants.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/policy-engine.js CHANGED
@@ -10,7 +10,12 @@ export class PolicyEngine {
10
10
  if (!this.policies.allowedPaths || this.policies.allowedPaths.length === 0) {
11
11
  return true; // No restriction
12
12
  }
13
- return this.policies.allowedPaths.some(allowed => filePath.startsWith(allowed));
13
+ return this.policies.allowedPaths.some(allowed => {
14
+ // Normalize: ensure trailing slash to prevent /var/www matching /var/www-evil
15
+ const normalizedAllowed = allowed.endsWith('/') ? allowed : allowed + '/';
16
+ const normalizedPath = filePath.endsWith('/') ? filePath : filePath + '/';
17
+ return normalizedPath.startsWith(normalizedAllowed) || filePath === allowed;
18
+ });
14
19
  }
15
20
 
16
21
  checkBlockedGlob(filePath) {
@@ -3,6 +3,8 @@ import path from 'path';
3
3
  import crypto from 'crypto';
4
4
  import { Writable } from 'stream';
5
5
 
6
+ const MAX_SNAPSHOTS = 50;
7
+
6
8
  export class SnapshotManager {
7
9
  constructor(baseDir = process.cwd()) {
8
10
  this.snapshotDir = path.join(baseDir, '.ftp-mcp-snapshots');
@@ -16,6 +18,24 @@ export class SnapshotManager {
16
18
  }
17
19
  }
18
20
 
21
+ async pruneOldSnapshots(maxToKeep = MAX_SNAPSHOTS) {
22
+ try {
23
+ const dirs = await fs.readdir(this.snapshotDir, { withFileTypes: true });
24
+ const txDirs = dirs
25
+ .filter(d => d.isDirectory() && d.name.startsWith('tx_'))
26
+ .map(d => ({ name: d.name, ts: parseInt(d.name.split('_')[1]) || 0 }))
27
+ .sort((a, b) => b.ts - a.ts);
28
+
29
+ if (txDirs.length > maxToKeep) {
30
+ for (const dir of txDirs.slice(maxToKeep)) {
31
+ await fs.rm(path.join(this.snapshotDir, dir.name), { recursive: true, force: true });
32
+ }
33
+ }
34
+ } catch (e) {
35
+ // Ignore pruning errors
36
+ }
37
+ }
38
+
19
39
  generateTransactionId() {
20
40
  return `tx_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
21
41
  }
@@ -57,7 +77,7 @@ export class SnapshotManager {
57
77
  }
58
78
 
59
79
  if (isFile) {
60
- const localSnapshotPath = path.join(txDir, crypto.createHash('md5').update(remotePath).digest('hex'));
80
+ const localSnapshotPath = path.join(txDir, crypto.createHash('sha256').update(remotePath).digest('hex'));
61
81
 
62
82
  if (useSFTP) {
63
83
  await client.get(remotePath, localSnapshotPath);
@@ -87,6 +107,9 @@ export class SnapshotManager {
87
107
  'utf8'
88
108
  );
89
109
 
110
+ // Auto-prune old snapshots to prevent unbounded disk growth
111
+ await this.pruneOldSnapshots();
112
+
90
113
  return txId;
91
114
  }
92
115
 
package/sync-manifest.js CHANGED
@@ -28,7 +28,7 @@ export class SyncManifestManager {
28
28
  async getFileHash(filePath) {
29
29
  try {
30
30
  const content = await fs.readFile(filePath);
31
- return crypto.createHash('md5').update(content).digest('hex');
31
+ return crypto.createHash('sha256').update(content).digest('hex');
32
32
  } catch (e) {
33
33
  return null;
34
34
  }