feat: implement Obsidian MCP Bundle MVP (Phase 1-3)
- Complete project setup with TypeScript, Jest, MCPB manifest - Implement foundational infrastructure (CLI executor, logger, error handler) - Add 9 file operation tools for User Story 1 - Full MCP protocol compliance with stdio transport - Input validation and sanitization for security - Comprehensive error handling with actionable messages - Constitutional compliance: all 6 principles satisfied MVP includes: - obsidian_create_note, read, append, prepend, delete, move, rename, open, file_info - Zod validation schemas for all parameters - 30s timeout configuration with per-command overrides - Stderr-only logging with sanitized output - Graceful shutdown handling Build: ✅ 0 errors, 0 vulnerabilities Tasks: 48/167 complete (MVP milestone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
155
src/validation/sanitizer.ts
Normal file
155
src/validation/sanitizer.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Parameter Sanitizer
|
||||
* Constitutional Principle III: Local Execution Security
|
||||
* Remove dangerous characters and validate inputs before CLI execution
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Characters that should be removed or escaped for security
|
||||
*/
|
||||
const DANGEROUS_CHARS = /[;&|`$(){}[\]<>]/g;
|
||||
const COMMAND_INJECTION_PATTERNS = [
|
||||
/\$\(/g, // Command substitution $(...)
|
||||
/`[^`]*`/g, // Command substitution `...`
|
||||
/\|\|/g, // OR operator
|
||||
/&&/g, // AND operator
|
||||
/;/g, // Command separator
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitize a single string parameter
|
||||
*/
|
||||
export function sanitizeString(input: string): string {
|
||||
if (typeof input !== 'string') {
|
||||
logger.warn('sanitizeString received non-string input', { type: typeof input });
|
||||
return String(input);
|
||||
}
|
||||
|
||||
// Remove null bytes
|
||||
let sanitized = input.replace(/\0/g, '');
|
||||
|
||||
// Check for command injection patterns
|
||||
for (const pattern of COMMAND_INJECTION_PATTERNS) {
|
||||
if (pattern.test(sanitized)) {
|
||||
logger.warn('Potential command injection detected', {
|
||||
pattern: pattern.toString(),
|
||||
});
|
||||
sanitized = sanitized.replace(pattern, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dangerous characters
|
||||
sanitized = sanitized.replace(DANGEROUS_CHARS, '');
|
||||
|
||||
// Trim whitespace
|
||||
sanitized = sanitized.trim();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a file path
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
if (typeof path !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove null bytes
|
||||
let sanitized = path.replace(/\0/g, '');
|
||||
|
||||
// Remove path traversal attempts
|
||||
sanitized = sanitized.replace(/\.\./g, '');
|
||||
|
||||
// Remove leading/trailing slashes (relative paths only)
|
||||
sanitized = sanitized.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Remove dangerous characters but allow path separators
|
||||
sanitized = sanitized.replace(/[;&|`$(){}[\]<>]/g, '');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a tag (must start with #)
|
||||
*/
|
||||
export function sanitizeTag(tag: string): string {
|
||||
if (typeof tag !== 'string') {
|
||||
return '#';
|
||||
}
|
||||
|
||||
let sanitized = tag.trim();
|
||||
|
||||
// Ensure tag starts with #
|
||||
if (!sanitized.startsWith('#')) {
|
||||
sanitized = '#' + sanitized;
|
||||
}
|
||||
|
||||
// Allow only alphanumeric, hyphens, underscores, and forward slashes
|
||||
sanitized = sanitized.replace(/[^#a-zA-Z0-9_/-]/g, '');
|
||||
|
||||
// Remove empty tags
|
||||
if (sanitized === '#') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize object parameters recursively
|
||||
*/
|
||||
export function sanitizeParameters(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (typeof value === 'string') {
|
||||
// Special handling for different parameter types
|
||||
if (key === 'tag' || key.startsWith('tag')) {
|
||||
sanitized[key] = sanitizeTag(value);
|
||||
} else if (key === 'path' || key.endsWith('Path') || key.endsWith('path')) {
|
||||
sanitized[key] = sanitizePath(value);
|
||||
} else {
|
||||
sanitized[key] = sanitizeString(value);
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Recursively sanitize nested objects
|
||||
sanitized[key] = sanitizeParameters(value as Record<string, unknown>);
|
||||
} else if (Array.isArray(value)) {
|
||||
// Sanitize array elements
|
||||
sanitized[key] = value.map((item) =>
|
||||
typeof item === 'string' ? sanitizeString(item) : item
|
||||
);
|
||||
} else {
|
||||
// Keep other types as-is (numbers, booleans, null)
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that parameters don't contain shell metacharacters
|
||||
*/
|
||||
export function containsDangerousCharacters(input: string): boolean {
|
||||
if (typeof input !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dangerous characters
|
||||
if (DANGEROUS_CHARS.test(input)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for command injection patterns
|
||||
for (const pattern of COMMAND_INJECTION_PATTERNS) {
|
||||
if (pattern.test(input)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user