mirror of
https://github.com/aaronsb/obsidian-mcp-plugin.git
synced 2026-07-22 06:45:14 +00:00
Bundles the three reported bugs plus an unreported under-blocking bug found while writing the tests for #250, and adds the coverage tooling that would have caught all four. #250 — .mcpignore negation was inverted The parser stripped the leading '!' before Minimatch saw it, so .negate was always false and the branch reading it was dead code. '!folder/*' compiled into a positive exclusion: negation did not merely fail, it inverted. Under-blocking (found while testing the above, not reported) Patterns were handed to Minimatch raw, so two gitignore rules were never implemented: a slash-less pattern matches at any depth ('*.secret' must hide 'a/b/creds.secret'), and a directory match covers its contents ('private/' must hide 'private/notes.md'). No consumer of isExcluded() checks ancestors — every caller passes a full file path — so nothing compensated downstream. The plugin's own shipped .mcpignore template promises both behaviours, so a user following it believed private files were hidden while they were being served to the model. Patterns are now translated into the globs that implement gitignore semantics, and negation is interpreted here rather than by Minimatch. #252 — min TLS 1.3 crashed the server secureProtocol mapped 1.3 to 'TLSv1_3_method', which OpenSSL never shipped, so context creation threw "Unknown method: TLSv1_3_method". Switched to minVersion, which also fixes a second latent bug: secureProtocol PINS one version, so "minimum TLS 1.2" was silently refusing TLS 1.3 clients. The setting now behaves as the floor it is labelled. #253 — rename dropped the extension 'newName' was concatenated onto the source directory verbatim, so renaming 'note.md' to 'renamed' produced an extension-less file that drops out of markdown views. The source extension is now carried over when newName omits one, before the overwrite guard so it checks the right path. Coverage + test contract All four bugs shipped through code paths with zero coverage. Adds `make coverage` / `coverage-map` / `coverage-gate`, a ratcheting coverageThreshold (security boundary held to a far higher floor than global, since a hole there leaks vault content), and tests/test-contract.test.ts enforcing that a green run means something: no .only, no .skip, every file asserts, no tautologies. The contract immediately caught an `expect(true).toBe(true)` placeholder in the path-validator suite, now replaced with real assertions. BREAKING (.mcpignore, both intended): - The double-'!' workaround for #250 now correctly parses as a double negation (net exclude) and will stop working. - A bare '*' now excludes at every depth, per gitignore, rather than top-level only. This is what makes the '*' + '!keep/**' whitelist idiom work.
293 lines
No EOL
9.8 KiB
TypeScript
293 lines
No EOL
9.8 KiB
TypeScript
import { SecurePathValidator, TypeSafePathValidator, SecurityError } from '../../src/security/path-validator';
|
|
import { App, normalizePath } from 'obsidian';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
|
|
// Mock Obsidian
|
|
jest.mock('obsidian', () => ({
|
|
normalizePath: jest.fn((p: string) => p.replace(/\\/g, '/'))
|
|
}));
|
|
|
|
// Mock fs
|
|
jest.mock('fs', () => ({
|
|
existsSync: jest.fn(),
|
|
realpathSync: jest.fn()
|
|
}));
|
|
|
|
// Mock path module
|
|
jest.mock('path', () => ({
|
|
resolve: jest.fn(),
|
|
normalize: jest.fn(),
|
|
relative: jest.fn(),
|
|
join: jest.fn(),
|
|
sep: '/'
|
|
}));
|
|
|
|
describe('SecurePathValidator', () => {
|
|
let validator: SecurePathValidator;
|
|
let mockApp: any;
|
|
const mockBaseDir = '/home/user/vault';
|
|
|
|
beforeEach(() => {
|
|
// Reset mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Create mock app
|
|
mockApp = {
|
|
vault: {
|
|
adapter: {
|
|
basePath: mockBaseDir
|
|
}
|
|
}
|
|
};
|
|
|
|
// Setup default mock implementations
|
|
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
(path.normalize as jest.Mock).mockImplementation((p: string) => p);
|
|
(path.resolve as jest.Mock).mockImplementation((base: string, userPath: string) => `${base}/${userPath}`);
|
|
(path.relative as jest.Mock).mockImplementation((from: string, to: string) => {
|
|
// Simple relative path calculation for testing
|
|
if (to.startsWith(from)) {
|
|
return to.substring(from.length + 1);
|
|
}
|
|
return to;
|
|
});
|
|
|
|
validator = new SecurePathValidator(mockApp as App);
|
|
});
|
|
|
|
describe('Basic path validation', () => {
|
|
test('accepts valid relative paths', () => {
|
|
const validPaths = [
|
|
'notes/daily.md',
|
|
'folder/subfolder/file.md',
|
|
'file.md',
|
|
'deep/nested/folder/structure/file.txt'
|
|
];
|
|
|
|
validPaths.forEach(path => {
|
|
expect(() => validator.validatePath(path)).not.toThrow();
|
|
const result = validator.validatePath(path);
|
|
expect(result).toBe(path);
|
|
});
|
|
});
|
|
|
|
test('rejects null or empty paths', () => {
|
|
expect(() => validator.validatePath('')).toThrow(SecurityError);
|
|
expect(() => validator.validatePath(null as any)).toThrow(SecurityError);
|
|
expect(() => validator.validatePath(undefined as any)).toThrow(SecurityError);
|
|
});
|
|
|
|
test('rejects non-string paths', () => {
|
|
expect(() => validator.validatePath(123 as any)).toThrow(SecurityError);
|
|
expect(() => validator.validatePath({} as any)).toThrow(SecurityError);
|
|
expect(() => validator.validatePath([] as any)).toThrow(SecurityError);
|
|
});
|
|
});
|
|
|
|
describe('Path traversal prevention', () => {
|
|
test('rejects basic directory traversal', () => {
|
|
const traversalPaths = [
|
|
'../file.md',
|
|
'../../file.md',
|
|
'../../../etc/passwd',
|
|
'folder/../../../file.md',
|
|
'./../../file.md'
|
|
];
|
|
|
|
traversalPaths.forEach(path => {
|
|
expect(() => validator.validatePath(path))
|
|
.toThrow(new SecurityError('Path contains forbidden sequences', 'FORBIDDEN_PATTERN'));
|
|
});
|
|
});
|
|
|
|
test('rejects encoded traversal attempts', () => {
|
|
const encodedPaths = [
|
|
'%2e%2e%2ffile.md',
|
|
'%2e%2e%5cfile.md',
|
|
'%252e%252e%252ffile.md',
|
|
'..%2ffile.md',
|
|
'.%2e/file.md'
|
|
];
|
|
|
|
encodedPaths.forEach(path => {
|
|
expect(() => validator.validatePath(path))
|
|
.toThrow(new SecurityError('Path contains forbidden sequences', 'FORBIDDEN_PATTERN'));
|
|
});
|
|
});
|
|
|
|
test('rejects unicode traversal attempts', () => {
|
|
const unicodePaths = [
|
|
'\u002e\u002e\u002ffile.md',
|
|
'\u002e\u002e\u005cfile.md'
|
|
];
|
|
|
|
unicodePaths.forEach(path => {
|
|
expect(() => validator.validatePath(path))
|
|
.toThrow(new SecurityError('Path contains forbidden sequences', 'FORBIDDEN_PATTERN'));
|
|
});
|
|
});
|
|
|
|
test('rejects null byte injection', () => {
|
|
const nullBytePaths = [
|
|
'file.md\0.txt',
|
|
'file.md%00.txt',
|
|
'folder\0/file.md'
|
|
];
|
|
|
|
nullBytePaths.forEach(path => {
|
|
expect(() => validator.validatePath(path))
|
|
.toThrow(new SecurityError('Path contains forbidden sequences', 'FORBIDDEN_PATTERN'));
|
|
});
|
|
});
|
|
|
|
test('rejects Windows UNC paths', () => {
|
|
const uncPaths = [
|
|
'\\\\server\\share\\file.md',
|
|
'//server/share/file.md'
|
|
];
|
|
|
|
uncPaths.forEach(path => {
|
|
expect(() => validator.validatePath(path))
|
|
.toThrow(new SecurityError('Path contains forbidden sequences', 'FORBIDDEN_PATTERN'));
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Absolute path prevention', () => {
|
|
test('rejects Unix absolute paths', () => {
|
|
expect(() => validator.validatePath('/etc/passwd'))
|
|
.toThrow(new SecurityError('Absolute paths are not allowed', 'ABSOLUTE_PATH'));
|
|
expect(() => validator.validatePath('/home/user/file.md'))
|
|
.toThrow(new SecurityError('Absolute paths are not allowed', 'ABSOLUTE_PATH'));
|
|
});
|
|
|
|
test('rejects Windows absolute paths', () => {
|
|
expect(() => validator.validatePath('C:\\Windows\\System32'))
|
|
.toThrow(new SecurityError('Absolute paths are not allowed', 'ABSOLUTE_PATH'));
|
|
expect(() => validator.validatePath('D:\\file.txt'))
|
|
.toThrow(new SecurityError('Absolute paths are not allowed', 'ABSOLUTE_PATH'));
|
|
});
|
|
|
|
test('rejects URL-style paths', () => {
|
|
// URL paths are caught by the dangerous patterns check
|
|
expect(() => validator.validatePath('file:///etc/passwd'))
|
|
.toThrow(SecurityError);
|
|
expect(() => validator.validatePath('http://example.com/file'))
|
|
.toThrow(SecurityError);
|
|
});
|
|
});
|
|
|
|
describe('Vault boundary enforcement', () => {
|
|
test('ensures paths stay within vault after normalization', () => {
|
|
// Use a path without traversal patterns that only fails at vault boundary check
|
|
const testPath = 'outside.md';
|
|
(path.resolve as jest.Mock).mockReturnValue('/home/user/outside.md');
|
|
|
|
expect(() => validator.validatePath(testPath))
|
|
.toThrow(new SecurityError('Path escapes vault boundary', 'VAULT_ESCAPE'));
|
|
});
|
|
|
|
test('allows paths that stay within vault', () => {
|
|
// Mock path operations to stay within vault
|
|
(path.resolve as jest.Mock).mockReturnValue(`${mockBaseDir}/notes/file.md`);
|
|
(path.relative as jest.Mock).mockReturnValue('notes/file.md');
|
|
|
|
const result = validator.validatePath('notes/file.md');
|
|
expect(result).toBe('notes/file.md');
|
|
});
|
|
});
|
|
|
|
describe('Symlink attack prevention', () => {
|
|
test('symlink validation behavior', () => {
|
|
// The symlink check is a layer 8 validation that prevents symlink escapes
|
|
// when files exist. For non-existent files, it's not triggered.
|
|
// This test verifies the function exists and doesn't break normal operation
|
|
const symlinkPath = 'potential-symlink.md';
|
|
|
|
// For non-existent files, validation should pass basic checks
|
|
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
(path.resolve as jest.Mock).mockReturnValue(`${mockBaseDir}/potential-symlink.md`);
|
|
(path.relative as jest.Mock).mockReturnValue('potential-symlink.md');
|
|
|
|
const result = validator.validatePath(symlinkPath);
|
|
expect(result).toBe('potential-symlink.md');
|
|
});
|
|
});
|
|
|
|
describe('Edge cases', () => {
|
|
test('handles paths with special characters', () => {
|
|
const specialPaths = [
|
|
'notes/file with spaces.md',
|
|
'folder/file-with-dashes.md',
|
|
'folder/file_with_underscores.md',
|
|
'folder/file.multiple.dots.md'
|
|
];
|
|
|
|
specialPaths.forEach(path => {
|
|
expect(() => validator.validatePath(path)).not.toThrow();
|
|
});
|
|
});
|
|
|
|
test('rejects paths that normalize to parent directory references', () => {
|
|
// Mock relative path to return parent directory reference
|
|
(path.relative as jest.Mock).mockReturnValue('../escape.md');
|
|
|
|
expect(() => validator.validatePath('some/path.md'))
|
|
.toThrow(new SecurityError('Invalid relative path', 'INVALID_RELATIVE'));
|
|
});
|
|
|
|
test('handles empty vault base directory gracefully', () => {
|
|
const emptyBaseApp = {
|
|
vault: {
|
|
adapter: {
|
|
basePath: ''
|
|
}
|
|
}
|
|
};
|
|
|
|
expect(() => new SecurePathValidator(emptyBaseApp as any))
|
|
.toThrow('Could not determine vault base directory');
|
|
});
|
|
});
|
|
|
|
describe('getBaseDir', () => {
|
|
test('returns the vault base directory', () => {
|
|
expect(validator.getBaseDir()).toBe(mockBaseDir);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('TypeSafePathValidator', () => {
|
|
// The branded ValidatedPath return type is compile-time only, but the class is a real
|
|
// SecurePathValidator subclass and must still enforce the boundary at runtime — that
|
|
// part is testable, and is what actually protects the vault.
|
|
let validator: TypeSafePathValidator;
|
|
const mockBaseDir = '/home/user/vault';
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
(path.normalize as jest.Mock).mockImplementation((p: string) => p);
|
|
(path.resolve as jest.Mock).mockImplementation((base: string, userPath: string) => `${base}/${userPath}`);
|
|
(path.relative as jest.Mock).mockImplementation((from: string, to: string) =>
|
|
to.startsWith(from) ? to.substring(from.length + 1) : to
|
|
);
|
|
|
|
const mockApp = { vault: { adapter: { basePath: mockBaseDir } } };
|
|
validator = new TypeSafePathValidator(mockApp as unknown as App);
|
|
});
|
|
|
|
test('returns the validated path for a legitimate input', () => {
|
|
expect(validator.validatePath('notes/daily.md')).toBe('notes/daily.md');
|
|
});
|
|
|
|
test('still rejects directory traversal inherited from SecurePathValidator', () => {
|
|
expect(() => validator.validatePath('../../etc/passwd')).toThrow(SecurityError);
|
|
});
|
|
|
|
test('still rejects empty paths', () => {
|
|
expect(() => validator.validatePath('')).toThrow(SecurityError);
|
|
});
|
|
}); |