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.
- package/bin/cf-memory-mcp.js +97 -11
- package/package.json +1 -1
package/bin/cf-memory-mcp.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
1812
|
-
|
|
1813
|
-
//
|
|
1814
|
-
//
|
|
1815
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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": {
|