cf-memory-mcp 3.9.6 → 3.9.7

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.
@@ -867,11 +867,29 @@ class CFMemoryMCP {
867
867
 
868
868
  // Read each file locally. Note: uploadFileBatch expects `relativePath`
869
869
  // (not `path`) — it maps to `file_path` server-side.
870
+ //
871
+ // Confine each file to project_root with the same realpath-aware
872
+ // check used in maybeServeLocalFileContent. Without this, an
873
+ // attacker could pass file_paths: ["/etc/hosts"] and pollute the
874
+ // indexed corpus with arbitrary local files. Same vulnerability
875
+ // class as the get_file_content fix (codex review).
876
+ const normalizedRoot = (() => {
877
+ try { return fs.realpathSync(projectRoot); }
878
+ catch (_) { return projectRoot; }
879
+ })();
870
880
  const files = [];
871
881
  const skipped = [];
872
882
  for (const rel of filePaths) {
873
883
  try {
874
- const full = path.resolve(projectRoot, rel);
884
+ const candidate = path.resolve(normalizedRoot, rel);
885
+ let full;
886
+ try { full = fs.realpathSync(candidate); }
887
+ catch (_) { skipped.push({ path: rel, reason: 'not readable' }); continue; }
888
+ const relInside = path.relative(normalizedRoot, full);
889
+ if (!relInside || relInside.startsWith('..') || path.isAbsolute(relInside)) {
890
+ skipped.push({ path: rel, reason: 'path escapes project_root' });
891
+ continue;
892
+ }
875
893
  const stat = fs.statSync(full);
876
894
  if (!stat.isFile()) {
877
895
  skipped.push({ path: rel, reason: 'not a file' });
@@ -964,6 +982,15 @@ class CFMemoryMCP {
964
982
  // Each file in the list already carries file_hash + indexed_at
965
983
  // (list_files was extended to return them), so we don't need any
966
984
  // additional server roundtrips. Just hash the local files.
985
+ //
986
+ // Defense in depth: indexed file_paths SHOULD be relative-to-root,
987
+ // but if a previous-version bridge indexed an absolute path, this
988
+ // loop would have read /etc/hosts to compare hashes. Confine to the
989
+ // project root via realpath check just like get_file_content.
990
+ const normalizedRoot = (() => {
991
+ try { return fs.realpathSync(projectRoot); }
992
+ catch (_) { return projectRoot; }
993
+ })();
967
994
  const stale = [];
968
995
  const missing = [];
969
996
  const fresh = [];
@@ -973,7 +1000,18 @@ class CFMemoryMCP {
973
1000
  const indexedAt = f.indexed_at;
974
1001
  if (!indexedHash) continue;
975
1002
 
976
- const full = path.resolve(projectRoot, filePath);
1003
+ const candidate = path.resolve(normalizedRoot, filePath);
1004
+ let full;
1005
+ try { full = fs.realpathSync(candidate); }
1006
+ catch (_) {
1007
+ missing.push({ file_path: filePath, indexed_at: indexedAt, reason: 'file missing locally' });
1008
+ continue;
1009
+ }
1010
+ const relInside = path.relative(normalizedRoot, full);
1011
+ if (!relInside || relInside.startsWith('..') || path.isAbsolute(relInside)) {
1012
+ missing.push({ file_path: filePath, indexed_at: indexedAt, reason: 'indexed path escapes project_root (skipped for safety)' });
1013
+ continue;
1014
+ }
977
1015
  let content;
978
1016
  try {
979
1017
  content = fs.readFileSync(full, 'utf8');
@@ -1049,13 +1087,27 @@ class CFMemoryMCP {
1049
1087
  return respond({ error: 'Failed to list indexed files' });
1050
1088
  }
1051
1089
 
1090
+ // Same defense as handleFindStaleFiles: confine indexed file_paths
1091
+ // to the project root via realpath check.
1092
+ const normalizedRoot = (() => {
1093
+ try { return fs.realpathSync(projectRoot); }
1094
+ catch (_) { return projectRoot; }
1095
+ })();
1052
1096
  const staleFiles = [];
1053
1097
  const missing = [];
1054
1098
  for (const f of filesList) {
1055
1099
  const filePath = f.file_path;
1056
1100
  const indexedHash = f.file_hash;
1057
1101
  if (!indexedHash) continue;
1058
- const full = path.resolve(projectRoot, filePath);
1102
+ const candidate = path.resolve(normalizedRoot, filePath);
1103
+ let full;
1104
+ try { full = fs.realpathSync(candidate); }
1105
+ catch (_) { missing.push(filePath); continue; }
1106
+ const relInside = path.relative(normalizedRoot, full);
1107
+ if (!relInside || relInside.startsWith('..') || path.isAbsolute(relInside)) {
1108
+ missing.push(filePath); // indexed path escapes root
1109
+ continue;
1110
+ }
1059
1111
  let content;
1060
1112
  try {
1061
1113
  content = fs.readFileSync(full, 'utf8');
@@ -1912,11 +1964,25 @@ class CFMemoryMCP {
1912
1964
 
1913
1965
  // Try to find a local project root. We watch CF_MEMORY_WATCH_PATH
1914
1966
  // (when auto-watch is on) or fall back to the cwd.
1915
- const root = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
1967
+ const rawRoot = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
1968
+ // Realpath + relative-path check — same defense as
1969
+ // maybeServeLocalFileContent. Server-supplied file_paths SHOULD be
1970
+ // relative-to-root, but if an old or buggy index stored an
1971
+ // absolute or escaping path, we'd otherwise read it (and reveal
1972
+ // its hash via the stale flag).
1973
+ const root = (() => {
1974
+ try { return fs.realpathSync(rawRoot); }
1975
+ catch (_) { return rawRoot; }
1976
+ })();
1916
1977
  const stalePaths = new Set();
1917
1978
  for (const r of results) {
1918
1979
  if (!r || !r.file_path || !r.indexed_file_hash) continue;
1919
- const full = path.resolve(root, r.file_path);
1980
+ const candidate = path.resolve(root, r.file_path);
1981
+ let full;
1982
+ try { full = fs.realpathSync(candidate); }
1983
+ catch (_) { continue; }
1984
+ const relInside = path.relative(root, full);
1985
+ if (!relInside || relInside.startsWith('..') || path.isAbsolute(relInside)) continue;
1920
1986
  let content;
1921
1987
  try { content = fs.readFileSync(full, 'utf8'); } catch (_) { continue; }
1922
1988
  // SHA-256 hex of UTF-8 content. Matches the server's hashContent().
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.9.6",
3
+ "version": "3.9.7",
4
4
  "description": "Cloudflare-hosted MCP server for code indexing, retrieval, and assistant memory with a direct remote MCP endpoint and local stdio bridge.",
5
5
  "main": "bin/cf-memory-mcp.js",
6
6
  "bin": {