Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions src/lib/writer.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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}`);
Expand All @@ -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}`);
}
Expand All @@ -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}`);
}
Expand All @@ -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<string, unknown>;
Expand Down
28 changes: 28 additions & 0 deletions test/writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/);
});
});
});