diff --git a/modules/jarvos-gbrain/src/index.js b/modules/jarvos-gbrain/src/index.js index cdc65d42..445eadd3 100644 --- a/modules/jarvos-gbrain/src/index.js +++ b/modules/jarvos-gbrain/src/index.js @@ -209,8 +209,23 @@ function resolveSourcePath(item, config) { const sourcePath = firstString(item.sourcePath, item.path); if (!sourcePath) return null; const expanded = expandTilde(sourcePath); - if (path.isAbsolute(expanded)) return expanded; - return path.join(config.vaultDir, expanded); + const vaultDir = path.resolve(config.vaultDir); + const resolved = path.resolve(vaultDir, expanded); + const relative = path.relative(vaultDir, resolved); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null; + return resolved; +} + +function resolveRealSourcePath(sourcePath, config) { + try { + const vaultDir = fs.realpathSync(config.vaultDir); + const resolved = fs.realpathSync(sourcePath); + const relative = path.relative(vaultDir, resolved); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null; + return resolved; + } catch { + return null; + } } function targetPathForItem(item, config) { @@ -354,20 +369,25 @@ function createImportPlan(overrides = {}) { const sourcePath = resolveSourcePath(item, config); if (!sourcePath) { - warnings.push(`Item ${index} is missing sourcePath`); + warnings.push(`Item ${index} has a missing or out-of-vault sourcePath`); continue; } if (!fs.existsSync(sourcePath)) { warnings.push(`Item ${index} source does not exist: ${sourcePath}`); continue; } + const realSourcePath = resolveRealSourcePath(sourcePath, config); + if (!realSourcePath) { + warnings.push(`Item ${index} source is outside the vault: ${sourcePath}`); + continue; + } const targetPath = targetPathForItem(item, config); items.push({ index, type: pageType, title: firstString(item.title, path.basename(sourcePath, '.md')), - sourcePath, + sourcePath: realSourcePath, targetPath, slug: path.basename(targetPath, '.md'), tags: Array.isArray(item.tags) ? item.tags : [], diff --git a/modules/jarvos-gbrain/test/gbrain.test.js b/modules/jarvos-gbrain/test/gbrain.test.js index 8f8e342f..c33a499b 100644 --- a/modules/jarvos-gbrain/test/gbrain.test.js +++ b/modules/jarvos-gbrain/test/gbrain.test.js @@ -157,6 +157,51 @@ test('createImportPlan maps curated manifest items to GBrain targets', () => { assert.equal(plan.warnings.length, 1); }); +test('createImportPlan rejects source paths outside the configured vault', () => { + const root = tempDir(); + const vault = path.join(root, 'vault'); + const brain = path.join(root, 'brain'); + const outsideNote = path.join(root, 'secret.md'); + const manifestPath = path.join(root, 'manifest.json'); + + fs.mkdirSync(vault, { recursive: true }); + fs.writeFileSync(outsideNote, 'must not be imported', 'utf8'); + fs.writeFileSync(manifestPath, JSON.stringify({ + version: 1, + items: [ + { type: 'source', sourcePath: '../secret.md' }, + { type: 'source', sourcePath: outsideNote }, + ], + }), 'utf8'); + + const plan = gbrain.createImportPlan({ vaultDir: vault, brainDir: brain, manifestPath }); + + assert.equal(plan.itemCount, 0); + assert.equal(plan.warnings.length, 2); + assert.ok(plan.warnings.every((warning) => /out-of-vault/.test(warning))); +}); + +test('createImportPlan rejects symlinks that escape the configured vault', () => { + const root = tempDir(); + const vault = path.join(root, 'vault'); + const outsideNote = path.join(root, 'secret.md'); + const link = path.join(vault, 'linked-secret.md'); + const manifestPath = path.join(root, 'manifest.json'); + + fs.mkdirSync(vault, { recursive: true }); + fs.writeFileSync(outsideNote, 'must not be imported', 'utf8'); + fs.symlinkSync(outsideNote, link); + fs.writeFileSync(manifestPath, JSON.stringify({ + version: 1, + items: [{ type: 'source', sourcePath: 'linked-secret.md' }], + }), 'utf8'); + + const plan = gbrain.createImportPlan({ vaultDir: vault, manifestPath }); + + assert.equal(plan.itemCount, 0); + assert.match(plan.warnings[0], /outside the vault/); +}); + test('importToBrain dry-run does not write generated pages', () => { const root = tempDir(); const vault = path.join(root, 'vault');