Skip to content

Commit 729cc81

Browse files
committed
Fix path traversal in add_*_attachment MCP tools
Validate that filePath is within projectDir before reading, preventing arbitrary file reads via absolute paths or ../ traversal.
1 parent e77a14f commit 729cc81

8 files changed

Lines changed: 84 additions & 22 deletions

File tree

demo-projects/graph-memory.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ author:
33
email: team@shopflow.dev
44

55
server:
6-
host: "0.0.0.0"
6+
host: "127.0.0.1"
77
port: 3000
88
sessionTimeout: 1800
99
model:

src/api/tools/knowledge/add-attachment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export function register(server: McpServer, mgr: KnowledgeGraphManager): void {
2020
},
2121
async ({ noteId, filePath }) => {
2222
const resolved = path.resolve(filePath);
23+
24+
const projectDir = mgr.projectDir;
25+
if (projectDir) {
26+
const normalizedProject = path.resolve(projectDir) + path.sep;
27+
if (!resolved.startsWith(normalizedProject) && resolved !== path.resolve(projectDir)) {
28+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File path must be within the project directory' }) }], isError: true };
29+
}
30+
}
31+
2332
let stat: fs.Stats;
2433
try { stat = fs.statSync(resolved); } catch {
2534
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };

src/api/tools/skills/add-attachment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export function register(server: McpServer, mgr: SkillGraphManager): void {
2020
},
2121
async ({ skillId, filePath }) => {
2222
const resolved = path.resolve(filePath);
23+
24+
const projectDir = mgr.projectDir;
25+
if (projectDir) {
26+
const normalizedProject = path.resolve(projectDir) + path.sep;
27+
if (!resolved.startsWith(normalizedProject) && resolved !== path.resolve(projectDir)) {
28+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File path must be within the project directory' }) }], isError: true };
29+
}
30+
}
31+
2332
let stat: fs.Stats;
2433
try { stat = fs.statSync(resolved); } catch {
2534
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };

src/api/tools/tasks/add-attachment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export function register(server: McpServer, mgr: TaskGraphManager): void {
2020
},
2121
async ({ taskId, filePath }) => {
2222
const resolved = path.resolve(filePath);
23+
24+
const projectDir = mgr.projectDir;
25+
if (projectDir) {
26+
const normalizedProject = path.resolve(projectDir) + path.sep;
27+
if (!resolved.startsWith(normalizedProject) && resolved !== path.resolve(projectDir)) {
28+
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File path must be within the project directory' }) }], isError: true };
29+
}
30+
}
31+
2332
let stat: fs.Stats;
2433
try { stat = fs.statSync(resolved); } catch {
2534
return { content: [{ type: 'text', text: JSON.stringify({ error: 'File not found' }) }], isError: true };

src/graphs/knowledge.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ export class KnowledgeGraphManager {
520520
private _bm25Index: BM25Index<KnowledgeNodeAttributes>;
521521

522522
get externalGraphs(): ExternalGraphs { return this.ext; }
523+
get projectDir(): string | undefined { return this.ctx.projectDir; }
523524

524525
constructor(
525526
private _graph: KnowledgeGraph,

src/graphs/skill.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,8 @@ export class SkillGraphManager {
746746
private mirrorTracker?: MirrorWriteTracker;
747747
private _bm25Index: BM25Index<SkillNodeAttributes>;
748748

749+
get projectDir(): string | undefined { return this.ctx.projectDir; }
750+
749751
constructor(
750752
private _graph: SkillGraph,
751753
private embedFns: EmbedFns,

src/graphs/task.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,8 @@ export class TaskGraphManager {
718718
private mirrorTracker?: MirrorWriteTracker;
719719
private _bm25Index: BM25Index<TaskNodeAttributes>;
720720

721+
get projectDir(): string | undefined { return this.ctx.projectDir; }
722+
721723
constructor(
722724
private _graph: TaskGraph,
723725
private embedFns: EmbedFns,

src/tests/mcp-attachments.test.ts

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,13 @@ function createTmpFile(dir: string, name: string, content: string): string {
4242
describe('MCP note attachment tools', () => {
4343
let ctx: McpTestContext;
4444
let noteId: string;
45-
let tmpDir: string;
45+
let projectDir: string;
4646
let filePath: string;
4747

4848
beforeAll(async () => {
49-
tmpDir = makeTmpDir();
50-
filePath = createTmpFile(tmpDir, 'doc.txt', 'Hello attachment');
49+
projectDir = makeTmpDir();
50+
filePath = createTmpFile(projectDir, 'doc.txt', 'Hello attachment');
5151

52-
const projectDir = makeTmpDir();
5352
const knowledgeGraph = createKnowledgeGraph();
5453
noteId = createNote(knowledgeGraph, 'Test Note', 'content', [], unitVec(0));
5554

@@ -62,7 +61,7 @@ describe('MCP note attachment tools', () => {
6261

6362
afterAll(async () => {
6463
await ctx.close();
65-
fs.rmSync(tmpDir, { recursive: true, force: true });
64+
fs.rmSync(projectDir, { recursive: true, force: true });
6665
});
6766

6867
it('add_note_attachment: attaches file successfully', async () => {
@@ -74,17 +73,28 @@ describe('MCP note attachment tools', () => {
7473
});
7574

7675
it('add_note_attachment: file not found returns error', async () => {
77-
const result = await ctx.call('add_note_attachment', { noteId, filePath: '/nonexistent/file.txt' });
76+
const missing = path.join(projectDir, 'nonexistent.txt');
77+
const result = await ctx.call('add_note_attachment', { noteId, filePath: missing });
7878
expect(result.isError).toBe(true);
7979
expect(text(result)).toContain('File not found');
8080
});
8181

8282
it('add_note_attachment: directory returns error', async () => {
83-
const result = await ctx.call('add_note_attachment', { noteId, filePath: tmpDir });
83+
const subDir = path.join(projectDir, 'subdir');
84+
fs.mkdirSync(subDir, { recursive: true });
85+
const result = await ctx.call('add_note_attachment', { noteId, filePath: subDir });
8486
expect(result.isError).toBe(true);
8587
expect(text(result)).toContain('not a regular file');
8688
});
8789

90+
it('add_note_attachment: path traversal rejected', async () => {
91+
const outside = createTmpFile(os.tmpdir(), 'evil.txt', 'secrets');
92+
const result = await ctx.call('add_note_attachment', { noteId, filePath: outside });
93+
expect(result.isError).toBe(true);
94+
expect(text(result)).toContain('within the project directory');
95+
fs.unlinkSync(outside);
96+
});
97+
8898
it('add_note_attachment: note not found returns error', async () => {
8999
const result = await ctx.call('add_note_attachment', { noteId: 'ghost-note', filePath });
90100
expect(result.isError).toBe(true);
@@ -113,14 +123,13 @@ describe('MCP note attachment tools', () => {
113123
describe('MCP task attachment tools', () => {
114124
let ctx: McpTestContext;
115125
let taskId: string;
116-
let tmpDir: string;
126+
let projectDir: string;
117127
let filePath: string;
118128

119129
beforeAll(async () => {
120-
tmpDir = makeTmpDir();
121-
filePath = createTmpFile(tmpDir, 'report.csv', 'id,name\n1,alice');
130+
projectDir = makeTmpDir();
131+
filePath = createTmpFile(projectDir, 'report.csv', 'id,name\n1,alice');
122132

123-
const projectDir = makeTmpDir();
124133
const taskGraph = createTaskGraph();
125134
taskId = createTask(taskGraph, 'Test Task', 'desc', 'todo', 'medium', [], unitVec(0));
126135

@@ -133,7 +142,7 @@ describe('MCP task attachment tools', () => {
133142

134143
afterAll(async () => {
135144
await ctx.close();
136-
fs.rmSync(tmpDir, { recursive: true, force: true });
145+
fs.rmSync(projectDir, { recursive: true, force: true });
137146
});
138147

139148
it('add_task_attachment: attaches file', async () => {
@@ -144,13 +153,24 @@ describe('MCP task attachment tools', () => {
144153
});
145154

146155
it('add_task_attachment: file not found', async () => {
147-
const result = await ctx.call('add_task_attachment', { taskId, filePath: '/no/file.txt' });
156+
const missing = path.join(projectDir, 'nope.txt');
157+
const result = await ctx.call('add_task_attachment', { taskId, filePath: missing });
148158
expect(result.isError).toBe(true);
149159
});
150160

151161
it('add_task_attachment: directory returns error', async () => {
152-
const result = await ctx.call('add_task_attachment', { taskId, filePath: tmpDir });
162+
const subDir = path.join(projectDir, 'subdir');
163+
fs.mkdirSync(subDir, { recursive: true });
164+
const result = await ctx.call('add_task_attachment', { taskId, filePath: subDir });
165+
expect(result.isError).toBe(true);
166+
});
167+
168+
it('add_task_attachment: path traversal rejected', async () => {
169+
const outside = createTmpFile(os.tmpdir(), 'evil.csv', 'stolen');
170+
const result = await ctx.call('add_task_attachment', { taskId, filePath: outside });
153171
expect(result.isError).toBe(true);
172+
expect(text(result)).toContain('within the project directory');
173+
fs.unlinkSync(outside);
154174
});
155175

156176
it('remove_task_attachment: removes', async () => {
@@ -173,14 +193,13 @@ describe('MCP task attachment tools', () => {
173193
describe('MCP skill attachment tools', () => {
174194
let ctx: McpTestContext;
175195
let skillId: string;
176-
let tmpDir: string;
196+
let projectDir: string;
177197
let filePath: string;
178198

179199
beforeAll(async () => {
180-
tmpDir = makeTmpDir();
181-
filePath = createTmpFile(tmpDir, 'template.yaml', 'key: value');
200+
projectDir = makeTmpDir();
201+
filePath = createTmpFile(projectDir, 'template.yaml', 'key: value');
182202

183-
const projectDir = makeTmpDir();
184203
const skillGraph = createSkillGraph();
185204
skillId = createSkill(skillGraph, 'Test Skill', 'desc', [], [], [], [], [], 'user', 1, unitVec(0));
186205

@@ -193,7 +212,7 @@ describe('MCP skill attachment tools', () => {
193212

194213
afterAll(async () => {
195214
await ctx.close();
196-
fs.rmSync(tmpDir, { recursive: true, force: true });
215+
fs.rmSync(projectDir, { recursive: true, force: true });
197216
});
198217

199218
it('add_skill_attachment: attaches file', async () => {
@@ -204,13 +223,24 @@ describe('MCP skill attachment tools', () => {
204223
});
205224

206225
it('add_skill_attachment: file not found', async () => {
207-
const result = await ctx.call('add_skill_attachment', { skillId, filePath: '/missing/file' });
226+
const missing = path.join(projectDir, 'missing.file');
227+
const result = await ctx.call('add_skill_attachment', { skillId, filePath: missing });
208228
expect(result.isError).toBe(true);
209229
});
210230

211231
it('add_skill_attachment: directory returns error', async () => {
212-
const result = await ctx.call('add_skill_attachment', { skillId, filePath: tmpDir });
232+
const subDir = path.join(projectDir, 'subdir');
233+
fs.mkdirSync(subDir, { recursive: true });
234+
const result = await ctx.call('add_skill_attachment', { skillId, filePath: subDir });
235+
expect(result.isError).toBe(true);
236+
});
237+
238+
it('add_skill_attachment: path traversal rejected', async () => {
239+
const outside = createTmpFile(os.tmpdir(), 'evil.yaml', 'stolen');
240+
const result = await ctx.call('add_skill_attachment', { skillId, filePath: outside });
213241
expect(result.isError).toBe(true);
242+
expect(text(result)).toContain('within the project directory');
243+
fs.unlinkSync(outside);
214244
});
215245

216246
it('remove_skill_attachment: removes', async () => {

0 commit comments

Comments
 (0)