cf-memory-mcp 3.9.6 → 3.9.8
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 +6 -0
- package/bin/cf-memory-mcp.js +89 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -137,6 +137,12 @@ That deploys the active worker and applies the simplified D1 schema and required
|
|
|
137
137
|
- [bin/cf-memory-mcp.js](/Users/johnlam/cf-memory-mcp/bin/cf-memory-mcp.js)
|
|
138
138
|
- [bin/cf-memory-mcp-indexer.js](/Users/johnlam/cf-memory-mcp/bin/cf-memory-mcp-indexer.js)
|
|
139
139
|
|
|
140
|
+
## Security
|
|
141
|
+
|
|
142
|
+
The bridge (`bin/cf-memory-mcp.js`) reads local files for several tools — `get_file_content`, `refresh_files`, `find_stale_files`, `refresh_stale`, and the staleness annotation pass on `retrieve_context` results. Every one of those code paths confines file access to the resolved project root via `fs.realpathSync` + relative-path check, so absolute paths, `..` traversal, and in-root symlinks pointing outside are all rejected before any read. Locked in by the test suite under `tests/bridge.test.ts`.
|
|
143
|
+
|
|
144
|
+
If you find a path that escapes the project root despite this, please open an issue with a reproduction.
|
|
145
|
+
|
|
140
146
|
## Notes
|
|
141
147
|
|
|
142
148
|
- The simplified worker is the active product path.
|
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');
|
|
@@ -1116,7 +1168,24 @@ class CFMemoryMCP {
|
|
|
1116
1168
|
}
|
|
1117
1169
|
|
|
1118
1170
|
async handleIndexProject(message) {
|
|
1119
|
-
const
|
|
1171
|
+
const args = message.params?.arguments || {};
|
|
1172
|
+
const { project_path, project_name, include_patterns, exclude_patterns, force_reindex } = args;
|
|
1173
|
+
|
|
1174
|
+
// Boundary validation: bad inputs were producing unhelpful Node errors
|
|
1175
|
+
// ("path must be string"). Return a clean MCP error with the hint
|
|
1176
|
+
// instead so the model can self-correct.
|
|
1177
|
+
if (typeof project_path !== 'string' || !project_path.trim()) {
|
|
1178
|
+
process.stdout.write(JSON.stringify({
|
|
1179
|
+
jsonrpc: '2.0',
|
|
1180
|
+
id: message.id,
|
|
1181
|
+
result: { content: [{ type: 'text', text: JSON.stringify({
|
|
1182
|
+
error: 'project_path is required',
|
|
1183
|
+
hint: 'Pass project_path as an absolute or relative filesystem path to the project root (e.g., "/Users/me/code/myrepo" or ".").',
|
|
1184
|
+
}, null, 2) }] },
|
|
1185
|
+
}) + '\n');
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1120
1189
|
const resolvedPath = path.resolve(project_path);
|
|
1121
1190
|
const name = project_name || path.basename(resolvedPath);
|
|
1122
1191
|
|
|
@@ -1912,11 +1981,25 @@ class CFMemoryMCP {
|
|
|
1912
1981
|
|
|
1913
1982
|
// Try to find a local project root. We watch CF_MEMORY_WATCH_PATH
|
|
1914
1983
|
// (when auto-watch is on) or fall back to the cwd.
|
|
1915
|
-
const
|
|
1984
|
+
const rawRoot = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
1985
|
+
// Realpath + relative-path check — same defense as
|
|
1986
|
+
// maybeServeLocalFileContent. Server-supplied file_paths SHOULD be
|
|
1987
|
+
// relative-to-root, but if an old or buggy index stored an
|
|
1988
|
+
// absolute or escaping path, we'd otherwise read it (and reveal
|
|
1989
|
+
// its hash via the stale flag).
|
|
1990
|
+
const root = (() => {
|
|
1991
|
+
try { return fs.realpathSync(rawRoot); }
|
|
1992
|
+
catch (_) { return rawRoot; }
|
|
1993
|
+
})();
|
|
1916
1994
|
const stalePaths = new Set();
|
|
1917
1995
|
for (const r of results) {
|
|
1918
1996
|
if (!r || !r.file_path || !r.indexed_file_hash) continue;
|
|
1919
|
-
const
|
|
1997
|
+
const candidate = path.resolve(root, r.file_path);
|
|
1998
|
+
let full;
|
|
1999
|
+
try { full = fs.realpathSync(candidate); }
|
|
2000
|
+
catch (_) { continue; }
|
|
2001
|
+
const relInside = path.relative(root, full);
|
|
2002
|
+
if (!relInside || relInside.startsWith('..') || path.isAbsolute(relInside)) continue;
|
|
1920
2003
|
let content;
|
|
1921
2004
|
try { content = fs.readFileSync(full, 'utf8'); } catch (_) { continue; }
|
|
1922
2005
|
// 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.8",
|
|
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": {
|