Files
obsidian-mcp/src/validation/sanitizer.ts
Peter.Morton 96b44ac97f fix: preserve < and > in note content so Mermaid arrows and HTML are not stripped (fixes #7)
< and > were in DANGEROUS_CHARS on the assumption they could trigger
shell redirection. However, shell redirection only applies at the
command level — inside double-quoted strings (which is how all values
are passed via formatParam) they are completely inert.

Removing them from DANGEROUS_CHARS and sanitizePath preserves:
- Mermaid diagram connectors: ->>, -->, <|, >>, etc.
- HTML tags in note content
- Any other angle-bracket syntax

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-28 12:21:24 -05:00

166 lines
4.9 KiB
TypeScript

/**
* 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
* Note: Brackets [], parentheses (), and braces {} are safe because values are quoted and passed as array args
* They're essential for Obsidian markdown (wikilinks [[link]], tasks - [ ] Task, templates {{...}}, etc.)
* Note: Single & is safe in quoted args (filenames like "Research & Development.md")
* Note: Backticks are safe because formatParam escapes them as \` inside double-quoted strings,
* preventing shell command substitution while preserving Markdown code fences (``` ```)
* Note: < and > are safe inside double-quoted strings — shell redirection only applies at the
* command level, not inside quotes. Stripping them breaks Mermaid arrows (->>, -->) and HTML.
* We only block: ; | $ (command separators, pipes, variable substitution)
* Command injection patterns (&&, ||, etc.) are handled separately
*/
const DANGEROUS_CHARS = /[;|$]/g;
const COMMAND_INJECTION_PATTERNS = [
/\$\(/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
// Note: Brackets, parentheses, braces, and single & are safe in paths (quoted args)
// Note: < and > are safe inside double-quoted strings (not shell redirects)
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;
}