cf-memory-mcp 3.9.5 → 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');
@@ -1808,11 +1860,27 @@ class CFMemoryMCP {
1808
1860
  // files because path.resolve treats absolute file_paths as-is and
1809
1861
  // doesn't enforce ancestry. This is a security finding from codex
1810
1862
  // review; fix before reading anything off disk.
1811
- const normalizedRoot = path.resolve(projectRoot);
1812
- const resolvedFull = path.resolve(normalizedRoot, filePath);
1813
- // Path must be strictly under the project root. `relative` returns
1814
- // an empty string for the root itself and a `..`-prefixed string
1815
- // for anything outside.
1863
+ //
1864
+ // Also dereference symlinks via fs.realpathSync — without this,
1865
+ // an attacker could create a symlink inside the project root
1866
+ // pointing at /etc/passwd and the relative-path check would
1867
+ // still pass (the symlink itself is in-root). realpath gives
1868
+ // the underlying target which we then check the same way.
1869
+ const normalizedRoot = (() => {
1870
+ try { return fs.realpathSync(path.resolve(projectRoot)); }
1871
+ catch (_) { return path.resolve(projectRoot); }
1872
+ })();
1873
+ const candidateFull = path.resolve(normalizedRoot, filePath);
1874
+ let resolvedFull;
1875
+ try {
1876
+ resolvedFull = fs.realpathSync(candidateFull);
1877
+ } catch (_) {
1878
+ return false; // doesn't exist or unreadable
1879
+ }
1880
+ // Path must be strictly under the (real) project root. `relative`
1881
+ // returns an empty string for the root itself and a `..`-prefixed
1882
+ // string for anything outside. Use the realpath of both sides so
1883
+ // symlinks can't smuggle the target out of the root.
1816
1884
  const relative = path.relative(normalizedRoot, resolvedFull);
1817
1885
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
1818
1886
  _mcpTrace('GFC_LOCAL_REJECT_ESCAPE', `${filePath} -> ${resolvedFull} escapes root ${normalizedRoot}`);
@@ -1856,7 +1924,11 @@ class CFMemoryMCP {
1856
1924
  const language = extToLang[ext] || null;
1857
1925
 
1858
1926
  const payload = {
1859
- file_path: path.relative(projectRoot, resolvedFull) || path.basename(resolvedFull),
1927
+ // Use normalizedRoot (the realpath'd root) to match resolvedFull
1928
+ // (also realpath'd). Using the original projectRoot here gave
1929
+ // weird relative paths on macOS where /tmp is a symlink to
1930
+ // /private/tmp.
1931
+ file_path: path.relative(normalizedRoot, resolvedFull) || path.basename(resolvedFull),
1860
1932
  language,
1861
1933
  total_lines: totalLines,
1862
1934
  size_bytes: stat.size,
@@ -1892,11 +1964,25 @@ class CFMemoryMCP {
1892
1964
 
1893
1965
  // Try to find a local project root. We watch CF_MEMORY_WATCH_PATH
1894
1966
  // (when auto-watch is on) or fall back to the cwd.
1895
- 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
+ })();
1896
1977
  const stalePaths = new Set();
1897
1978
  for (const r of results) {
1898
1979
  if (!r || !r.file_path || !r.indexed_file_hash) continue;
1899
- 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;
1900
1986
  let content;
1901
1987
  try { content = fs.readFileSync(full, 'utf8'); } catch (_) { continue; }
1902
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.5",
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": {