< 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>
166 lines
4.9 KiB
TypeScript
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;
|
|
}
|