diff --git a/src/lib/writer.ts b/src/lib/writer.ts index 481cedd..d682e6e 100644 --- a/src/lib/writer.ts +++ b/src/lib/writer.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs'; -import { join, basename } from 'path'; +import { basename, dirname, isAbsolute, relative, resolve } from 'path'; import matter from 'gray-matter'; import type { Store } from './store.js'; @@ -16,15 +16,28 @@ export class VaultWriter { private store: Store, ) {} - createNode(opts: CreateNodeOptions): string { - const dir = opts.directory - ? join(this.vaultPath, opts.directory) - : this.vaultPath; - mkdirSync(dir, { recursive: true }); + /** + * Resolves a vault-relative path to an absolute path, guaranteeing the + * result stays within vaultPath. Rejects `../` segments (and absolute + * path overrides) that would otherwise let a caller escape the vault. + */ + private resolveInVault(relPath: string): string { + const absPath = resolve(this.vaultPath, relPath); + const rel = relative(this.vaultPath, absPath); + + if (rel.startsWith('..') || isAbsolute(rel)) { + throw new Error(`Path escape attempt: ${relPath}`); + } + + return absPath; + } + createNode(opts: CreateNodeOptions): string { const filename = `${opts.title}.md`; const relPath = opts.directory ? `${opts.directory}/${filename}` : filename; - const absPath = join(dir, filename); + const absPath = this.resolveInVault(relPath); + + mkdirSync(dirname(absPath), { recursive: true }); if (existsSync(absPath)) { throw new Error(`File already exists: ${relPath}`); @@ -41,7 +54,7 @@ export class VaultWriter { } annotateNode(nodeId: string, content: string): void { - const absPath = join(this.vaultPath, nodeId); + const absPath = this.resolveInVault(nodeId); if (!existsSync(absPath)) { throw new Error(`Node not found: ${nodeId}`); } @@ -53,7 +66,7 @@ export class VaultWriter { } addLink(sourceId: string, targetRef: string, context: string): void { - const absPath = join(this.vaultPath, sourceId); + const absPath = this.resolveInVault(sourceId); if (!existsSync(absPath)) { throw new Error(`Source node not found: ${sourceId}`); } @@ -74,7 +87,7 @@ export class VaultWriter { } private indexFile(relPath: string): void { - const absPath = join(this.vaultPath, relPath); + const absPath = this.resolveInVault(relPath); const raw = readFileSync(absPath, 'utf-8'); let fm: Record; diff --git a/test/writer.test.ts b/test/writer.test.ts index 164a068..6eb45a9 100644 --- a/test/writer.test.ts +++ b/test/writer.test.ts @@ -86,6 +86,25 @@ describe('VaultWriter', () => { content: 'Duplicate.', })).toThrow(/already exists/); }); + + it('rejects a directory that escapes the vault via ../', () => { + expect(() => writer.createNode({ + title: 'Evil', + directory: '../../etc', + frontmatter: {}, + content: 'pwned', + })).toThrow(/Path escape attempt/); + + expect(existsSync(join(tempVault, '..', '..', 'etc', 'Evil.md'))).toBe(false); + }); + + it('rejects a title that escapes the vault via ../', () => { + expect(() => writer.createNode({ + title: '../../../tmp/evil', + frontmatter: {}, + content: 'pwned', + })).toThrow(/Path escape attempt/); + }); }); describe('annotateNode', () => { @@ -115,6 +134,10 @@ describe('VaultWriter', () => { it('throws if the node does not exist', () => { expect(() => writer.annotateNode('nonexistent.md', 'stuff')).toThrow(/not found/); }); + + it('rejects a nodeId that escapes the vault via ../', () => { + expect(() => writer.annotateNode('../../etc/passwd', 'pwned')).toThrow(/Path escape attempt/); + }); }); describe('addLink', () => { @@ -144,5 +167,10 @@ describe('VaultWriter', () => { const edges = store.getEdgesFrom('Source.md'); expect(edges.some(e => e.targetId === 'People/Alice Smith.md')).toBe(true); }); + + it('rejects a sourceId that escapes the vault via ../', () => { + expect(() => writer.addLink('../../etc/passwd', 'People/Alice Smith', 'pwned')) + .toThrow(/Path escape attempt/); + }); }); });